Skip to content

Commit

Permalink
LNURL Withdraw rework cont. (#227)
Browse files Browse the repository at this point in the history
* Display error message next to LNURL withdraw request data

* Populate form fields at all times
* Disable form is the request data is invalid or is below network limits
* Use LnUrlWithdrawLimits widget to display the range
* Handle cases where:
- there's been an error retrieving network limits
- fixed payment amount is outside network limits
- effective payment amount range is below network limits

* Show flushbar message if parsing an input fails

Useful for LNURL payments when there service & connection related issues.

* Add option to refresh payment limits on LnUrlWithdraw page

* Map LnUrlWithdrawError's to user friendly messages

* Convert LNURLWithdrawDialog into a fullscreen dialog

* Refactor LNURLWithdrawDialog

* Rename effectiveMaxSat used on first validation to rawMaxSat for clarity

* LNURL payment page improved error handling (#229)

* Fix UI jank while rendering LNURL Metadata Image

* Display network payment limit retrieval errors

* Add option to retry retrieving network payment limits

Extract open confirmation page logic into a method for readability

* Hide bottom navigation bar when loading

* Skip range validation on confirmation page

* Differentiate between rawMaxSat & effectiveMaxSat on validatePayment

* Update amount to effective minimum if there's no errors

* Change bottom bar button visibility & texts per feedback

Show close button only for invalid invoices
  • Loading branch information
erdemyerebasmaz authored Nov 12, 2024
1 parent 6721ea9 commit e441674
Show file tree
Hide file tree
Showing 10 changed files with 593 additions and 303 deletions.
2 changes: 2 additions & 0 deletions lib/handlers/input_handler/src/input_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ class InputHandler extends Handler {
return handleBitcoinAddress(context, inputState);
} else if (inputState is UrlInputState) {
throw context.texts().payment_info_dialog_error_unsupported_input;
} else if (inputState is EmptyInputState) {
throw "Failed to parse input.";
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ class _EnterPaymentInfoPageState extends State<EnterPaymentInfoPage> {
String errorMessage = "";
ModalRoute? _loaderRoute;

@override
void initState() {
super.initState();
_paymentInfoController.addListener(() {
setState(() {});
});
}

@override
Widget build(BuildContext context) {
final texts = context.texts();
Expand Down Expand Up @@ -89,14 +97,13 @@ class _EnterPaymentInfoPageState extends State<EnterPaymentInfoPage> {
),
),
),
bottomNavigationBar: SingleButtonBottomBar(
text: _paymentInfoController.text.isNotEmpty && errorMessage.isEmpty
? texts.payment_info_dialog_action_approve
: texts.payment_info_dialog_action_cancel,
onPressed: _paymentInfoController.text.isNotEmpty && errorMessage.isEmpty
? _onApprovePressed
: () => Navigator.pop(context),
),
bottomNavigationBar: _paymentInfoController.text.isNotEmpty
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.withdraw_funds_action_next,
onPressed: _onApprovePressed,
)
: const SizedBox.shrink(),
);
}

Expand Down Expand Up @@ -157,7 +164,7 @@ class _EnterPaymentInfoPageState extends State<EnterPaymentInfoPage> {
if (_formKey.currentState!.validate()) {
_setLoading(false);
if (mounted) Navigator.pop(context);
inputCubit.addIncomingInput(_paymentInfoController.text, InputSource.inputField);
inputCubit.addIncomingInput(_paymentInfoController.text.trim(), InputSource.inputField);
}
} catch (error) {
_setLoading(false);
Expand All @@ -167,6 +174,8 @@ class _EnterPaymentInfoPageState extends State<EnterPaymentInfoPage> {
errorMessage = context.texts().payment_info_dialog_error;
});
}
} finally {
_setLoading(false);
}
}

Expand Down
202 changes: 136 additions & 66 deletions lib/routes/lnurl/payment/lnurl_payment_page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:math';

import 'package:auto_size_text/auto_size_text.dart';
import 'package:breez_translations/breez_translations_locales.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Expand All @@ -16,17 +17,19 @@ import 'package:l_breez/widgets/back_button.dart' as back_button;
import 'package:l_breez/widgets/keyboard_done_action.dart';
import 'package:l_breez/widgets/loader.dart';
import 'package:l_breez/widgets/route.dart';
import 'package:l_breez/widgets/scrollable_error_message_widget.dart';
import 'package:l_breez/widgets/single_button_bottom_bar.dart';
import 'package:service_injector/service_injector.dart';

class LnUrlPaymentPage extends StatefulWidget {
final bool isConfirmation;
final LnUrlPayRequestData requestData;
final String? comment;

static const routeName = "/lnurl_payment";
static const paymentMethod = PaymentMethod.lightning;

const LnUrlPaymentPage({super.key, required this.requestData, this.comment});
const LnUrlPaymentPage({super.key, this.isConfirmation = false, required this.requestData, this.comment});

@override
State<StatefulWidget> createState() => LnUrlPaymentPageState();
Expand All @@ -42,6 +45,7 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {

bool _isFixedAmount = false;
bool _loading = true;
bool _isFormEnabled = true;
bool _isCalculatingFees = false;
String errorMessage = "";
LightningPaymentLimitsResponse? _lightningLimits;
Expand Down Expand Up @@ -87,40 +91,43 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
}

Future<void> _handleLightningPaymentLimitsResponse() async {
final minNetworkLimit = _lightningLimits!.send.minSat.toInt();
final maxNetworkLimit = _lightningLimits!.send.maxSat.toInt();
final minSendableSat = widget.requestData.minSendable.toInt() ~/ 1000;
final maxSendableSat = widget.requestData.maxSendable.toInt() ~/ 1000;
final effectiveMinSat = min(
max(_lightningLimits!.send.minSat.toInt(), minSendableSat),
_lightningLimits!.send.maxSat.toInt(),
max(minNetworkLimit, minSendableSat),
maxNetworkLimit,
);
final effectiveMaxSat = min(_lightningLimits!.send.maxSat.toInt(), maxSendableSat);
final rawMaxSat = min(maxNetworkLimit, maxSendableSat);
final effectiveMaxSat = max(minNetworkLimit, rawMaxSat);
_updateFormFields(amountSat: minSendableSat);
final errorMessage = validatePayment(
amountSat: _isFixedAmount ? minSendableSat : effectiveMinSat,
effectiveMinSat: effectiveMinSat,
rawMaxSat: rawMaxSat,
effectiveMaxSat: effectiveMaxSat,
throwError: true,
);
if (errorMessage == null) {
await _updateFormFields(amountSat: effectiveMaxSat);
_updateFormFields(amountSat: effectiveMinSat);
if (errorMessage == null && _isFixedAmount) {
await _prepareLnUrlPayment(rawMaxSat);
}
}

Future<void> _updateFormFields({
void _updateFormFields({
required int amountSat,
}) async {
if (_isFixedAmount) {
}) {
if (!_isFixedAmount) {
final currencyCubit = context.read<CurrencyCubit>();
final currencyState = currencyCubit.state;

_amountController.text = currencyState.bitcoinCurrency.format(
amountSat,
includeDisplayName: false,
);
_descriptionController.text = widget.comment ?? "";
await _prepareLnUrlPayment(amountSat);
} else if (_amountFocusNode.canRequestFocus) {
_amountFocusNode.requestFocus();
}
_descriptionController.text = widget.comment ?? "";
}

Future<void> _prepareLnUrlPayment(int amountSat) async {
Expand Down Expand Up @@ -189,13 +196,32 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
String payeeName = metadataMap["text/identifier"] ?? widget.requestData.domain;
String? metadataText = metadataMap['text/long-desc'] ?? metadataMap['text/plain'];

if (_lightningLimits == null) {
if (errorMessage.isEmpty) {
return Center(
child: Loader(
color: themeData.primaryColor.withOpacity(0.5),
),
);
}
return ScrollableErrorMessageWidget(
title: "Failed to retrieve payment limits:",
message: texts.reverse_swap_upstream_generic_error_message(errorMessage),
);
}

final minNetworkLimit = _lightningLimits!.send.minSat.toInt();
final maxNetworkLimit = _lightningLimits!.send.maxSat.toInt();
final minSendableSat = widget.requestData.minSendable.toInt() ~/ 1000;
final maxSendableSat = widget.requestData.maxSendable.toInt() ~/ 1000;
final effectiveMinSat = min(
max(_lightningLimits!.send.minSat.toInt(), minSendableSat),
_lightningLimits!.send.maxSat.toInt(),
max(minNetworkLimit, minSendableSat),
maxNetworkLimit,
);
final effectiveMaxSat = max(
minNetworkLimit,
min(maxNetworkLimit, maxSendableSat),
);
final effectiveMaxSat = min(_lightningLimits!.send.maxSat.toInt(), maxSendableSat);

return Form(
key: _formKey,
Expand All @@ -206,12 +232,10 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (base64String != null && base64String.isNotEmpty) ...[
Padding(
padding: EdgeInsets.zero,
child: Center(child: LNURLMetadataImage(base64String: base64String)),
),
],
Padding(
padding: EdgeInsets.zero,
child: Center(child: LNURLMetadataImage(base64String: base64String)),
),
if (_isFixedAmount) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
Expand All @@ -228,7 +252,8 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
texts: texts,
bitcoinCurrency: currencyState.bitcoinCurrency,
focusNode: _amountFocusNode,
autofocus: true,
autofocus: _isFormEnabled && errorMessage.isEmpty,
enabled: _isFormEnabled,
controller: _amountController,
validatorFn: (amountSat) => validatePayment(
amountSat: amountSat,
Expand Down Expand Up @@ -275,6 +300,17 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
},
),
),
if (!_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty) ...[
const SizedBox(height: 8.0),
AutoSizeText(
errorMessage,
maxLines: 3,
textAlign: TextAlign.left,
style: FieldTextStyle.labelStyle.copyWith(
color: themeData.colorScheme.error,
),
),
],
],
if (_prepareResponse != null && _isFixedAmount) ...[
Padding(
Expand All @@ -301,6 +337,7 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
],
if (widget.requestData.commentAllowed > 0) ...[
LnUrlPaymentComment(
enabled: _isFormEnabled,
descriptionController: _descriptionController,
maxCommentLength: widget.requestData.commentAllowed.toInt(),
)
Expand All @@ -312,62 +349,50 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
);
},
),
bottomNavigationBar: errorMessage.isNotEmpty
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.qr_code_dialog_action_close,
onPressed: () {
Navigator.of(context).pop();
},
)
: !_isFixedAmount
bottomNavigationBar: _loading
? null
: _lightningLimits == null
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.lnurl_fetch_invoice_action_continue,
onPressed: () async {
if (_formKey.currentState?.validate() ?? false) {
final currencyCubit = context.read<CurrencyCubit>();
final currencyState = currencyCubit.state;
final amountSat = currencyState.bitcoinCurrency.parse(_amountController.text);
final amountMsat = BigInt.from(amountSat * 1000);
final requestData = widget.requestData.copyWith(
minSendable: amountMsat,
maxSendable: amountMsat,
);
PrepareLnUrlPayResponse? prepareResponse =
await Navigator.of(context).push<PrepareLnUrlPayResponse?>(
FadeInRoute<PrepareLnUrlPayResponse?>(
builder: (_) => BlocProvider(
create: (BuildContext context) => PaymentLimitsCubit(ServiceInjector().liquidSDK),
child: LnUrlPaymentPage(
requestData: requestData,
comment: _descriptionController.text,
),
),
),
);
if (prepareResponse == null || !context.mounted) {
return Future.value();
}
Navigator.pop(context, prepareResponse);
}
text: texts.invoice_ln_address_action_retry,
onPressed: () {
_fetchLightningLimits();
},
)
: _prepareResponse != null
: !_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.lnurl_payment_page_action_pay,
onPressed: () async {
Navigator.pop(context, _prepareResponse);
text: texts.qr_code_dialog_action_close,
onPressed: () {
Navigator.of(context).pop();
},
)
: const SizedBox.shrink(),
: !_isFixedAmount
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.withdraw_funds_action_next,
onPressed: () async {
if (_formKey.currentState?.validate() ?? false) {
await _openConfirmationPage();
}
},
)
: _prepareResponse != null
? SingleButtonBottomBar(
stickToBottom: true,
text: texts.bottom_action_bar_send,
onPressed: () async {
Navigator.pop(context, _prepareResponse);
},
)
: const SizedBox.shrink(),
);
}

String? validatePayment({
required int amountSat,
required int effectiveMinSat,
int? rawMaxSat,
required int effectiveMaxSat,
bool throwError = false,
}) {
Expand All @@ -380,7 +405,23 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
message = "Failed to retrieve network payment limits. Please try again later.";
}

if (amountSat > effectiveMaxSat) {
if (!widget.isConfirmation && _isFixedAmount && effectiveMinSat == effectiveMaxSat) {
final minNetworkLimit = _lightningLimits!.send.minSat.toInt();
final maxNetworkLimit = _lightningLimits!.send.maxSat.toInt();
final minNetworkLimitFormatted = currencyState.bitcoinCurrency.format(minNetworkLimit);
final maxNetworkLimitFormatted = currencyState.bitcoinCurrency.format(maxNetworkLimit);
message =
"Payment amount is outside the allowed limits, which range from $minNetworkLimitFormatted to $maxNetworkLimitFormatted";
} else if (rawMaxSat != null && rawMaxSat < effectiveMinSat) {
final networkLimit = currencyState.bitcoinCurrency.format(
effectiveMinSat,
includeDisplayName: true,
);
message = texts.invoice_payment_validator_error_payment_below_invoice_limit(networkLimit);
setState(() {
_isFormEnabled = false;
});
} else if (amountSat > effectiveMaxSat) {
final networkLimit = "(${currencyState.bitcoinCurrency.format(
effectiveMaxSat,
includeDisplayName: true,
Expand Down Expand Up @@ -416,6 +457,35 @@ class LnUrlPaymentPageState extends State<LnUrlPaymentPage> {
final lnUrlCubit = context.read<LnUrlCubit>();
return lnUrlCubit.validateLnUrlPayment(BigInt.from(amount), outgoing, _lightningLimits!, balance);
}

Future<void> _openConfirmationPage() async {
final currencyCubit = context.read<CurrencyCubit>();
final currencyState = currencyCubit.state;
final amountSat = currencyState.bitcoinCurrency.parse(_amountController.text);
final amountMsat = BigInt.from(amountSat * 1000);
final requestData = widget.requestData.copyWith(
minSendable: amountMsat,
maxSendable: amountMsat,
);
PrepareLnUrlPayResponse? prepareResponse = await Navigator.of(context).push<PrepareLnUrlPayResponse?>(
FadeInRoute<PrepareLnUrlPayResponse?>(
builder: (_) => BlocProvider(
create: (BuildContext context) => PaymentLimitsCubit(ServiceInjector().liquidSDK),
child: LnUrlPaymentPage(
isConfirmation: true,
requestData: requestData,
comment: _descriptionController.text,
),
),
),
);
if (prepareResponse == null || !context.mounted) {
return Future.value();
}
if (mounted) {
Navigator.pop(context, prepareResponse);
}
}
}

extension LnUrlPayRequestDataCopyWith on LnUrlPayRequestData {
Expand Down
Loading

0 comments on commit e441674

Please sign in to comment.