diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c476cedf..56d56beb 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -46,15 +46,10 @@ class App extends StatelessWidget { ), ), BlocProvider( - create: (BuildContext context) => UserProfileCubit(), + create: (BuildContext context) => UserProfileCubit(injector.breezPreferences), ), - BlocProvider( - lazy: false, - create: (BuildContext context) => WebhookCubit( - injector.breezSdkLiquid, - injector.breezPreferences, - injector.notifications, - ), + BlocProvider( + create: (BuildContext context) => LnAddressCubitFactory.create(injector), ), BlocProvider( create: (BuildContext context) => CurrencyCubit(injector.breezSdkLiquid), diff --git a/lib/cubit/account/account_cubit.dart b/lib/cubit/account/account_cubit.dart index 7d582bd0..d274b77a 100644 --- a/lib/cubit/account/account_cubit.dart +++ b/lib/cubit/account/account_cubit.dart @@ -22,11 +22,11 @@ class AccountCubit extends Cubit with HydratedMixin void _listenAccountChanges() { _logger.info('Listening to account changes'); - breezSdkLiquid.walletInfoStream.distinct().listen( - (GetInfoResponse infoResponse) { + breezSdkLiquid.getInfoResponseStream.distinct().listen( + (GetInfoResponse getInfoResponse) { final AccountState newState = state.copyWith( - walletInfo: infoResponse.walletInfo, - blockchainInfo: infoResponse.blockchainInfo, + walletInfo: getInfoResponse.walletInfo, + blockchainInfo: getInfoResponse.blockchainInfo, ); _logger.info('AccountState changed: $newState'); emit(newState); diff --git a/lib/cubit/chain_swap/chain_swap_cubit.dart b/lib/cubit/chain_swap/chain_swap_cubit.dart index e5dc132c..c81c62f2 100644 --- a/lib/cubit/chain_swap/chain_swap_cubit.dart +++ b/lib/cubit/chain_swap/chain_swap_cubit.dart @@ -18,7 +18,7 @@ class ChainSwapCubit extends Cubit { } void _initializeChainSwapCubit() { - _breezSdkLiquid.walletInfoStream.first.then((_) => rescanOnchainSwaps()); + _breezSdkLiquid.getInfoResponseStream.first.then((_) => rescanOnchainSwaps()); } Future rescanOnchainSwaps() async { diff --git a/lib/cubit/cubit.dart b/lib/cubit/cubit.dart index 37d7b805..270ab9d7 100644 --- a/lib/cubit/cubit.dart +++ b/lib/cubit/cubit.dart @@ -8,6 +8,7 @@ export 'connectivity/connectivity_cubit.dart'; export 'csv_exporter.dart'; export 'currency/currency_cubit.dart'; export 'input/input_cubit.dart'; +export 'ln_address/ln_address_cubit.dart'; export 'lnurl/lnurl_cubit.dart'; export 'model/models.dart'; export 'payment_limits/payment_limits_cubit.dart'; @@ -15,4 +16,3 @@ export 'payments/payments_cubit.dart'; export 'refund/refund_cubit.dart'; export 'security/security_cubit.dart'; export 'user_profile/user_profile_cubit.dart'; -export 'webhook/webhook_cubit.dart'; diff --git a/lib/cubit/currency/currency_cubit.dart b/lib/cubit/currency/currency_cubit.dart index 177b601e..24770bff 100644 --- a/lib/cubit/currency/currency_cubit.dart +++ b/lib/cubit/currency/currency_cubit.dart @@ -16,8 +16,8 @@ class CurrencyCubit extends Cubit with HydratedMixin { + final BreezSDKLiquid breezSdkLiquid; + final BreezPreferences breezPreferences; + final LnUrlPayService lnAddressService; + final WebhookService webhookService; + + LnAddressCubit({ + required this.breezSdkLiquid, + required this.breezPreferences, + required this.lnAddressService, + required this.webhookService, + }) : super(const LnAddressState()); + + /// Sets up or updates the Lightning Address. + /// + /// - If [baseUsername] is provided, the function updates the Lightning Address username. + /// - Otherwise, it initializes a new Lightning Address or refreshes an existing one. + Future setupLightningAddress({String? baseUsername}) async { + final bool isUpdating = baseUsername != null; + final String actionMessage = + isUpdating ? 'Update LN Address Username to: $baseUsername' : 'Setup Lightning Address'; + _logger.info(actionMessage); + + emit( + state.copyWith( + status: isUpdating ? state.status : LnAddressStatus.loading, + updateStatus: isUpdating ? const LnAddressUpdateStatus(status: UpdateStatus.loading) : null, + ), + ); + + try { + final RegisterLnurlPayResponse registrationResponse = await _setupAndRegisterLnAddress( + baseUsername: baseUsername, + ); + + emit( + state.copyWith( + status: LnAddressStatus.success, + lnurl: registrationResponse.lnurl, + lnAddress: registrationResponse.lightningAddress, + updateStatus: isUpdating ? const LnAddressUpdateStatus(status: UpdateStatus.success) : null, + ), + ); + } catch (e, stackTrace) { + _logger.severe('Failed to $actionMessage', e, stackTrace); + final LnAddressStatus status = isUpdating ? state.status : LnAddressStatus.error; + final Object? error = isUpdating ? null : e; + final LnAddressUpdateStatus? updateStatus = + isUpdating ? _createErrorUpdateStatus(e, actionMessage) : null; + emit( + state.copyWith( + status: status, + error: error, + updateStatus: updateStatus, + ), + ); + } + } + + LnAddressUpdateStatus _createErrorUpdateStatus(Object e, String action) { + final String errorMessage = e is RegisterLnurlPayException + ? (e.responseBody?.isNotEmpty == true ? e.responseBody! : e.message) + : e is UsernameConflictException + ? e.toString() + : 'Failed to $action'; + + return LnAddressUpdateStatus( + status: UpdateStatus.error, + error: e, + errorMessage: errorMessage, + ); + } + + /// Registers or updates an LNURL webhook for a Lightning Address. + /// + /// - If [baseUsername] is provided, it updates the existing registration. + /// - Otherwise, it determines a suitable username and registers a new webhook. + Future _setupAndRegisterLnAddress({String? baseUsername}) async { + final WalletInfo walletInfo = await _getWalletInfo(); + final String webhookUrl = await _setupWebhook(walletInfo.pubkey); + final String? username = baseUsername ?? await _resolveUsername(); + final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String signature = await _generateWebhookSignature(time, webhookUrl, username); + final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( + time: time, + webhookUrl: webhookUrl, + signature: signature, + username: username, + ); + return await _registerLnurlWebhook( + pubKey: walletInfo.pubkey, + request: request, + ); + } + + Future _getWalletInfo() async { + final WalletInfo? walletInfo = (await breezSdkLiquid.instance?.getInfo())?.walletInfo; + if (walletInfo == null) { + throw Exception('Failed to retrieve wallet info'); + } + return walletInfo; + } + + /// Sets up a webhook for the given public key. + /// - Generates a new webhook URL, unregisters any existing webhook if needed, + /// - Registers the new webhook, and stores the webhook URL in preferences. + Future _setupWebhook(String pubKey) async { + _logger.info('Setting up webhook'); + final String webhookUrl = await webhookService.generateWebhookUrl(); + await _unregisterExistingWebhookIfNeeded(pubKey: pubKey, webhookUrl: webhookUrl); + await webhookService.register(webhookUrl); + await breezPreferences.setWebhookUrl(webhookUrl); + return webhookUrl; + } + + /// Checks if there is an existing webhook URL and unregisters it if different from the provided one. + Future _unregisterExistingWebhookIfNeeded({ + required String pubKey, + required String webhookUrl, + }) async { + final String? existingWebhook = await breezPreferences.webhookUrl; + if (existingWebhook != null && existingWebhook != webhookUrl) { + _logger.info('Unregistering existing webhook: $existingWebhook'); + await _unregisterWebhook(existingWebhook, pubKey); + breezPreferences.removeWebhookUrl(); + } + } + + /// Unregisters a webhook for a given public key. + Future _unregisterWebhook(String webhookUrl, String pubKey) async { + final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final String message = '$time-$webhookUrl'; + final String signature = await _signMessage(message); + + final UnregisterLnurlPayRequest invalidateWebhookRequest = UnregisterLnurlPayRequest( + time: time, + webhookUrl: webhookUrl, + signature: signature, + ); + + await lnAddressService.unregister(pubKey, invalidateWebhookRequest); + } + + /// Signs the given message with the private key. + Future _signMessage(String message) async { + _logger.info('Signing message: $message'); + + final SignMessageResponse? signMessageRes = breezSdkLiquid.instance?.signMessage( + req: SignMessageRequest(message: message), + ); + + if (signMessageRes == null) { + throw Exception('Failed to sign message'); + } + + _logger.info('Successfully signed message'); + return signMessageRes.signature; + } + + /// Resolves the appropriate username for LNURL registration. + /// + /// - If the webhook is not yet registered, it utilizes default profile name as username. + /// - If the webhook is already registered, it retrieves the stored username from [BreezPreferences]. + Future _resolveUsername() async { + final bool isLnUrlWebhookRegistered = await breezPreferences.isLnUrlWebhookRegistered; + + if (!isLnUrlWebhookRegistered) { + final String? profileName = await breezPreferences.profileName; + final String formattedUsername = UsernameFormatter.formatDefaultProfileName(profileName); + _logger.info('Registering LNURL Webhook: Using formatted profile name: $formattedUsername'); + return formattedUsername; + } + + // TODO(erdemyerebasmaz): Add null-handling to revert to the profile name if the stored username is null. + final String? storedUsername = await breezPreferences.lnAddressUsername; + _logger.info('Refreshing LNURL Webhook: Using stored username: $storedUsername'); + return storedUsername; + } + + /// Signs a webhook request message for authentication and validation purposes. + Future _generateWebhookSignature(int time, String webhookUrl, String? username) async { + _logger.info('Generating webhook signature'); + final String usernameComponent = username?.isNotEmpty == true ? '-$username' : ''; + final String message = '$time-$webhookUrl$usernameComponent'; + final String signature = await _signMessage(message); + _logger.info('Successfully generated webhook signature'); + return signature; + } + + /// Registers an LNURL webhook with the provided public key and request. + /// + /// - Saves the username to [BreezPreferences] if present and + /// - Sets webhook as registered on [BreezPreferences] if succeeds + Future _registerLnurlWebhook({ + required String pubKey, + required RegisterLnurlPayRequest request, + }) async { + final RegisterLnurlPayResponse registrationResponse = await lnAddressService.register( + pubKey: pubKey, + request: request, + ); + + final String? username = request.username; + if (username != null && username.isNotEmpty) { + await breezPreferences.setLnAddressUsername(username); + } + + _logger.info( + 'Successfully registered LNURL Webhook: $registrationResponse', + ); + + await breezPreferences.setLnUrlWebhookRegistered(); + return registrationResponse; + } + + void clearUpdateStatus() { + _logger.info('Clearing LnAddressUpdateStatus'); + emit( + state.copyWith( + updateStatus: const LnAddressUpdateStatus(), + ), + ); + } +} diff --git a/lib/cubit/ln_address/ln_address_state.dart b/lib/cubit/ln_address/ln_address_state.dart new file mode 100644 index 00000000..e01b4007 --- /dev/null +++ b/lib/cubit/ln_address/ln_address_state.dart @@ -0,0 +1,35 @@ +import 'package:l_breez/cubit/cubit.dart'; + +enum LnAddressStatus { initial, loading, success, error } + +class LnAddressState { + final String? lnurl; + final String? lnAddress; + final LnAddressStatus status; + final LnAddressUpdateStatus updateStatus; + final Object? error; + + const LnAddressState({ + this.lnurl, + this.lnAddress, + this.status = LnAddressStatus.initial, + this.updateStatus = const LnAddressUpdateStatus(), + this.error, + }); + + LnAddressState copyWith({ + String? lnurl, + String? lnAddress, + LnAddressStatus? status, + LnAddressUpdateStatus? updateStatus, + Object? error, + }) { + return LnAddressState( + lnurl: lnurl ?? this.lnurl, + lnAddress: lnAddress ?? this.lnAddress, + status: status ?? this.status, + updateStatus: updateStatus ?? this.updateStatus, + error: error ?? this.error, + ); + } +} diff --git a/lib/cubit/ln_address/models/exceptions.dart b/lib/cubit/ln_address/models/exceptions.dart new file mode 100644 index 00000000..da971e43 --- /dev/null +++ b/lib/cubit/ln_address/models/exceptions.dart @@ -0,0 +1,48 @@ +class RegisterLnurlPayException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + RegisterLnurlPayException(this.message, {this.statusCode, this.responseBody}); + + @override + String toString() => + 'RegisterLnurlPayException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; +} + +class UnregisterLnurlPayException implements Exception { + final String message; + + UnregisterLnurlPayException(this.message); + + @override + String toString() => message; +} + +class UsernameConflictException implements Exception { + @override + String toString() => 'Username is already taken'; +} + +class MaxRetriesExceededException implements Exception { + @override + String toString() => 'Maximum retry attempts exceeded'; +} + +class GenerateWebhookUrlException implements Exception { + final String message; + + GenerateWebhookUrlException(this.message); + + @override + String toString() => message; +} + +class RegisterWebhookException implements Exception { + final String message; + + RegisterWebhookException(this.message); + + @override + String toString() => message; +} diff --git a/lib/cubit/ln_address/models/ln_address_update_status.dart b/lib/cubit/ln_address/models/ln_address_update_status.dart new file mode 100644 index 00000000..6fbfa5dc --- /dev/null +++ b/lib/cubit/ln_address/models/ln_address_update_status.dart @@ -0,0 +1,25 @@ +enum UpdateStatus { initial, loading, success, error } + +class LnAddressUpdateStatus { + final UpdateStatus status; + final Object? error; + final String? errorMessage; + + const LnAddressUpdateStatus({ + this.status = UpdateStatus.initial, + this.error, + this.errorMessage, + }); + + LnAddressUpdateStatus copyWith({ + UpdateStatus? status, + Object? error, + String? errorMessage, + }) { + return LnAddressUpdateStatus( + status: status ?? this.status, + error: error ?? this.error, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} diff --git a/lib/cubit/ln_address/models/lnurl_pay_registration.dart b/lib/cubit/ln_address/models/lnurl_pay_registration.dart new file mode 100644 index 00000000..d97c0ab0 --- /dev/null +++ b/lib/cubit/ln_address/models/lnurl_pay_registration.dart @@ -0,0 +1,82 @@ +class RegisterLnurlPayRequest { + final String? username; + final int time; + final String webhookUrl; + final String signature; + + const RegisterLnurlPayRequest({ + required this.username, + required this.time, + required this.webhookUrl, + required this.signature, + }); + + RegisterLnurlPayRequest copyWith({ + String? username, + int? time, + String? webhookUrl, + String? signature, + }) { + return RegisterLnurlPayRequest( + username: username ?? this.username, + time: time ?? this.time, + webhookUrl: webhookUrl ?? this.webhookUrl, + signature: signature ?? this.signature, + ); + } + + Map toJson() { + return { + 'username': username, + 'time': time, + 'webhook_url': webhookUrl, + 'signature': signature, + }; + } + + @override + String toString() => 'username=$username, time=$time, webhook_url=$webhookUrl, signature=$signature'; +} + +class RegisterLnurlPayResponse { + final String lnurl; + final String lightningAddress; + + const RegisterLnurlPayResponse({ + required this.lnurl, + required this.lightningAddress, + }); + + factory RegisterLnurlPayResponse.fromJson(Map json) { + return RegisterLnurlPayResponse( + lnurl: json['lnurl'] as String, + lightningAddress: json['lightning_address'] as String? ?? '', + ); + } + + @override + String toString() => 'lnurl=$lnurl, lightning_address=$lightningAddress'; +} + +class UnregisterLnurlPayRequest { + final int time; + final String webhookUrl; + final String signature; + + const UnregisterLnurlPayRequest({ + required this.time, + required this.webhookUrl, + required this.signature, + }); + + Map toJson() { + return { + 'time': time, + 'webhook_url': webhookUrl, + 'signature': signature, + }; + } + + @override + String toString() => 'time=$time, webhook_url=$webhookUrl, signature=$signature'; +} diff --git a/lib/cubit/ln_address/models/models.dart b/lib/cubit/ln_address/models/models.dart new file mode 100644 index 00000000..2063b85d --- /dev/null +++ b/lib/cubit/ln_address/models/models.dart @@ -0,0 +1,3 @@ +export 'exceptions.dart'; +export 'ln_address_update_status.dart'; +export 'lnurl_pay_registration.dart'; diff --git a/lib/cubit/ln_address/services/lnurl_pay_service.dart b/lib/cubit/ln_address/services/lnurl_pay_service.dart new file mode 100644 index 00000000..bda095af --- /dev/null +++ b/lib/cubit/ln_address/services/lnurl_pay_service.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('LnUrlPayService'); + +class LnUrlPayService { + static const int _maxRetries = 3; + static const String _baseUrl = 'https://breez.fun'; + static final http.Client _client = http.Client(); + + // TODO(erdemyerebasmaz): Handle multiple device setup case + Future register({ + required String pubKey, + required RegisterLnurlPayRequest request, + }) async { + _logger.info('Registering lightning address for pubkey: $pubKey with request: $request'); + + // Register without retries if this is an update to existing LNURL Webhook + if (request.username?.isNotEmpty ?? false) { + return _register(pubKey: pubKey, request: request); + } + + // Register with retries if LNURL Webhook hasn't been registered yet + return _registerWithRetries(pubKey: pubKey, username: request.username ?? '', request: request); + } + + // TODO(erdemyerebasmaz): Optimize if current retry logic is insufficient + // If initial registration fails, up to [_maxRetries] registration attempts will be made on opening [ReceiveLightningAddressPage]. + // If these attempts also fail, the user can retry manually via a button, which will trigger another registration attempt with [_maxRetries] retries. + // + // Future improvements could include: + // - Retrying indefinitely with intervals until registration succeeds + // - Explicit handling of [UsernameConflictException] and LNURL server connectivity issues + // - Randomizing the default profile name itself after a set number of failures + // - Adding additional digits to the discriminator + Future _registerWithRetries({ + required String pubKey, + required String username, + required RegisterLnurlPayRequest request, + }) async { + for (int retryCount = 0; retryCount < _maxRetries; retryCount++) { + final String currentUsername = UsernameGenerator.generateUsername(username, retryCount); + try { + _logger.info('Attempt ${retryCount + 1}/$_maxRetries with username: $currentUsername'); + return await _register( + pubKey: pubKey, + request: request.copyWith(username: currentUsername), + ); + } on UsernameConflictException { + _logger.warning('Username conflict for: $currentUsername'); + } + } + + _logger.severe('Max retries exceeded for username registration'); + throw MaxRetriesExceededException(); + } + + Future _register({ + required String pubKey, + required RegisterLnurlPayRequest request, + }) async { + final Uri uri = Uri.parse('$_baseUrl/lnurlpay/$pubKey'); + _logger.fine('Sending registration request to: $uri'); + + try { + final http.Response response = await _client.post( + uri, + body: jsonEncode(request.toJson()), + ); + _logHttpResponse(response); + + if (response.statusCode == 200) { + return RegisterLnurlPayResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + if (response.statusCode == 409) { + throw UsernameConflictException(); + } + + throw RegisterLnurlPayException( + 'Server returned error response', + statusCode: response.statusCode, + responseBody: response.body, + ); + } catch (e, stackTrace) { + if (e is UsernameConflictException || e is RegisterLnurlPayException) { + rethrow; + } + + _logger.severe('Registration failed', e, stackTrace); + throw RegisterLnurlPayException(e.toString()); + } + } + + Future unregister(String pubKey, UnregisterLnurlPayRequest request) async { + _logger.info('Unregistering webhook: ${request.webhookUrl}'); + final Uri uri = Uri.parse('$_baseUrl/lnurlpay/$pubKey'); + + try { + final http.Response response = await _client.delete( + uri, + body: jsonEncode(request.toJson()), + ); + _logHttpResponse(response); + + if (response.statusCode != 200) { + throw UnregisterLnurlPayException(response.body); + } + + _logger.info('Successfully unregistered webhook'); + } catch (e, stackTrace) { + _logger.severe('Failed to unregister webhook', e, stackTrace); + throw UnregisterLnurlPayException(e.toString()); + } + } + + void _logHttpResponse(http.Response response) { + _logger.fine('Response status: ${response.statusCode}'); + _logger.fine('Response body: ${response.body}'); + } +} diff --git a/lib/cubit/ln_address/services/services.dart b/lib/cubit/ln_address/services/services.dart new file mode 100644 index 00000000..0210e62b --- /dev/null +++ b/lib/cubit/ln_address/services/services.dart @@ -0,0 +1,2 @@ +export 'lnurl_pay_service.dart'; +export 'webhook_service.dart'; diff --git a/lib/cubit/ln_address/services/webhook_service.dart b/lib/cubit/ln_address/services/webhook_service.dart new file mode 100644 index 00000000..f1ce90d8 --- /dev/null +++ b/lib/cubit/ln_address/services/webhook_service.dart @@ -0,0 +1,61 @@ +import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; +import 'package:firebase_notifications_client/firebase_notifications_client.dart'; +import 'package:flutter/foundation.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('WebhookService'); + +class WebhookService { + static const String _notifierServiceURL = 'https://notifier.breez.technology'; + + final BreezSDKLiquid _breezSdkLiquid; + final NotificationsClient _notificationsClient; + + WebhookService(this._breezSdkLiquid, this._notificationsClient); + + Future register(String webhookUrl) async { + try { + _logger.info('Registering webhook: $webhookUrl'); + await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); + _logger.info('Successfully registered webhook'); + } catch (e, stackTrace) { + _logger.severe('Failed to register webhook', e, stackTrace); + throw RegisterWebhookException('Failed to register webhook: $e'); + } + } + + Future generateWebhookUrl() async { + try { + _logger.info('Generating webhook URL'); + final String platform = _getPlatform(); + final String token = await _getToken(); + final String webhookUrl = '$_notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; + _logger.info('Generated webhook URL: $webhookUrl'); + return webhookUrl; + } catch (e) { + _logger.severe('Failed to generate webhook URL', e); + throw GenerateWebhookUrlException(e.toString()); + } + } + + String _getPlatform() { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return 'ios'; + } + if (defaultTargetPlatform == TargetPlatform.android) { + return 'android'; + } + _logger.severe('Unsupported platform: $defaultTargetPlatform'); + throw GenerateWebhookUrlException('Platform not supported'); + } + + Future _getToken() async { + final String? token = await _notificationsClient.getToken(); + if (token != null) { + return token; + } + _logger.severe('Failed to get notification token'); + throw GenerateWebhookUrlException('Failed to get notification token'); + } +} diff --git a/lib/cubit/ln_address/utils/username_formatter.dart b/lib/cubit/ln_address/utils/username_formatter.dart new file mode 100644 index 00000000..d1bd9f06 --- /dev/null +++ b/lib/cubit/ln_address/utils/username_formatter.dart @@ -0,0 +1,51 @@ +import 'package:flutter/services.dart'; + +class UsernameInputFormatter extends TextInputFormatter { + // Loosely comply with email standards, namely RFC 5322 + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + String formatted = newValue.text.trim(); + + // Remove invalid characters + formatted = formatted.replaceAll( + RegExp(r"[^a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]"), + '', + ); + + // Prevent consecutive dots (but allow trailing dots during typing) + formatted = formatted.replaceAll(RegExp(r'\.\.+'), '.'); + + return TextEditingValue( + text: formatted, + selection: newValue.selection, + ); + } +} + +class UsernameFormatter { + // Example: ". Satoshi Nakamoto." -> "satoshinakamoto" + static String sanitize(String rawUsername) { + if (rawUsername.isEmpty) { + return ''; + } + + // Ensure no leading or trailing dots + String sanitized = rawUsername.trim(); + if (sanitized.startsWith('.')) { + sanitized = sanitized.substring(1); + } + if (sanitized.endsWith('.')) { + sanitized = sanitized.substring(0, sanitized.length - 1); + } + + return sanitized.toLowerCase().replaceAll(' ', ''); + } + + // Example: "Tomato Elephant" -> "tomatoelephant" + static String formatDefaultProfileName(String? profileName) { + return sanitize(profileName ?? ''); + } +} diff --git a/lib/cubit/ln_address/utils/username_generator.dart b/lib/cubit/ln_address/utils/username_generator.dart new file mode 100644 index 00000000..91e976be --- /dev/null +++ b/lib/cubit/ln_address/utils/username_generator.dart @@ -0,0 +1,16 @@ +import 'dart:math'; + +class UsernameGenerator { + static const int _discriminatorLength = 4; + static final Random _secureRandom = Random.secure(); + + static String generateUsername(String baseUsername, int attempt) { + if (attempt == 0) { + return baseUsername; + } + + final int discriminator = _secureRandom.nextInt(10000); + final String formattedDiscriminator = discriminator.toString().padLeft(_discriminatorLength, '0'); + return '$baseUsername$formattedDiscriminator'; + } +} diff --git a/lib/cubit/ln_address/utils/utils.dart b/lib/cubit/ln_address/utils/utils.dart new file mode 100644 index 00000000..78c59d08 --- /dev/null +++ b/lib/cubit/ln_address/utils/utils.dart @@ -0,0 +1,2 @@ +export 'username_formatter.dart'; +export 'username_generator.dart'; diff --git a/lib/cubit/payment_limits/payment_limits_cubit.dart b/lib/cubit/payment_limits/payment_limits_cubit.dart index 7596f01f..919b5469 100644 --- a/lib/cubit/payment_limits/payment_limits_cubit.dart +++ b/lib/cubit/payment_limits/payment_limits_cubit.dart @@ -34,7 +34,7 @@ class PaymentLimitsCubit extends Cubit { void _fetchPaymentLimits() { if (_breezSdkLiquid.instance != null) { - _breezSdkLiquid.walletInfoStream.first.then((GetInfoResponse walletInfo) { + _breezSdkLiquid.getInfoResponseStream.first.then((GetInfoResponse getInfoResponse) { fetchLightningLimits(); fetchOnchainLimits(); }).timeout( diff --git a/lib/cubit/refund/refund_cubit.dart b/lib/cubit/refund/refund_cubit.dart index 4885636f..11492bf6 100644 --- a/lib/cubit/refund/refund_cubit.dart +++ b/lib/cubit/refund/refund_cubit.dart @@ -23,7 +23,7 @@ class RefundCubit extends Cubit { } void _initializeRefundCubit() { - _breezSdkLiquid.walletInfoStream.first.then((_) => listRefundables()); + _breezSdkLiquid.getInfoResponseStream.first.then((_) => listRefundables()); } void listRefundables() async { diff --git a/lib/cubit/user_profile/user_profile_cubit.dart b/lib/cubit/user_profile/user_profile_cubit.dart index 5e0d9f00..119721b9 100644 --- a/lib/cubit/user_profile/user_profile_cubit.dart +++ b/lib/cubit/user_profile/user_profile_cubit.dart @@ -3,6 +3,7 @@ import 'dart:io' as io; import 'dart:io'; import 'dart:typed_data'; +import 'package:breez_preferences/breez_preferences.dart'; import 'package:breez_translations/breez_translations_locales.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; @@ -17,7 +18,11 @@ export 'user_profile_state.dart'; final Logger _logger = Logger('UserProfileCubit'); class UserProfileCubit extends Cubit with HydratedMixin { - UserProfileCubit() : super(UserProfileState.initial()) { + final BreezPreferences _breezPreferences; + + UserProfileCubit( + this._breezPreferences, + ) : super(UserProfileState.initial()) { hydrate(); UserProfileState profile = state; _logger.info('State: ${profile.profileSettings.toJson()}'); @@ -36,9 +41,17 @@ class UserProfileCubit extends Cubit with HydratedMixin _setProfileName(String name) async { + final String? profileName = await _breezPreferences.profileName; + if (profileName == null) { + await _breezPreferences.setProfileName(name); + } + } + Future saveProfileImage(Uint8List bytes) async { try { _logger.info('Saving profile image, size: ${bytes.length} bytes'); @@ -60,7 +73,7 @@ class UserProfileCubit extends Cubit with HydratedMixin updateProfile({ String? name, String? color, String? animal, @@ -68,7 +81,7 @@ class UserProfileCubit extends Cubit with HydratedMixin with HydratedMixin { - static const String notifierServiceURL = 'https://notifier.breez.technology'; - static const String lnurlServiceURL = 'https://breez.fun'; - - final BreezSDKLiquid _breezSdkLiquid; - final BreezPreferences _breezPreferences; - final NotificationsClient _notifications; - - WebhookCubit( - this._breezSdkLiquid, - this._breezPreferences, - this._notifications, - ) : super(WebhookState()) { - _breezSdkLiquid.walletInfoStream.first.then( - (GetInfoResponse getInfoResponse) => refreshLnurlPay(walletInfo: getInfoResponse.walletInfo), - ); - } - - Future refreshLnurlPay({WalletInfo? walletInfo}) async { - _logger.info('Refreshing Lightning Address'); - emit(WebhookState(isLoading: true)); - try { - walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; - if (walletInfo != null) { - await _registerWebhooks(walletInfo); - } else { - throw Exception('Unable to retrieve wallet information.'); - } - } catch (err) { - _logger.warning('Failed to refresh lnurlpay: $err'); - emit( - state.copyWith( - lnurlPayErrorTitle: 'Failed to refresh Lightning Address:', - lnurlPayError: err.toString(), - ), - ); - } finally { - emit(state.copyWith(isLoading: false)); - } - } - - Future _registerWebhooks(WalletInfo walletInfo) async { - try { - final String webhookUrl = await _generateWebhookURL(); - await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); - _logger.info('SDK webhook registered: $webhookUrl'); - final String lnurl = await _registerLnurlpay(walletInfo, webhookUrl); - emit(WebhookState(lnurlPayUrl: lnurl)); - } catch (err) { - _logger.warning('Failed to register webhooks: $err'); - emit(state.copyWith(lnurlPayErrorTitle: 'Failed to register webhooks:', lnurlPayError: err.toString())); - rethrow; - } - } - - Future _registerLnurlpay( - WalletInfo walletInfo, - String webhookUrl, - ) async { - final String? lastUsedLnurlPay = await _breezPreferences.getLnUrlPayKey(); - if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { - await _invalidateLnurlPay(walletInfo, lastUsedLnurlPay); - } - final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$webhookUrl'); - final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); - if (signMessageRes == null) { - throw Exception('Missing signature'); - } - final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/${walletInfo.pubkey}'; - final Uri uri = Uri.parse(lnurlWebhookUrl); - final http.Response jsonResponse = await http.post( - uri, - body: jsonEncode( - AddWebhookRequest( - time: currentTime, - webhookUrl: webhookUrl, - signature: signMessageRes.signature, - ).toJson(), - ), - ); - if (jsonResponse.statusCode == 200) { - final Map data = jsonDecode(jsonResponse.body); - final String lnurl = data['lnurl']; - _logger.info('lnurlpay webhook registered: $webhookUrl, lnurl = $lnurl'); - await _breezPreferences.setLnUrlPayKey(webhookUrl); - return lnurl; - } else { - throw jsonResponse.body; - } - } - - Future _invalidateLnurlPay( - WalletInfo walletInfo, - String toInvalidate, - ) async { - final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/${walletInfo.pubkey}'; - final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$toInvalidate'); - final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); - if (signMessageRes == null) { - throw Exception('Missing signature'); - } - final Uri uri = Uri.parse(lnurlWebhookUrl); - final http.Response response = await http.delete( - uri, - body: jsonEncode( - RemoveWebhookRequest( - time: currentTime, - webhookUrl: toInvalidate, - signature: signMessageRes.signature, - ).toJson(), - ), - ); - _logger.info('invalidate lnurl pay response: ${response.statusCode}'); - await _breezPreferences.resetLnUrlPayKey(); - } - - Future _generateWebhookURL() async { - final String? token = await _notifications.getToken(); - _logger.info('Retrieved token, registering…'); - final String platform = defaultTargetPlatform == TargetPlatform.iOS - ? 'ios' - : defaultTargetPlatform == TargetPlatform.android - ? 'android' - : ''; - if (platform.isEmpty) { - throw Exception('Notifications for platform is not supported'); - } - return '$notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; - } -} diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart deleted file mode 100644 index 7cba57f6..00000000 --- a/lib/cubit/webhook/webhook_state.dart +++ /dev/null @@ -1,63 +0,0 @@ -class WebhookState { - final String? lnurlPayUrl; - final String? lnurlPayError; - final String? lnurlPayErrorTitle; - final bool isLoading; - - WebhookState({ - this.lnurlPayUrl, - this.lnurlPayError, - this.lnurlPayErrorTitle, - this.isLoading = false, - }); - - WebhookState copyWith({ - String? lnurlPayUrl, - String? lnurlPayError, - String? lnurlPayErrorTitle, - bool? isLoading, - }) { - return WebhookState( - lnurlPayUrl: lnurlPayUrl ?? this.lnurlPayUrl, - lnurlPayError: lnurlPayError ?? this.lnurlPayError, - lnurlPayErrorTitle: lnurlPayErrorTitle ?? this.lnurlPayErrorTitle, - isLoading: isLoading ?? this.isLoading, - ); - } -} - -class AddWebhookRequest { - final int time; - final String webhookUrl; - final String signature; - - AddWebhookRequest({ - required this.time, - required this.webhookUrl, - required this.signature, - }); - - Map toJson() => { - 'time': time, - 'webhook_url': webhookUrl, - 'signature': signature, - }; -} - -class RemoveWebhookRequest { - final int time; - final String webhookUrl; - final String signature; - - RemoveWebhookRequest({ - required this.time, - required this.webhookUrl, - required this.signature, - }); - - Map toJson() => { - 'time': time, - 'webhook_url': webhookUrl, - 'signature': signature, - }; -} diff --git a/lib/routes/dev/developers_view.dart b/lib/routes/dev/developers_view.dart index 0515915a..54cbdf11 100644 --- a/lib/routes/dev/developers_view.dart +++ b/lib/routes/dev/developers_view.dart @@ -53,10 +53,10 @@ class _DevelopersViewState extends State { @override void initState() { super.initState(); - _preferences.getBugReportBehavior().then( - (BugReportBehavior value) => bugReportBehavior = value, - onError: (Object e) => _logger.warning(e), - ); + _preferences.bugReportBehavior.then( + (BugReportBehavior value) => bugReportBehavior = value, + onError: (Object e) => _logger.warning(e), + ); } @override @@ -100,15 +100,15 @@ class _DevelopersViewState extends State { Choice( title: texts.developers_page_menu_prompt_bug_report_title, icon: Icons.bug_report, - function: (_) { - _preferences.setBugReportBehavior(BugReportBehavior.prompt).then( - (void value) => setState( - () { - bugReportBehavior = BugReportBehavior.prompt; - }, - ), - onError: (Object e) => _logger.warning(e), - ); + function: (_) async { + try { + await _preferences.setBugReportBehavior(BugReportBehavior.prompt); + setState(() { + bugReportBehavior = BugReportBehavior.prompt; + }); + } catch (e) { + _logger.warning('Failed to set BugReportBehavior: $e'); + } }, ), ] diff --git a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart index 98f8179b..5ab01c37 100644 --- a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart +++ b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart @@ -144,13 +144,14 @@ class BreezAvatarDialogState extends State { final String? userName = nameInputController.text.isNotEmpty ? nameInputController.text : userProfileCubit.state.profileSettings.name; - userProfileCubit.updateProfile(name: userName); + await userProfileCubit.updateProfile(name: userName); await saveProfileImage(); setState(() { isUploading = false; }); navigator.pop(); } catch (e) { + await userProfileCubit.updateProfile(name: userProfileCubit.state.profileSettings.name); setState(() { isUploading = false; pickedImage = null; @@ -223,9 +224,9 @@ class BreezAvatarDialogState extends State { _logger.info('saveProfileImage ${pickedImage?.path} $randomAvatarPath'); if (pickedImage != null) { final String profileImageFilePath = await userProfileCubit.saveProfileImage(await scaleAndFormatPNG()); - userProfileCubit.updateProfile(image: profileImageFilePath); + await userProfileCubit.updateProfile(image: profileImageFilePath); } else if (randomAvatarPath != null) { - userProfileCubit.updateProfile(image: randomAvatarPath); + await userProfileCubit.updateProfile(image: randomAvatarPath); } } diff --git a/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart b/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart index 93619cee..cc88d953 100644 --- a/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart +++ b/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart @@ -380,9 +380,9 @@ class _ExpansionTile extends StatelessWidget { ), ) .toList(), - onExpansionChanged: (bool isExpanded) { + onExpansionChanged: (bool isExpanded) async { final UserProfileCubit userProfileCubit = context.read(); - userProfileCubit.updateProfile(expandPreferences: isExpanded); + await userProfileCubit.updateProfile(expandPreferences: isExpanded); if (isExpanded) { Timer( const Duration(milliseconds: 200), diff --git a/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart b/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart index 68287e34..3b3acf40 100644 --- a/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart +++ b/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart @@ -44,7 +44,7 @@ class _BalanceTextState extends State { }, ), ), - onPressed: () => _changeBtcCurrency(context), + onPressed: () async => await _changeBtcCurrency(context), child: widget.hiddenBalance ? Text( texts.wallet_dashboard_balance_hide, @@ -78,13 +78,13 @@ class _BalanceTextState extends State { ); } - void _changeBtcCurrency(BuildContext context) { + Future _changeBtcCurrency(BuildContext context) async { final UserProfileCubit userProfileCubit = context.read(); final CurrencyCubit currencyCubit = context.read(); final CurrencyState currencyState = currencyCubit.state; if (widget.hiddenBalance == true) { - userProfileCubit.updateProfile(hideBalance: false); + await userProfileCubit.updateProfile(hideBalance: false); return; } final List list = BitcoinCurrency.currencies; @@ -93,7 +93,7 @@ class _BalanceTextState extends State { ); final int nextCurrencyIndex = (index + 1) % list.length; if (nextCurrencyIndex == 1) { - userProfileCubit.updateProfile(hideBalance: true); + await userProfileCubit.updateProfile(hideBalance: true); } currencyCubit.setBitcoinTicker(list[nextCurrencyIndex].tickerSymbol); } diff --git a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart index c933f7b9..6b333cd8 100644 --- a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart +++ b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart @@ -3,7 +3,7 @@ import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; -import 'package:l_breez/routes/routes.dart'; +import 'package:l_breez/routes/receive_payment/widgets/widgets.dart'; import 'package:l_breez/utils/exceptions.dart'; import 'package:l_breez/widgets/widgets.dart'; @@ -14,21 +14,19 @@ class ReceiveLightningAddressPage extends StatefulWidget { const ReceiveLightningAddressPage({super.key}); @override - State createState() { - return ReceiveLightningAddressPageState(); - } + State createState() => ReceiveLightningAddressPageState(); } class ReceiveLightningAddressPageState extends State { @override void initState() { super.initState(); - _refreshLnurlPay(); + setupLightningAddress(); } - void _refreshLnurlPay() { - final WebhookCubit webhookCubit = context.read(); - webhookCubit.refreshLnurlPay(); + void setupLightningAddress() { + final LnAddressCubit lnAddressCubit = context.read(); + lnAddressCubit.setupLightningAddress(); } @override @@ -36,22 +34,22 @@ class ReceiveLightningAddressPageState extends State( - builder: (BuildContext context, WebhookState webhookState) { + return BlocBuilder( + builder: (BuildContext context, LnAddressState lnAddressState) { return Scaffold( - body: webhookState.isLoading + body: lnAddressState.status == LnAddressStatus.loading ? Center( child: Loader( color: themeData.primaryColor.withValues(alpha: .5), ), ) - : webhookState.lnurlPayError != null + : lnAddressState.status == LnAddressStatus.error ? ScrollableErrorMessageWidget( showIcon: true, - title: webhookState.lnurlPayErrorTitle ?? texts.lightning_address_service_error_title, - message: extractExceptionMessage(webhookState.lnurlPayError!, texts), + title: texts.lightning_address_service_error_title, + message: extractExceptionMessage(lnAddressState.error!, texts), ) - : webhookState.lnurlPayUrl != null + : lnAddressState.status == LnAddressStatus.success && lnAddressState.lnurl != null ? Padding( padding: const EdgeInsets.only(top: 32.0, bottom: 40.0), child: SingleChildScrollView( @@ -67,8 +65,8 @@ class ReceiveLightningAddressPageState extends State( - builder: (BuildContext context, PaymentLimitsState snapshot) { - return webhookState.lnurlPayError != null || snapshot.hasError - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.invoice_ln_address_action_retry, - onPressed: webhookState.lnurlPayError != null - ? () => _refreshLnurlPay() - : () { - final PaymentLimitsCubit paymentLimitsCubit = - context.read(); - paymentLimitsCubit.fetchLightningLimits(); - }, - ) - : SingleButtonBottomBar( - stickToBottom: true, - text: texts.qr_code_dialog_action_close, - onPressed: () { - Navigator.of(context).pop(); - }, - ); + builder: (BuildContext context, PaymentLimitsState limitsState) { + final bool hasError = lnAddressState.status == LnAddressStatus.error || limitsState.hasError; + + return SingleButtonBottomBar( + stickToBottom: true, + text: hasError ? texts.invoice_ln_address_action_retry : texts.qr_code_dialog_action_close, + onPressed: + hasError ? _handleRetry(lnAddressState, limitsState) : () => Navigator.of(context).pop(), + ); }, ), ); }, ); } + + VoidCallback _handleRetry(LnAddressState state, PaymentLimitsState limitsState) { + return () { + if (state.status == LnAddressStatus.error) { + setupLightningAddress(); + } else if (limitsState.hasError) { + final PaymentLimitsCubit paymentLimitsCubit = context.read(); + paymentLimitsCubit.fetchLightningLimits(); + } + }; + } } diff --git a/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart b/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart index 9e3ccae6..d9b61e7b 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart @@ -17,19 +17,19 @@ final Logger _logger = Logger('DestinationWidget'); class DestinationWidget extends StatefulWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; - final bool isLnAddress; const DestinationWidget({ super.key, this.snapshot, this.destination, + this.lnAddress, this.paymentMethod, this.onLongPress, this.infoWidget, - this.isLnAddress = false, }); @override @@ -42,7 +42,7 @@ class _DestinationWidgetState extends State { @override void initState() { super.initState(); - if (widget.isLnAddress) { + if (widget.lnAddress != null && widget.lnAddress!.isNotEmpty) { // Ignore new payments for a duration upon generating LN Address. // This delay is added to avoid popping the page before user gets the chance to copy, // share or get their LN address scanned. @@ -56,7 +56,7 @@ class _DestinationWidgetState extends State { // For receive payment pages other than LN Address, user input is required before creating an invoice. // Therefore, they rely on `didUpdateWidget` instead of `initState` to capture updates after // initial widget setup. - if (!widget.isLnAddress) { + if (!(widget.lnAddress != null && widget.lnAddress!.isNotEmpty)) { _trackPaymentEvents(getUpdatedDestination(oldWidget)); } } @@ -152,6 +152,7 @@ class _DestinationWidgetState extends State { child: DestinationQRWidget( snapshot: widget.snapshot, destination: widget.destination, + lnAddress: widget.lnAddress, paymentMethod: widget.paymentMethod, onLongPress: widget.onLongPress, infoWidget: widget.infoWidget, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart similarity index 90% rename from lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart rename to lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart index a57e8eac..e775c2c3 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart @@ -10,13 +10,15 @@ import 'package:share_plus/share_plus.dart'; class DestinationActions extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; const DestinationActions({ required this.snapshot, required this.destination, - super.key, + this.lnAddress, this.paymentMethod, + super.key, }); @override @@ -31,6 +33,7 @@ class DestinationActions extends StatelessWidget { Expanded( child: _CopyButton( destination: destination, + lnAddress: lnAddress, paymentMethod: paymentMethod, ), ), @@ -51,10 +54,12 @@ class DestinationActions extends StatelessWidget { class _CopyButton extends StatelessWidget { final String destination; final String? paymentMethod; + final String? lnAddress; const _CopyButton({ required this.destination, this.paymentMethod, + this.lnAddress, }); @override @@ -85,10 +90,13 @@ class _CopyButton extends StatelessWidget { style: balanceFiatConversionTextStyle, ), onPressed: () { - ServiceInjector().deviceClient.setClipboardText(destination); + ServiceInjector().deviceClient.setClipboardText( + (lnAddress != null && lnAddress!.isNotEmpty) ? lnAddress! : destination, + ); showFlushbar( context, - message: (paymentMethod != null && paymentMethod!.isNotEmpty) + message: (lnAddress != null && lnAddress!.isNotEmpty) || + (paymentMethod != null && paymentMethod!.isNotEmpty) ? texts.payment_details_dialog_copied(paymentMethod!) : texts.invoice_btc_address_deposit_address_copied, duration: const Duration(seconds: 3), diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart new file mode 100644 index 00000000..61104c73 --- /dev/null +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:l_breez/routes/receive_payment/widgets/widgets.dart'; +import 'package:l_breez/widgets/widgets.dart'; + +class DestinationInformation extends StatefulWidget { + final String lnAddress; + + const DestinationInformation({ + required this.lnAddress, + super.key, + }); + + @override + DestinationInformationState createState() => DestinationInformationState(); +} + +class DestinationInformationState extends State { + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + return GestureDetector( + onTapDown: (TapDownDetails details) { + // TODO(erdemyerebasmaz): Display the dropdown menu in a static place that does not obstruct LN Address + final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final Offset offset = details.globalPosition; + + showMenu( + context: context, + color: themeData.colorScheme.surface, + position: RelativeRect.fromRect( + Rect.fromPoints(offset, offset), + Offset.zero & overlay.size, + ), + items: >[ + const PopupMenuItem( + // TODO(erdemyerebasmaz): Replace with const var + value: 'customize', + child: Row( + children: [ + Icon(Icons.edit), + SizedBox( + width: 8.0, + ), + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + Text('Customize Address'), + ], + ), + ), + ], + ).then((String? value) { + // TODO(erdemyerebasmaz): Replace with const var + if (value == 'customize') { + if (context.mounted) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + isScrollControlled: true, + builder: (BuildContext context) => UpdateLnAddressUsernameBottomSheet( + lnAddress: widget.lnAddress, + ), + ); + } + } + }); + }, + child: WarningBox( + boxPadding: const EdgeInsets.only(bottom: 24.0), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + borderColor: Colors.transparent, + backgroundColor: Theme.of(context).canvasColor, + child: Center( + child: Text( + widget.lnAddress, + style: themeData.primaryTextTheme.bodyMedium!.copyWith(fontSize: 18.0), + ), + ), + ), + ); + } +} diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart index 6268bf46..325f2c7d 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart @@ -9,6 +9,7 @@ import 'package:l_breez/widgets/widgets.dart'; class DestinationQRWidget extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; @@ -16,10 +17,11 @@ class DestinationQRWidget extends StatelessWidget { const DestinationQRWidget({ required this.snapshot, required this.destination, + this.lnAddress, this.paymentMethod, - super.key, this.onLongPress, this.infoWidget, + super.key, }); @override @@ -49,7 +51,11 @@ class DestinationQRWidget extends StatelessWidget { snapshot: snapshot, destination: destination, paymentMethod: paymentMethod, + lnAddress: lnAddress, ), + if (lnAddress != null && lnAddress!.isNotEmpty) ...[ + DestinationInformation(lnAddress: lnAddress!), + ], if (infoWidget != null) ...[ SizedBox( width: MediaQuery.of(context).size.width, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart new file mode 100644 index 00000000..c018fdca --- /dev/null +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -0,0 +1,210 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:l_breez/widgets/widgets.dart'; + +class UpdateLnAddressUsernameBottomSheet extends StatefulWidget { + final String lnAddress; + + const UpdateLnAddressUsernameBottomSheet({ + required this.lnAddress, + super.key, + }); + + @override + State createState() => _UpdateLnAddressUsernameBottomSheetState(); +} + +class _UpdateLnAddressUsernameBottomSheetState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _usernameController = TextEditingController(); + final FocusNode _usernameFocusNode = FocusNode(); + late final KeyboardDoneAction _doneAction; + + @override + void initState() { + super.initState(); + _usernameController.text = widget.lnAddress.split('@').first; + _usernameController.addListener(() => setState(() {})); + _doneAction = KeyboardDoneAction(focusNodes: [_usernameFocusNode]); + + // Clear any previous error messages when opening the bottom sheet + final LnAddressCubit lnAddressCubit = context.read(); + lnAddressCubit.clearUpdateStatus(); + } + + @override + void dispose() { + _doneAction.dispose(); + FocusManager.instance.primaryFocus?.unfocus(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); + + return BlocListener( + listenWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus.status != previous.updateStatus.status, + listener: (BuildContext context, LnAddressState state) { + if (state.updateStatus.status == UpdateStatus.success) { + Navigator.pop(context); + showFlushbar( + context, + message: 'Successfully updated Lightning Address username.', + ); + } else if (state.updateStatus.status == UpdateStatus.error) { + if (state.updateStatus.error is! UsernameConflictException) { + showFlushbar( + context, + message: 'Failed to update Lightning Address username.', + ); + } + _formKey.currentState?.validate(); + } + }, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + child: Container( + margin: const EdgeInsets.only(top: 8.0), + width: 40.0, + height: 6.5, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(50), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Customize Address:', + style: themeData.primaryTextTheme.headlineMedium!.copyWith( + fontSize: 18.0, + color: Colors.white, + ), + textAlign: TextAlign.left, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: BlocBuilder( + buildWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus != previous.updateStatus, + builder: (BuildContext context, LnAddressState state) { + final bool isConflict = state.updateStatus.error is UsernameConflictException; + return TextFormField( + controller: _usernameController, + focusNode: _usernameFocusNode, + decoration: InputDecoration( + labelText: 'Username', + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: themeData.colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: themeData.colorScheme.error), + ), + errorMaxLines: 2, + errorStyle: themeData.primaryTextTheme.bodySmall!.copyWith( + color: themeData.colorScheme.error, + height: 1.0, + ), + suffix: const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Text('@breez.fun'), + ), + border: const OutlineInputBorder(), + errorText: isConflict ? 'Username is already taken' : null, + ), + // 64 is the maximum allowed length for a username + // but a %12.5 margin of error is added for good measure, + // which is likely to get sanitized by the UsernameFormatter + maxLength: 64 + 8, + keyboardType: TextInputType.emailAddress, + autofocus: true, + validator: _validateUsername, + inputFormatters: [ + UsernameInputFormatter(), + ], + onEditingComplete: () => _usernameFocusNode.unfocus(), + ); + }, + ), + ), + ), + const SizedBox(height: 8.0), + Align( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), + child: BlocBuilder( + buildWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus.status != previous.updateStatus.status, + builder: (BuildContext context, LnAddressState state) { + return SingleButtonBottomBar( + text: texts.currency_converter_dialog_action_done, + loading: state.updateStatus.status == UpdateStatus.loading, + expand: true, + onPressed: _handleSubmit, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + String? _validateUsername(String? value) { + final String sanitized = UsernameFormatter.sanitize(value ?? ''); + if (sanitized.isEmpty) { + return 'Please enter a username'; + } + + if (sanitized.length > 64) { + return 'Username must not be longer than 64 characters.'; + } + + final LnAddressState state = context.read().state; + if (state.updateStatus.error is UsernameConflictException) { + return 'Username is already taken'; + } + + final String email = '$sanitized@${widget.lnAddress.split('@').last}'; + return EmailValidator.validate(email) ? null : 'Invalid username.'; + } + + void _handleSubmit() { + if (_usernameController.text.isEmpty) { + Navigator.pop(context); + return; + } + + if (_formKey.currentState?.validate() ?? false) { + final LnAddressCubit lnAddressCubit = context.read(); + final String username = UsernameFormatter.sanitize(_usernameController.text); + lnAddressCubit.setupLightningAddress(baseUsername: username); + } + } +} diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart index 56409526..328a81d2 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart @@ -1,5 +1,7 @@ export 'compact_qr_image.dart'; -export 'destination_header.dart'; +export 'destination_actions.dart'; +export 'destination_information.dart'; export 'destination_qr_image.dart'; export 'destination_qr_widget.dart'; export 'loading_or_error.dart'; +export 'update_ln_address_username_bottom_sheet.dart'; diff --git a/lib/widgets/single_button_bottom_bar.dart b/lib/widgets/single_button_bottom_bar.dart index b6adfb42..c5f1f5f1 100644 --- a/lib/widgets/single_button_bottom_bar.dart +++ b/lib/widgets/single_button_bottom_bar.dart @@ -7,6 +7,7 @@ class SingleButtonBottomBar extends StatelessWidget { final bool stickToBottom; final bool enabled; final bool expand; + final bool loading; const SingleButtonBottomBar({ required this.text, @@ -15,6 +16,7 @@ class SingleButtonBottomBar extends StatelessWidget { this.stickToBottom = false, this.enabled = true, this.expand = false, + this.loading = false, }); @override @@ -36,6 +38,7 @@ class SingleButtonBottomBar extends StatelessWidget { onPressed, enabled: enabled, expand: expand, + loading: loading, ), ), ], @@ -49,6 +52,7 @@ class SubmitButton extends StatelessWidget { final String text; final bool enabled; final bool expand; + final bool loading; const SubmitButton( this.text, @@ -56,6 +60,7 @@ class SubmitButton extends StatelessWidget { super.key, this.enabled = true, this.expand = false, + this.loading = false, }); @override @@ -78,12 +83,21 @@ class SubmitButton extends StatelessWidget { ), minimumSize: expand ? Size(screenWidth, 48) : null, ), - onPressed: enabled ? onPressed : null, - child: AutoSizeText( - text, - maxLines: 1, - style: themeData.textTheme.labelLarge, - ), + onPressed: (enabled && !loading) ? onPressed : null, + child: loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : AutoSizeText( + text, + maxLines: 1, + style: themeData.textTheme.labelLarge, + ), ), ); } diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index 40de8067..36898a61 100644 --- a/packages/breez_preferences/lib/src/breez_preferences.dart +++ b/packages/breez_preferences/lib/src/breez_preferences.dart @@ -1,115 +1,100 @@ import 'package:breez_preferences/src/model/bug_report_behavior.dart'; -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:shared_preference_app_group/shared_preference_app_group.dart'; import 'package:shared_preferences/shared_preferences.dart'; -const double kDefaultProportionalFee = 1.0; -const int kDefaultExemptFeeMsat = 20000; -const int kDefaultChannelSetupFeeLimitMsat = 5000000; - -const String _mempoolSpaceUrlKey = 'mempool_space_url'; -const String _kPaymentOptionProportionalFee = 'payment_options_proportional_fee'; -const String _kPaymentOptionExemptFee = 'payment_options_exempt_fee'; -const String _kPaymentOptionChannelSetupFeeLimit = 'payment_options_channel_setup_fee_limit'; -const String _kReportPrefKey = 'report_preference_key'; -const String _kLnUrlPayKey = 'lnurlpay_key'; - final Logger _logger = Logger('BreezPreferences'); class BreezPreferences { + // Preference Keys + static const String _kBugReportBehavior = 'bug_report_behavior'; + static const String _kProfileName = 'profile_name'; + static const String _kWebhookUrl = 'webhook_url'; + static const String _kLnUrlWebhookRegistered = 'lnurl_webhook_registered'; + static const String _kLnAddressUsername = 'ln_address_username'; + const BreezPreferences(); - Future hasPaymentOptions() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getKeys().containsAll([ - _kPaymentOptionProportionalFee, - _kPaymentOptionExemptFee, - _kPaymentOptionChannelSetupFeeLimit, - ]); - } + Future get _preferences => SharedPreferences.getInstance(); - Future getMempoolSpaceUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_mempoolSpaceUrlKey); - } + // Bug Report Behavior + Future get bugReportBehavior async { + final SharedPreferences prefs = await _preferences; + final int? value = prefs.getInt(_kBugReportBehavior); + final BugReportBehavior behavior = BugReportBehavior.values[value ?? BugReportBehavior.prompt.index]; - Future setMempoolSpaceUrl(String url) async { - _logger.info('set mempool space url: $url'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_mempoolSpaceUrlKey, url); + _logger.info('Fetched BugReportBehavior: $behavior'); + return behavior; } - Future resetMempoolSpaceUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(_mempoolSpaceUrlKey); + Future setBugReportBehavior(BugReportBehavior behavior) async { + _logger.info('Setting BugReportBehavior: $behavior'); + final SharedPreferences prefs = await _preferences; + await prefs.setInt(_kBugReportBehavior, behavior.index); } - Future getPaymentOptionsProportionalFee() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getDouble(_kPaymentOptionProportionalFee) ?? kDefaultProportionalFee; - } + // Profile Name + Future get profileName async { + final SharedPreferences prefs = await _preferences; + final String? name = prefs.getString(_kProfileName); - Future setPaymentOptionsProportionalFee(double fee) async { - _logger.info('set payment options proportional fee: $fee'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_kPaymentOptionProportionalFee, fee); + _logger.info('Fetched Profile Name: $name'); + return name; } - Future getPaymentOptionsExemptFee() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kPaymentOptionExemptFee) ?? kDefaultExemptFeeMsat; + Future setProfileName(String name) async { + _logger.info('Setting Profile Name: $name'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kProfileName, name); } - Future setPaymentOptionsExemptFee(int exemptFeeMsat) async { - _logger.info('set payment options exempt fee : $exemptFeeMsat'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kPaymentOptionExemptFee, exemptFeeMsat); - } + // Webhook URL + Future get webhookUrl async { + final SharedPreferences prefs = await _preferences; + final String? url = prefs.getString(_kWebhookUrl); - Future getPaymentOptionsChannelSetupFeeLimitMsat() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kPaymentOptionChannelSetupFeeLimit) ?? kDefaultChannelSetupFeeLimitMsat; + _logger.info('Fetched Webhook URL: $url'); + return url; } - Future setPaymentOptionsChannelSetupFeeLimit(int channelFeeLimitMsat) async { - _logger.info('set payment options channel setup limit fee : $channelFeeLimitMsat'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kPaymentOptionChannelSetupFeeLimit, channelFeeLimitMsat); - // iOS Extension requirement - if (defaultTargetPlatform == TargetPlatform.iOS) { - await SharedPreferenceAppGroup.setInt(_kPaymentOptionChannelSetupFeeLimit, channelFeeLimitMsat); - } + Future setWebhookUrl(String url) async { + _logger.info('Setting Webhook URL: $url'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kWebhookUrl, url); } - Future getBugReportBehavior() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - final int? value = prefs.getInt(_kReportPrefKey); - if (value == null || value < 0 || value >= BugReportBehavior.values.length) { - return BugReportBehavior.prompt; - } - return BugReportBehavior.values[value]; + Future removeWebhookUrl() async { + _logger.info('Removing Webhook URL'); + final SharedPreferences prefs = await _preferences; + await prefs.remove(_kWebhookUrl); } - Future setBugReportBehavior(BugReportBehavior behavior) async { - _logger.info('set bug report behavior: $behavior'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kReportPrefKey, behavior.index); + // LN URL Webhook Registration + Future get isLnUrlWebhookRegistered async { + final SharedPreferences prefs = await _preferences; + final bool registered = prefs.getBool(_kLnUrlWebhookRegistered) ?? false; + + _logger.info('LNURL Webhook Registered: $registered'); + return registered; } - Future getLnUrlPayKey() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_kLnUrlPayKey); + Future setLnUrlWebhookRegistered() async { + _logger.info('Setting LNURL Webhook as Registered'); + final SharedPreferences prefs = await _preferences; + await prefs.setBool(_kLnUrlWebhookRegistered, true); } - Future setLnUrlPayKey(String webhookUrl) async { - _logger.info('set lnurl pay key: $webhookUrl'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kLnUrlPayKey, webhookUrl); + // LN Address Username + Future get lnAddressUsername async { + final SharedPreferences prefs = await _preferences; + final String? username = prefs.getString(_kLnAddressUsername); + + _logger.info('Fetched LN Address Username: $username'); + return username; } - Future resetLnUrlPayKey() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kLnUrlPayKey); + Future setLnAddressUsername(String username) async { + _logger.info('Setting LN Address Username: $username'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kLnAddressUsername, username); } } diff --git a/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart b/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart index e6a14454..b0f2955a 100644 --- a/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart +++ b/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart @@ -55,9 +55,9 @@ class BreezSDKLiquid { } Future _getInfo(liquid_sdk.BindingLiquidSdk sdk) async { - final liquid_sdk.GetInfoResponse walletInfo = await sdk.getInfo(); - _walletInfoController.add(walletInfo); - return walletInfo; + final liquid_sdk.GetInfoResponse getInfoResponse = await sdk.getInfo(); + _getInfoResponseController.add(getInfoResponse); + return getInfoResponse; } Future> _listPayments({ @@ -94,10 +94,10 @@ class BreezSDKLiquid { _breezEventsStream ??= sdk.addEventListener().asBroadcastStream(); } - final StreamController _walletInfoController = + final StreamController _getInfoResponseController = BehaviorSubject(); - Stream get walletInfoStream => _walletInfoController.stream; + Stream get getInfoResponseStream => _getInfoResponseController.stream; final StreamController> _paymentsController = BehaviorSubject>();