diff --git a/lib/routes/enter_payment_info/enter_payment_info_page.dart b/lib/routes/enter_payment_info/enter_payment_info_page.dart index 8fa5a765..c0723fa1 100644 --- a/lib/routes/enter_payment_info/enter_payment_info_page.dart +++ b/lib/routes/enter_payment_info/enter_payment_info_page.dart @@ -50,48 +50,65 @@ class _EnterPaymentInfoPageState extends State { child: SingleChildScrollView( child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _paymentInfoController, - decoration: InputDecoration( - labelText: texts.enter_payment_info_page_label, - suffixIcon: IconButton( - padding: const EdgeInsets.only(top: 21.0), - alignment: Alignment.bottomRight, - icon: Image( - image: const AssetImage('assets/icons/qr_scan.png'), - color: themeData.iconTheme.color, - width: 24.0, - height: 24.0, + child: Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _paymentInfoController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), + ), + prefixIcon: const SizedBox.shrink(), + contentPadding: EdgeInsets.zero, + labelText: texts.enter_payment_info_page_label, + suffixIcon: IconButton( + padding: const EdgeInsets.only(bottom: 12.0, right: 12.0), + alignment: Alignment.bottomRight, + icon: Image( + image: const AssetImage('assets/icons/qr_scan.png'), + color: themeData.iconTheme.color, + width: 24.0, + height: 24.0, + ), + tooltip: texts.enter_payment_info_page_scan_tooltip, + onPressed: () => _scanBarcode(), ), - tooltip: texts.enter_payment_info_page_scan_tooltip, - onPressed: () => _scanBarcode(), ), + style: FieldTextStyle.textStyle, + validator: (String? value) => errorMessage.isNotEmpty ? errorMessage : null, + onFieldSubmitted: (String input) async { + if (input.isNotEmpty) { + setState(() { + _paymentInfoController.text = input; + }); + await _validateInput(); + } + }, ), - style: FieldTextStyle.textStyle, - validator: (String? value) => errorMessage.isNotEmpty ? errorMessage : null, - onFieldSubmitted: (String input) async { - if (input.isNotEmpty) { - setState(() { - _paymentInfoController.text = input; - }); - await _validateInput(); - } - }, - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - texts.enter_payment_info_page_label_expanded, - style: FieldTextStyle.labelStyle.copyWith( - fontSize: 13.0, + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + texts.enter_payment_info_page_label_expanded, + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 13.0, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/routes/lnurl/widgets/lnurl_metadata_image.dart b/lib/routes/lnurl/widgets/lnurl_metadata_image.dart new file mode 100644 index 00000000..36e7d40e --- /dev/null +++ b/lib/routes/lnurl/widgets/lnurl_metadata_image.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +class LNURLMetadataImage extends StatelessWidget { + final String? base64String; + + const LNURLMetadataImage({ + super.key, + this.base64String, + }); + + @override + Widget build(BuildContext context) { + const double imageSize = 128.0; + + final Uint8List? imageBytes = base64String?.isNotEmpty == true ? base64Decode(base64String!) : null; + + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: imageSize, + minWidth: imageSize, + maxHeight: imageSize, + maxWidth: imageSize, + ), + child: imageBytes != null && imageBytes.isNotEmpty + ? Image.memory( + imageBytes, + width: imageSize, + fit: BoxFit.cover, + ) + : Image.asset( + 'assets/icons/app_icon.png', + width: imageSize, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/lib/routes/lnurl/widgets/lnurl_metadata.dart b/lib/routes/lnurl/widgets/lnurl_metadata_text.dart similarity index 51% rename from lib/routes/lnurl/widgets/lnurl_metadata.dart rename to lib/routes/lnurl/widgets/lnurl_metadata_text.dart index 990d56b4..e6c6e6b6 100644 --- a/lib/routes/lnurl/widgets/lnurl_metadata.dart +++ b/lib/routes/lnurl/widgets/lnurl_metadata_text.dart @@ -1,9 +1,5 @@ -import 'dart:convert'; -import 'dart:typed_data'; - import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; -import 'package:l_breez/theme/theme.dart'; import 'package:l_breez/utils/min_font_size.dart'; class LNURLMetadataText extends StatefulWidget { @@ -20,9 +16,11 @@ class _LNURLMetadataTextState extends State { @override Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return Container( constraints: const BoxConstraints( - maxHeight: 200, + maxHeight: 120, minWidth: double.infinity, ), child: Scrollbar( @@ -33,7 +31,12 @@ class _LNURLMetadataTextState extends State { controller: _scrollController, child: AutoSizeText( widget.metadataText, - style: Theme.of(context).paymentItemSubtitleTextStyle.copyWith(color: Colors.white70), + style: themeData.primaryTextTheme.displaySmall!.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1.156, + ), minFontSize: MinFontSize(context).minFontSize, ), ), @@ -41,33 +44,3 @@ class _LNURLMetadataTextState extends State { ); } } - -class LNURLMetadataImage extends StatelessWidget { - final String? base64String; - - const LNURLMetadataImage({super.key, this.base64String}); - - @override - Widget build(BuildContext context) { - if (base64String != null) { - final Uint8List bytes = base64Decode(base64String!); - if (bytes.isNotEmpty) { - const double imageSize = 128.0; - return ConstrainedBox( - constraints: const BoxConstraints( - minHeight: imageSize, - minWidth: imageSize, - maxWidth: imageSize, - maxHeight: imageSize, - ), - child: Image.memory( - bytes, - width: imageSize, - fit: BoxFit.fitWidth, - ), - ); - } - } - return const SizedBox.shrink(); - } -} diff --git a/lib/routes/lnurl/widgets/widgets.dart b/lib/routes/lnurl/widgets/widgets.dart index 3addc727..cb26b9f2 100644 --- a/lib/routes/lnurl/widgets/widgets.dart +++ b/lib/routes/lnurl/widgets/widgets.dart @@ -1 +1,2 @@ -export 'lnurl_metadata.dart'; +export 'lnurl_metadata_image.dart'; +export 'lnurl_metadata_text.dart'; diff --git a/lib/routes/receive_payment/lightning/receive_lightning_page.dart b/lib/routes/receive_payment/lightning/receive_lightning_page.dart index fa4fa738..07951c8b 100644 --- a/lib/routes/receive_payment/lightning/receive_lightning_page.dart +++ b/lib/routes/receive_payment/lightning/receive_lightning_page.dart @@ -34,6 +34,7 @@ class ReceiveLightningPaymentPageState extends State _scaffoldKey = GlobalKey(); final TextEditingController _descriptionController = TextEditingController(); + final FocusNode _descriptionFocusNode = FocusNode(); final TextEditingController _amountController = TextEditingController(); final FocusNode _amountFocusNode = FocusNode(); KeyboardDoneAction _doneAction = KeyboardDoneAction(); @@ -67,6 +68,7 @@ class ReceiveLightningPaymentPageState extends State( builder: (BuildContext context, CurrencyState currencyState) { - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _descriptionController, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.done, - maxLines: null, - maxLength: 90, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - decoration: InputDecoration( - labelText: texts.invoice_description_label, - ), - style: FieldTextStyle.textStyle, + return Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), ), - AmountFormField( - context: context, - texts: texts, - bitcoinCurrency: currencyState.bitcoinCurrency, - focusNode: _amountFocusNode, - autofocus: true, - readOnly: false, - controller: _amountController, - validatorFn: (int v) => validatePayment(v, lightningPaymentLimits), - style: FieldTextStyle.textStyle, - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: AutoSizeText( - texts.invoice_min_payment_limit( - currencyState.bitcoinCurrency.format( - lightningPaymentLimits.receive.minSat.toInt(), + ), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + focusNode: _descriptionFocusNode, + controller: _descriptionController, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + maxLength: 90, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + decoration: InputDecoration( + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), ), + prefixIcon: const SizedBox.shrink(), + contentPadding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), + border: const OutlineInputBorder(), + labelText: texts.invoice_description_label, + counterStyle: _descriptionFocusNode.hasFocus ? focusedCounterTextStyle : counterTextStyle, ), - style: textStyle, - maxLines: 1, - minFontSize: MinFontSize(context).minFontSize, + style: FieldTextStyle.textStyle, ), - ), - ], + const SizedBox(height: 8.0), + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + focusNode: _amountFocusNode, + autofocus: true, + readOnly: false, + controller: _amountController, + validatorFn: (int v) => validatePayment(v, lightningPaymentLimits), + style: FieldTextStyle.textStyle, + errorStyle: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: AutoSizeText( + texts.invoice_min_payment_limit( + currencyState.bitcoinCurrency.format( + lightningPaymentLimits.receive.minSat.toInt(), + ), + ), + style: paymentLimitInformationTextStyle, + maxLines: 1, + minFontSize: MinFontSize(context).minFontSize, + ), + ), + ].expand((Widget widget) sync* { + yield widget; + yield const Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, + ); + }).toList() + ..removeLast(), + ), ), ); }, @@ -210,11 +246,22 @@ class ReceiveLightningPaymentPageState extends State( future: receivePaymentResponseFuture, builder: (BuildContext context, AsyncSnapshot receiveSnapshot) { - return DestinationWidget( - snapshot: receiveSnapshot, - title: context.texts().receive_payment_method_lightning_invoice, - infoWidget: PaymentFeesMessageBox( - feesSat: prepareSnapshot.data!.feesSat.toInt(), + return Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: DestinationWidget( + snapshot: receiveSnapshot, + paymentMethod: context.texts().receive_payment_method_lightning_invoice, + infoWidget: PaymentFeesMessageBox( + feesSat: prepareSnapshot.data!.feesSat.toInt(), + ), ), ); }, 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 79d0b20a..bcccedc3 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 @@ -42,18 +42,30 @@ class ReceiveLightningAddressPageState extends State with SingleTi child: FadeTransition( opacity: _opacityAnimation, child: Dialog.fullscreen( + backgroundColor: themeData.colorScheme.surface, child: FutureBuilder( future: _lnurlWithdrawFuture, builder: (BuildContext context, AsyncSnapshot snapshot) { @@ -109,7 +110,9 @@ class _LnurlWithdrawDialogState extends State with SingleTi const Expanded(child: SizedBox.expand()), Text( texts.lnurl_withdraw_dialog_title, - style: themeData.dialogTheme.titleTextStyle, + style: themeData.dialogTheme.titleTextStyle!.copyWith( + color: themeData.isLightTheme ? themeData.textTheme.labelLarge!.color : Colors.white, + ), ), const SizedBox(height: 24), if (snapshotError == null) @@ -117,7 +120,11 @@ class _LnurlWithdrawDialogState extends State with SingleTi children: [ LoadingAnimatedText( loadingMessage: texts.lnurl_withdraw_dialog_wait, - textStyle: themeData.dialogTheme.contentTextStyle, + textStyle: themeData.dialogTheme.contentTextStyle!.copyWith( + color: themeData.isLightTheme + ? themeData.textTheme.labelLarge!.color + : Colors.white, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), @@ -130,6 +137,14 @@ class _LnurlWithdrawDialogState extends State with SingleTi else ScrollableErrorMessageWidget( title: texts.lnurl_withdraw_page_unknown_error_title, + titleStyle: FieldTextStyle.labelStyle.copyWith( + color: + themeData.isLightTheme ? themeData.textTheme.labelLarge!.color : Colors.white, + fontSize: 14.3, + ), + errorTextStyle: FieldTextStyle.labelStyle.copyWith( + color: themeData.isLightTheme ? Colors.red : themeData.colorScheme.error, + ), message: extractExceptionMessage(snapshotError, texts), padding: EdgeInsets.zero, ), diff --git a/lib/routes/receive_payment/lnurl/lnurl_withdraw_page.dart b/lib/routes/receive_payment/lnurl/lnurl_withdraw_page.dart index 3cc2a218..4ae5171c 100644 --- a/lib/routes/receive_payment/lnurl/lnurl_withdraw_page.dart +++ b/lib/routes/receive_payment/lnurl/lnurl_withdraw_page.dart @@ -5,6 +5,7 @@ import 'package:breez_liquid/breez_liquid.dart'; import 'package:breez_translations/breez_translations_locales.dart'; import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/cubit/cubit.dart'; @@ -38,7 +39,9 @@ class LnUrlWithdrawPage extends StatefulWidget { class LnUrlWithdrawPageState extends State { final GlobalKey _formKey = GlobalKey(); final GlobalKey _scaffoldKey = GlobalKey(); + final TextEditingController _descriptionController = TextEditingController(); + final FocusNode _descriptionFocusNode = FocusNode(); final TextEditingController _amountController = TextEditingController(); final FocusNode _amountFocusNode = FocusNode(); KeyboardDoneAction _doneAction = KeyboardDoneAction(); @@ -177,7 +180,7 @@ class LnUrlWithdrawPageState extends State { ); return Padding( - padding: const EdgeInsets.only(bottom: 40.0), + padding: const EdgeInsets.only(top: 32, bottom: 40.0), child: SingleChildScrollView( child: Form( key: _formKey, @@ -186,9 +189,13 @@ class LnUrlWithdrawPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Padding( + padding: EdgeInsets.only(bottom: 32), + child: Center(child: LNURLMetadataImage()), + ), if (_isFixedAmount) ...[ Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), + padding: const EdgeInsets.only(bottom: 32), child: LnWithdrawHeader( callback: widget.requestData.callback, amountSat: minWithdrawableSat, @@ -196,82 +203,129 @@ class LnUrlWithdrawPageState extends State { ), ), ], - if (!_isFixedAmount) ...[ - AmountFormField( - context: context, - texts: texts, - bitcoinCurrency: currencyState.bitcoinCurrency, - focusNode: _amountFocusNode, - autofocus: _isFormEnabled && errorMessage.isEmpty, - enabled: _isFormEnabled, - enableInteractiveSelection: _isFormEnabled, - controller: _amountController, - validatorFn: (int amountSat) => validatePayment( - amountSat: amountSat, - effectiveMinSat: effectiveMinSat, - effectiveMaxSat: effectiveMaxSat, + Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), ), - returnFN: (String amountStr) async { - if (amountStr.isNotEmpty) { - final int amountSat = currencyState.bitcoinCurrency.parse(amountStr); - setState(() { - _amountController.text = currencyState.bitcoinCurrency.format( - amountSat, - includeDisplayName: false, - ); - }); - _formKey.currentState?.validate(); - } - }, - onFieldSubmitted: (String amountStr) async { - if (amountStr.isNotEmpty) { - _formKey.currentState?.validate(); - } - }, - style: FieldTextStyle.textStyle, - errorMaxLines: 3, + color: Color.fromRGBO(40, 59, 74, 0.5), ), - ], - if (!_isFixedAmount) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.only(top: 8), - child: LnUrlWithdrawLimits( - limitsResponse: _lightningLimits, - minWithdrawableSat: minWithdrawableSat, - maxWithdrawableSat: maxWithdrawableSat, - onTap: (int amountSat) async { - _amountFocusNode.unfocus(); - setState(() { - _amountController.text = currencyState.bitcoinCurrency.format( - amountSat, - includeDisplayName: false, - ); - }); - _formKey.currentState?.validate(); - }, - ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Column( + children: [ + TextFormField( + focusNode: _descriptionFocusNode, + controller: _descriptionController, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + maxLength: 90, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + decoration: InputDecoration( + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), + ), + prefixIcon: const SizedBox.shrink(), + contentPadding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), + border: const OutlineInputBorder(), + labelText: texts.invoice_description_label, + counterStyle: _descriptionFocusNode.hasFocus + ? focusedCounterTextStyle + : counterTextStyle, + ), + style: FieldTextStyle.textStyle, + ), + if (!_isFixedAmount) ...[ + const SizedBox(height: 8.0), + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + focusNode: _amountFocusNode, + autofocus: _isFormEnabled && errorMessage.isEmpty, + enabled: _isFormEnabled, + enableInteractiveSelection: _isFormEnabled, + controller: _amountController, + validatorFn: (int amountSat) => validatePayment( + amountSat: amountSat, + effectiveMinSat: effectiveMinSat, + effectiveMaxSat: effectiveMaxSat, + ), + returnFN: (String amountStr) async { + if (amountStr.isNotEmpty) { + final int amountSat = currencyState.bitcoinCurrency.parse(amountStr); + setState(() { + _amountController.text = currencyState.bitcoinCurrency.format( + amountSat, + includeDisplayName: false, + ); + }); + _formKey.currentState?.validate(); + } + }, + onFieldSubmitted: (String amountStr) async { + if (amountStr.isNotEmpty) { + _formKey.currentState?.validate(); + } + }, + style: FieldTextStyle.textStyle, + errorMaxLines: 3, + errorStyle: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + ), + ], + if (!_isFormEnabled && !_isFixedAmount) ...[ + const SizedBox(height: 8.0), + AutoSizeText( + errorMessage, + maxLines: 3, + textAlign: TextAlign.left, + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + ), + ], + if (!_isFixedAmount) ...[ + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: LnUrlWithdrawLimits( + limitsResponse: _lightningLimits, + minWithdrawableSat: minWithdrawableSat, + maxWithdrawableSat: maxWithdrawableSat, + onTap: _isFormEnabled + ? (int amountSat) async { + _amountFocusNode.unfocus(); + setState(() { + _amountController.text = currencyState.bitcoinCurrency.format( + amountSat, + includeDisplayName: false, + ); + }); + _formKey.currentState?.validate(); + } + : (int amountSat) async { + return; + }, + ), + ), + ], + ].expand((Widget widget) sync* { + yield widget; + yield const Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, + ); + }).toList() + ..removeLast(), ), - ], - 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 (widget.requestData.defaultDescription.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentDescription( - metadataText: widget.requestData.defaultDescription, - ), - ), - ], + ), ], ), ), @@ -291,23 +345,16 @@ class LnUrlWithdrawPageState extends State { _fetchLightningLimits(); }, ) - : !_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.qr_code_dialog_action_close, - onPressed: () { - Navigator.of(context).pop(); - }, - ) - : SingleButtonBottomBar( - stickToBottom: true, - text: texts.invoice_action_redeem, - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - _withdraw(); - } - }, - ), + : SingleButtonBottomBar( + stickToBottom: true, + text: texts.invoice_action_redeem, + enabled: _isFormEnabled || _isFixedAmount && errorMessage.isEmpty, + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _withdraw(); + } + }, + ), ); } @@ -376,20 +423,22 @@ class LnUrlWithdrawPageState extends State { final String networkLimit = '(${currencyState.bitcoinCurrency.format( effectiveMaxSat, )})'; + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? texts.valid_payment_error_exceeds_the_limit(networkLimit) - : texts.lnurl_withdraw_dialog_error_amount_exceeds(effectiveMaxSat); + : '${texts.lnurl_withdraw_dialog_error_amount_exceeds(effectiveMaxSat)} ${currencyState.bitcoinCurrency.displayName}'; } else if (amountSat < effectiveMinSat) { final String effMinSendableFormatted = currencyState.bitcoinCurrency.format(effectiveMinSat); + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? '${texts.invoice_payment_validator_error_payment_below_invoice_limit(effMinSendableFormatted)}.' - : texts.lnurl_withdraw_dialog_error_amount_below(effectiveMinSat); + : '${texts.lnurl_withdraw_dialog_error_amount_below(effectiveMinSat)} ${currencyState.bitcoinCurrency.displayName}'; } else { message = PaymentValidator( validatePayment: _validateLnUrlWithdraw, currency: currencyState.bitcoinCurrency, texts: context.texts(), - ).validateOutgoing(amountSat); + ).validateIncoming(amountSat); } setState(() { errorMessage = message ?? ''; diff --git a/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_header.dart b/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_header.dart index 8ebe386c..ccf00a3f 100644 --- a/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_header.dart +++ b/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_header.dart @@ -43,15 +43,18 @@ class _LnWithdrawHeaderState extends State { children: [ Text( 'Redeeming funds from', - style: themeData.primaryTextTheme.displaySmall!.copyWith(fontSize: 16, color: Colors.white), + style: themeData.primaryTextTheme.displaySmall!.copyWith( + fontSize: 16, + color: Colors.white70, + ), textAlign: TextAlign.center, ), Text( domain, - style: Theme.of(context) - .primaryTextTheme - .headlineMedium! - .copyWith(fontSize: 16, color: Colors.white), + style: themeData.primaryTextTheme.headlineMedium!.copyWith( + fontSize: 18, + color: Colors.white, + ), textAlign: TextAlign.center, ), GestureDetector( @@ -70,42 +73,47 @@ class _LnWithdrawHeaderState extends State { constraints: const BoxConstraints( minWidth: double.infinity, ), - child: _showFiatCurrency && fiatConversion != null - ? Text( - fiatConversion.format(widget.amountSat), - style: balanceAmountTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ) - : RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: balanceAmountTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - text: currencyState.bitcoinCurrency.format( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: balanceAmountTextStyle.copyWith( + color: themeData.colorScheme.onSurface, + ), + text: _showFiatCurrency && fiatConversion != null + ? fiatConversion.format( + widget.amountSat, + addCurrencySymbol: false, + includeDisplayName: true, + ) + : currencyState.bitcoinCurrency.format( widget.amountSat, removeTrailingZeros: true, includeDisplayName: false, ), - children: [ - TextSpan( - text: ' ${currencyState.bitcoinCurrency.displayName}', - style: balanceCurrencyTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - ), - ], + children: [ + TextSpan( + text: _showFiatCurrency && fiatConversion != null + ? '' + : ' ${currencyState.bitcoinCurrency.displayName}', + style: balanceCurrencyTextStyle.copyWith( + color: themeData.colorScheme.onSurface, ), ), + ], + ), + ), ), ), /* if (fiatConversion != null) ...[ AutoSizeText( - "≈ ${fiatConversion.format(widget.totalAmount)}", + fiatConversion.format( + widget.amountSat, + addCurrencySymbol: false, + includeDisplayName: true, + ), style: balanceFiatConversionTextStyle.copyWith( + fontSize: 18.0, color: themeData.colorScheme.onSurface.withOpacity(0.7), ), textAlign: TextAlign.center, @@ -117,7 +125,7 @@ class _LnWithdrawHeaderState extends State { widget.errorMessage, textAlign: TextAlign.center, style: themeData.primaryTextTheme.displaySmall?.copyWith( - fontSize: 14.3, + fontSize: 18.0, color: themeData.colorScheme.error, ), ), diff --git a/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_limits.dart b/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_limits.dart index 46a30838..96406fb8 100644 --- a/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_limits.dart +++ b/lib/routes/receive_payment/lnurl/widgets/lnurl_withdraw_limits.dart @@ -33,15 +33,12 @@ class LnUrlWithdrawLimits extends StatelessWidget { final ThemeData themeData = Theme.of(context); if (limitsResponse == null) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: AutoSizeText( - texts.payment_limits_fetch_error_message, - maxLines: 3, - textAlign: TextAlign.left, - style: FieldTextStyle.labelStyle.copyWith( - color: themeData.isLightTheme ? Colors.red : themeData.colorScheme.error, - ), + return AutoSizeText( + texts.payment_limits_fetch_error_message, + maxLines: 3, + textAlign: TextAlign.left, + style: FieldTextStyle.labelStyle.copyWith( + color: themeData.isLightTheme ? Colors.red : themeData.colorScheme.error, ), ); } @@ -65,25 +62,25 @@ class LnUrlWithdrawLimits extends StatelessWidget { (effectiveMinSat == effectiveMaxSat) ? maxWithdrawableSat : effectiveMaxSat, ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - style: FieldTextStyle.labelStyle, - children: [ - TextSpan( - text: texts.lnurl_fetch_invoice_min(effMinSendableFormatted), - recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMinSat), - ), - TextSpan( - text: texts.lnurl_fetch_invoice_and(effMaxSendableFormatted), - recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMaxSat), - ), - ], + return Align( + alignment: Alignment.centerLeft, + child: RichText( + text: TextSpan( + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 14.3, ), + children: [ + TextSpan( + text: texts.lnurl_fetch_invoice_min(effMinSendableFormatted), + recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMinSat), + ), + TextSpan( + text: texts.lnurl_fetch_invoice_and(effMaxSendableFormatted), + recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMaxSat), + ), + ], ), - ], + ), ); } } diff --git a/lib/routes/receive_payment/onchain/bitcoin_address/receive_bitcoin_address_payment_page.dart b/lib/routes/receive_payment/onchain/bitcoin_address/receive_bitcoin_address_payment_page.dart index f0f0018e..a1bac541 100644 --- a/lib/routes/receive_payment/onchain/bitcoin_address/receive_bitcoin_address_payment_page.dart +++ b/lib/routes/receive_payment/onchain/bitcoin_address/receive_bitcoin_address_payment_page.dart @@ -29,6 +29,7 @@ class _ReceiveBitcoinAddressPaymentPageState extends State( future: receivePaymentResponseFuture, builder: (BuildContext context, AsyncSnapshot receiveSnapshot) { - return DestinationWidget( - snapshot: receiveSnapshot, - title: context.texts().withdraw_funds_btc_address, - infoWidget: PaymentFeesMessageBox( - feesSat: prepareSnapshot.data!.feesSat.toInt(), + return Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: DestinationWidget( + snapshot: receiveSnapshot, + paymentMethod: context.texts().withdraw_funds_btc_address, + infoWidget: PaymentFeesMessageBox( + feesSat: prepareSnapshot.data!.feesSat.toInt(), + ), ), ); }, @@ -174,51 +183,82 @@ class _ReceiveBitcoinAddressPaymentPageState extends State _buildForm(OnchainPaymentLimitsResponse onchainPaymentLimits) { final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); return BlocBuilder( builder: (BuildContext context, CurrencyState currencyState) { - return Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _descriptionController, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.done, - maxLines: null, - maxLength: 90, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - decoration: InputDecoration( - labelText: texts.invoice_description_label, - ), - style: FieldTextStyle.textStyle, + return Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), ), - AmountFormField( - context: context, - texts: texts, - bitcoinCurrency: currencyState.bitcoinCurrency, - focusNode: _amountFocusNode, - autofocus: true, - controller: _amountController, - validatorFn: (int v) => validatePayment(v, onchainPaymentLimits), - style: FieldTextStyle.textStyle, - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: AutoSizeText( - texts.invoice_min_payment_limit( - currencyState.bitcoinCurrency.format( - onchainPaymentLimits.receive.minSat.toInt(), + ), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + focusNode: _descriptionFocusNode, + controller: _descriptionController, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + maxLength: 90, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + decoration: InputDecoration( + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), ), + prefixIcon: const SizedBox.shrink(), + contentPadding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), + border: const OutlineInputBorder(), + labelText: texts.invoice_description_label, + counterStyle: _descriptionFocusNode.hasFocus ? focusedCounterTextStyle : counterTextStyle, ), - style: textStyle, - maxLines: 1, - minFontSize: MinFontSize(context).minFontSize, + style: FieldTextStyle.textStyle, ), - ), - ], + const Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, + ), + const SizedBox(height: 8.0), + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + focusNode: _amountFocusNode, + autofocus: true, + controller: _amountController, + validatorFn: (int v) => validatePayment(v, onchainPaymentLimits), + style: FieldTextStyle.textStyle, + errorStyle: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: AutoSizeText( + texts.invoice_min_payment_limit( + currencyState.bitcoinCurrency.format( + onchainPaymentLimits.receive.minSat.toInt(), + ), + ), + style: paymentLimitInformationTextStyle, + maxLines: 1, + minFontSize: MinFontSize(context).minFontSize, + ), + ), + ], + ), ), ); }, 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 70df53f4..4cf6d8c6 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/destination_widget.dart @@ -17,7 +17,7 @@ final Logger _logger = Logger('DestinationWidget'); class DestinationWidget extends StatefulWidget { final AsyncSnapshot? snapshot; final String? destination; - final String? title; + final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; final bool isLnAddress; @@ -26,7 +26,7 @@ class DestinationWidget extends StatefulWidget { super.key, this.snapshot, this.destination, - this.title, + this.paymentMethod, this.onLongPress, this.infoWidget, this.isLnAddress = false, @@ -155,21 +155,14 @@ class _DestinationWidgetState extends State { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: DestinationHeader( - snapshot: widget.snapshot, - destination: widget.destination, - paymentMethod: widget.title, - ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: DestinationQRWidget( snapshot: widget.snapshot, destination: widget.destination, + 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_header.dart index ab85fbeb..29726df2 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart @@ -2,16 +2,17 @@ import 'package:breez_translations/breez_translations_locales.dart'; import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/theme/src/theme.dart'; import 'package:l_breez/widgets/widgets.dart'; import 'package:service_injector/service_injector.dart'; import 'package:share_plus/share_plus.dart'; -class DestinationHeader extends StatelessWidget { +class DestinationActions extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; final String? paymentMethod; - const DestinationHeader({ + const DestinationActions({ required this.snapshot, required this.destination, super.key, @@ -21,100 +22,120 @@ class DestinationHeader extends StatelessWidget { @override Widget build(BuildContext context) { final String? destination = this.destination ?? snapshot?.data?.destination; - return SizedBox( - height: 64, + return Padding( + padding: const EdgeInsets.only(top: 24.0, bottom: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (paymentMethod != null && paymentMethod!.isNotEmpty) ...[ - Text(paymentMethod!), - ], - if (destination != null) ...[ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ShareIcon( + children: (destination != null) + ? [ + _CopyButton( destination: destination, paymentMethod: paymentMethod, ), - _CopyIcon( + _ShareButton( destination: destination, paymentMethod: paymentMethod, ), - ], - ), - ], - ], + ] + : [], ), ); } } -class _ShareIcon extends StatelessWidget { +class _CopyButton extends StatelessWidget { final String destination; final String? paymentMethod; - const _ShareIcon({ + const _CopyButton({ required this.destination, - required this.paymentMethod, + this.paymentMethod, }); @override Widget build(BuildContext context) { - final ThemeData themeData = Theme.of(context); + final BreezTranslations texts = context.texts(); - return Tooltip( - // TODO(erdemyerebasmaz): Add these messages to Breez-Translations - message: (paymentMethod != null && paymentMethod!.isNotEmpty) - ? 'Share $paymentMethod' - : 'Share deposit address', - child: IconButton( - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0, right: 2.0, left: 20.0), - icon: const Icon(IconData(0xe917, fontFamily: 'icomoon')), - color: themeData.colorScheme.primary, - onPressed: () { - Share.share(destination); - }, + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 48.0, + minWidth: 138.0, + ), + child: Tooltip( + message: texts.qr_code_dialog_copy, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon( + IconData(0xe90b, fontFamily: 'icomoon'), + size: 20.0, + ), + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + label: const Text( + 'COPY', + style: balanceFiatConversionTextStyle, + ), + onPressed: () { + ServiceInjector().deviceClient.setClipboardText(destination); + showFlushbar( + context, + message: (paymentMethod != null && paymentMethod!.isNotEmpty) + ? texts.payment_details_dialog_copied(paymentMethod!) + : texts.invoice_btc_address_deposit_address_copied, + duration: const Duration(seconds: 3), + ); + }, + ), ), ); } } -class _CopyIcon extends StatelessWidget { +class _ShareButton extends StatelessWidget { final String destination; final String? paymentMethod; - const _CopyIcon({ + const _ShareButton({ required this.destination, - this.paymentMethod, + required this.paymentMethod, }); @override Widget build(BuildContext context) { - final BreezTranslations texts = context.texts(); - final ThemeData themeData = Theme.of(context); - - return Tooltip( - message: texts.qr_code_dialog_copy, - child: IconButton( - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0, right: 8.0, left: 2.0), - icon: const Icon(IconData(0xe90b, fontFamily: 'icomoon')), - color: themeData.colorScheme.primary, - onPressed: () { - ServiceInjector().deviceClient.setClipboardText(destination); - // TODO(erdemyerebasmaz): Create payment method specific copy messages to Breez-Translations - showFlushbar( - context, - message: (paymentMethod != null && paymentMethod!.isNotEmpty) - ? texts.payment_details_dialog_copied(paymentMethod!) - : texts.invoice_btc_address_deposit_address_copied, - duration: const Duration(seconds: 3), - ); - }, + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 48.0, + minWidth: 138.0, + ), + child: Tooltip( + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + message: (paymentMethod != null && paymentMethod!.isNotEmpty) + ? 'Share $paymentMethod' + : 'Share deposit address', + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon( + IconData(0xe917, fontFamily: 'icomoon'), + size: 20.0, + ), + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + label: const Text( + 'SHARE', + style: balanceFiatConversionTextStyle, + ), + onPressed: () { + Share.share(destination); + }, + ), ), ); } diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_image.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_image.dart index b86aa684..a231b697 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_image.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_image.dart @@ -10,9 +10,17 @@ class DestinationQRImage extends StatelessWidget { Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, - child: SizedBox( + child: Container( width: 230.0, height: 230.0, + clipBehavior: Clip.antiAlias, + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + ), child: CompactQRImage( data: destination, ), 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 f2e62b5e..45a985da 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 @@ -1,19 +1,18 @@ -import 'package:breez_translations/breez_translations_locales.dart'; -import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/routes/routes.dart'; -import 'package:l_breez/utils/exceptions.dart'; class DestinationQRWidget extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; const DestinationQRWidget({ required this.snapshot, required this.destination, + this.paymentMethod, super.key, this.onLongPress, this.infoWidget, @@ -21,17 +20,10 @@ class DestinationQRWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final BreezTranslations texts = context.texts(); - final String? destination = this.destination ?? snapshot?.data?.destination; return AnimatedCrossFade( - firstChild: LoadingOrError( - error: snapshot?.error, - displayErrorMessage: snapshot?.error != null - ? extractExceptionMessage(snapshot!.error!, texts) - : texts.qr_code_dialog_warning_message_error, - ), + firstChild: LoadingOrError(error: snapshot?.error), secondChild: destination == null ? const SizedBox.shrink() : Column( @@ -42,6 +34,11 @@ class DestinationQRWidget extends StatelessWidget { destination: destination, ), ), + DestinationActions( + snapshot: snapshot, + destination: destination, + paymentMethod: paymentMethod, + ), if (infoWidget != null) ...[ SizedBox( width: MediaQuery.of(context).size.width, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/loading_or_error.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/loading_or_error.dart index cb026ef0..481f4eb8 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/loading_or_error.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/loading_or_error.dart @@ -1,10 +1,13 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; +import 'package:l_breez/utils/exceptions.dart'; +import 'package:l_breez/widgets/scrollable_error_message_widget.dart'; class LoadingOrError extends StatelessWidget { final Object? error; - final String displayErrorMessage; - const LoadingOrError({required this.displayErrorMessage, super.key, this.error}); + const LoadingOrError({super.key, this.error}); @override Widget build(BuildContext context) { @@ -25,14 +28,12 @@ class LoadingOrError extends StatelessWidget { ); } - return Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Text( - displayErrorMessage, - style: themeData.primaryTextTheme.displaySmall!.copyWith( - fontSize: 16, - ), - ), + final BreezTranslations texts = context.texts(); + + return ScrollableErrorMessageWidget( + title: '${texts.qr_code_dialog_warning_message_error}:', + message: extractExceptionMessage(error!, texts), + padding: EdgeInsets.zero, ); } } diff --git a/lib/routes/receive_payment/widgets/payment_message_boxes/payment_info_message_box.dart b/lib/routes/receive_payment/widgets/payment_message_boxes/payment_info_message_box.dart index fadf2fc7..42da92ad 100644 --- a/lib/routes/receive_payment/widgets/payment_message_boxes/payment_info_message_box.dart +++ b/lib/routes/receive_payment/widgets/payment_message_boxes/payment_info_message_box.dart @@ -11,11 +11,14 @@ class PaymentInfoMessageBox extends StatelessWidget { final ThemeData themeData = Theme.of(context); return WarningBox( - boxPadding: const EdgeInsets.symmetric(vertical: 16), - contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 12), + boxPadding: EdgeInsets.zero, + backgroundColor: themeData.colorScheme.error.withOpacity(0.1), + contentPadding: const EdgeInsets.all(16.0), child: Text( message, - style: themeData.textTheme.titleLarge, + style: themeData.textTheme.titleLarge?.copyWith( + color: themeData.colorScheme.error, + ), textAlign: TextAlign.center, ), ); diff --git a/lib/routes/send_payment/chainswap/send_chainswap_form.dart b/lib/routes/send_payment/chainswap/send_chainswap_form.dart index 38b68471..ba7cb40c 100644 --- a/lib/routes/send_payment/chainswap/send_chainswap_form.dart +++ b/lib/routes/send_payment/chainswap/send_chainswap_form.dart @@ -1,3 +1,4 @@ +import 'package:auto_size_text/auto_size_text.dart'; import 'package:breez_translations/breez_translations_locales.dart'; import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; @@ -6,6 +7,8 @@ import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/cubit/cubit.dart'; import 'package:l_breez/models/currency.dart'; import 'package:l_breez/routes/routes.dart'; +import 'package:l_breez/theme/src/theme.dart'; +import 'package:l_breez/utils/min_font_size.dart'; import 'package:l_breez/widgets/widgets.dart'; import 'package:logging/logging.dart'; @@ -15,7 +18,7 @@ class SendChainSwapForm extends StatefulWidget { final GlobalKey formKey; final TextEditingController amountController; final TextEditingController addressController; - final bool withdrawMaxValue; + final bool useEntireBalance; final ValueChanged onChanged; final BitcoinAddressData? btcAddressData; final BitcoinCurrency bitcoinCurrency; @@ -26,7 +29,7 @@ class SendChainSwapForm extends StatefulWidget { required this.amountController, required this.addressController, required this.onChanged, - required this.withdrawMaxValue, + required this.useEntireBalance, required this.bitcoinCurrency, required this.paymentLimits, super.key, @@ -39,6 +42,8 @@ class SendChainSwapForm extends StatefulWidget { class _SendChainSwapFormState extends State { final ValidatorHolder _validatorHolder = ValidatorHolder(); + final FocusNode _amountFocusNode = FocusNode(); + KeyboardDoneAction _doneAction = KeyboardDoneAction(); @override void initState() { @@ -46,6 +51,14 @@ class _SendChainSwapFormState extends State { if (widget.btcAddressData != null) { _fillBtcAddressData(widget.btcAddressData!); } + + _doneAction = KeyboardDoneAction(focusNodes: [_amountFocusNode]); + } + + @override + void dispose() { + _doneAction.dispose(); + super.dispose(); } void _fillBtcAddressData(BitcoinAddressData addressData) { @@ -67,55 +80,118 @@ class _SendChainSwapFormState extends State { @override Widget build(BuildContext context) { final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Form( - key: widget.formKey, - child: Column( - children: [ - BitcoinAddressTextFormField( - controller: widget.addressController, - validatorHolder: _validatorHolder, + return Form( + key: widget.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BitcoinAddressTextFormField( + controller: widget.addressController, + validatorHolder: _validatorHolder, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, ), - WithdrawFundsAmountTextFormField( - context: context, - bitcoinCurrency: widget.bitcoinCurrency, - controller: widget.amountController, - withdrawMaxValue: widget.withdrawMaxValue, - balance: widget.paymentLimits.send.maxSat, - policy: WithdrawFundsPolicy( - WithdrawKind.withdrawFunds, - widget.paymentLimits.send.minSat, - widget.paymentLimits.send.maxSat, - ), + ), + WithdrawFundsAmountTextFormField( + context: context, + bitcoinCurrency: widget.bitcoinCurrency, + controller: widget.amountController, + focusNode: _amountFocusNode, + useEntireBalance: widget.useEntireBalance, + balance: widget.paymentLimits.send.maxSat, + policy: WithdrawFundsPolicy( + WithdrawKind.withdrawFunds, + widget.paymentLimits.send.minSat, + widget.paymentLimits.send.maxSat, ), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - texts.withdraw_funds_use_all_funds, - style: const TextStyle(color: Colors.white), - maxLines: 1, - ), - trailing: Switch( - value: widget.withdrawMaxValue, - activeColor: Colors.white, - onChanged: (bool value) async { - setState(() { - widget.onChanged(value); - if (value) { - final AccountCubit accountCubit = context.read(); - final AccountState accountState = accountCubit.state; - _setAmount(accountState.walletInfo!.balanceSat.toInt()); - } else { - widget.amountController.text = ''; - } - }); - }, + ), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: AutoSizeText( + texts.invoice_min_payment_limit( + widget.bitcoinCurrency.format( + widget.paymentLimits.send.minSat.toInt(), + ), ), + style: paymentLimitInformationTextStyle, + maxLines: 1, + minFontSize: MinFontSize(context).minFontSize, ), - ], - ), + ), + BlocBuilder( + builder: (BuildContext context, CurrencyState currencyState) { + return BlocBuilder( + builder: (BuildContext context, AccountState accountState) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + texts.withdraw_funds_use_all_funds, + style: const TextStyle( + color: Colors.white, + fontSize: 18.0, + height: 1.208, + fontWeight: FontWeight.w500, + fontFamily: 'IBMPlexSans', + ), + ), + subtitle: Text( + '${texts.available_balance_label} ${currencyState.bitcoinCurrency.format( + accountState.walletInfo!.balanceSat.toInt(), + )}', + style: const TextStyle( + color: Color.fromRGBO(182, 188, 193, 1), + fontSize: 14 + .0, + height: 1.182, + fontWeight: FontWeight.w400, + fontFamily: 'IBMPlexSans', + ), + ), + trailing: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Switch( + value: widget.useEntireBalance, + activeColor: Colors.white, + activeTrackColor: themeData.primaryColor, + onChanged: (bool value) async { + setState( + () { + widget.onChanged(value); + if (value) { + final String formattedAmount = currencyState.bitcoinCurrency + .format( + accountState.walletInfo!.balanceSat.toInt(), + includeDisplayName: false, + userInput: true, + ) + .formatBySatAmountFormFieldFormatter(); + setState(() { + widget.amountController.text = formattedAmount; + }); + } else { + widget.amountController.text = ''; + } + }, + ); + }, + ), + ), + ), + ); + }, + ); + }, + ), + ], ), ); } diff --git a/lib/routes/send_payment/chainswap/send_chainswap_form_page.dart b/lib/routes/send_payment/chainswap/send_chainswap_form_page.dart index 037cc299..9dd0e96e 100644 --- a/lib/routes/send_payment/chainswap/send_chainswap_form_page.dart +++ b/lib/routes/send_payment/chainswap/send_chainswap_form_page.dart @@ -1,23 +1,20 @@ -import 'package:breez_translations/breez_translations_locales.dart'; -import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/models/currency.dart'; import 'package:l_breez/routes/routes.dart'; -import 'package:l_breez/utils/exceptions.dart'; -import 'package:l_breez/widgets/widgets.dart'; -import 'package:logging/logging.dart'; - -final Logger _logger = Logger('SendChainSwapFormPage'); class SendChainSwapFormPage extends StatefulWidget { + final GlobalKey formKey; final BitcoinCurrency bitcoinCurrency; final OnchainPaymentLimitsResponse paymentLimits; final BitcoinAddressData? btcAddressData; + final TextEditingController amountController; const SendChainSwapFormPage({ + required this.formKey, required this.bitcoinCurrency, required this.paymentLimits, + required this.amountController, super.key, this.btcAddressData, }); @@ -27,95 +24,45 @@ class SendChainSwapFormPage extends StatefulWidget { } class _SendChainSwapFormPageState extends State { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _amountController = TextEditingController(); final TextEditingController _addressController = TextEditingController(); - bool _withdrawMaxValue = false; + + bool _useEntireBalance = false; @override Widget build(BuildContext context) { - final BreezTranslations texts = context.texts(); - return SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 32), child: Column( children: [ - SendChainSwapForm( - formKey: _formKey, - amountController: _amountController, - addressController: _addressController, - withdrawMaxValue: _withdrawMaxValue, - btcAddressData: widget.btcAddressData, - bitcoinCurrency: widget.bitcoinCurrency, - paymentLimits: widget.paymentLimits, - onChanged: (bool value) { - setState(() { - _withdrawMaxValue = value; - }); - }, - ), - const AvailableBalance(), - Expanded(child: Container()), - SingleButtonBottomBar( - text: texts.withdraw_funds_action_next, - onPressed: _prepareSendChainSwap, + Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + color: Color.fromRGBO(40, 59, 74, 0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: SendChainSwapForm( + formKey: widget.formKey, + amountController: widget.amountController, + addressController: _addressController, + useEntireBalance: _useEntireBalance, + btcAddressData: widget.btcAddressData, + bitcoinCurrency: widget.bitcoinCurrency, + paymentLimits: widget.paymentLimits, + onChanged: (bool value) { + setState(() { + _useEntireBalance = value; + }); + }, + ), ), ], ), ), ); } - - int _getAmount() { - int amount = 0; - try { - amount = widget.bitcoinCurrency.parse(_amountController.text); - } catch (e) { - _logger.warning('Failed to parse the input amount', e); - } - return amount; - } - - void _prepareSendChainSwap() async { - final BreezTranslations texts = context.texts(); - final NavigatorState navigator = Navigator.of(context); - if (_formKey.currentState?.validate() ?? false) { - final TransparentPageRoute loaderRoute = createLoaderRoute(context); - navigator.push(loaderRoute); - try { - final int amount = _getAmount(); - if (loaderRoute.isActive) { - navigator.removeRoute(loaderRoute); - } - navigator.push( - FadeInRoute( - builder: (_) => SendChainSwapConfirmationPage( - amountSat: amount, - onchainRecipientAddress: _addressController.text, - isMaxValue: _withdrawMaxValue, - ), - ), - ); - } catch (error) { - if (loaderRoute.isActive) { - navigator.removeRoute(loaderRoute); - } - _logger.severe('Received error: $error'); - if (!context.mounted) { - return; - } - showFlushbar( - context, - message: texts.reverse_swap_upstream_generic_error_message( - extractExceptionMessage(error, texts), - ), - ); - } finally { - if (loaderRoute.isActive) { - navigator.removeRoute(loaderRoute); - } - } - } - } } diff --git a/lib/routes/send_payment/chainswap/send_chainswap_page.dart b/lib/routes/send_payment/chainswap/send_chainswap_page.dart index ce019e21..a9ce9d73 100644 --- a/lib/routes/send_payment/chainswap/send_chainswap_page.dart +++ b/lib/routes/send_payment/chainswap/send_chainswap_page.dart @@ -5,8 +5,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/cubit/cubit.dart'; import 'package:l_breez/routes/routes.dart'; +import 'package:l_breez/utils/exceptions.dart'; import 'package:l_breez/widgets/back_button.dart' as back_button; import 'package:l_breez/widgets/widgets.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('SendChainSwapPage'); class SendChainSwapPage extends StatefulWidget { final BitcoinAddressData? btcAddressData; @@ -21,10 +25,13 @@ class SendChainSwapPage extends StatefulWidget { class _SendChainSwapPageState extends State { final GlobalKey _scaffoldKey = GlobalKey(); + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _amountController = TextEditingController(); @override Widget build(BuildContext context) { final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); return Scaffold( key: _scaffoldKey, @@ -35,16 +42,24 @@ class _SendChainSwapPageState extends State { body: BlocBuilder( builder: (BuildContext context, PaymentLimitsState snapshot) { if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: ScrollableErrorMessageWidget( + showIcon: true, + title: texts.payment_limits_generic_error_title, + message: extractExceptionMessage(snapshot.errorMessage, texts), + ), + ); + } + final OnchainPaymentLimitsResponse? onchainPaymentLimits = snapshot.onchainPaymentLimits; + if (onchainPaymentLimits == null) { return Center( - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 0, 32, 0), - child: Text( - texts.reverse_swap_upstream_generic_error_message(snapshot.errorMessage), - textAlign: TextAlign.center, - ), + child: Loader( + color: themeData.primaryColor.withOpacity(0.5), ), ); } + if (snapshot.onchainPaymentLimits == null) { final ThemeData themeData = Theme.of(context); @@ -58,12 +73,87 @@ class _SendChainSwapPageState extends State { final CurrencyCubit currencyCubit = context.read(); final CurrencyState currencyState = currencyCubit.state; return SendChainSwapFormPage( + formKey: _formKey, + amountController: _amountController, bitcoinCurrency: currencyState.bitcoinCurrency, paymentLimits: snapshot.onchainPaymentLimits!, btcAddressData: widget.btcAddressData, ); }, ), + bottomNavigationBar: BlocBuilder( + builder: (BuildContext context, PaymentLimitsState snapshot) { + return snapshot.hasError + ? SingleButtonBottomBar( + stickToBottom: true, + text: texts.invoice_btc_address_action_retry, + onPressed: () { + final PaymentLimitsCubit paymentLimitsCubit = context.read(); + paymentLimitsCubit.fetchOnchainLimits(); + }, + ) + : snapshot.lightningPaymentLimits == null + ? const SizedBox.shrink() + : SingleButtonBottomBar( + text: texts.withdraw_funds_action_next, + onPressed: _prepareSendChainSwap, + ); + }, + ), ); } + + void _prepareSendChainSwap() async { + final BreezTranslations texts = context.texts(); + final NavigatorState navigator = Navigator.of(context); + if (_formKey.currentState?.validate() ?? false) { + final TransparentPageRoute loaderRoute = createLoaderRoute(context); + navigator.push(loaderRoute); + try { + final int amount = _getAmount(); + if (loaderRoute.isActive) { + navigator.removeRoute(loaderRoute); + } + navigator.push( + FadeInRoute( + builder: (_) => SendChainSwapConfirmationPage( + amountSat: amount, + onchainRecipientAddress: _amountController.text, + isMaxValue: false, + ), + ), + ); + } catch (error) { + if (loaderRoute.isActive) { + navigator.removeRoute(loaderRoute); + } + _logger.severe('Received error: $error'); + if (!context.mounted) { + return; + } + showFlushbar( + context, + message: texts.reverse_swap_upstream_generic_error_message( + extractExceptionMessage(error, texts), + ), + ); + } finally { + if (loaderRoute.isActive) { + navigator.removeRoute(loaderRoute); + } + } + } + } + + int _getAmount() { + final CurrencyCubit currencyCubit = context.read(); + final CurrencyState currencyState = currencyCubit.state; + int amount = 0; + try { + amount = currencyState.bitcoinCurrency.parse(_amountController.text); + } catch (e) { + _logger.warning('Failed to parse the input amount', e); + } + return amount; + } } diff --git a/lib/routes/send_payment/chainswap/widgets/bitcoin_address_text_form_field.dart b/lib/routes/send_payment/chainswap/widgets/bitcoin_address_text_form_field.dart index 3e9dbcd3..2b32dda1 100644 --- a/lib/routes/send_payment/chainswap/widgets/bitcoin_address_text_form_field.dart +++ b/lib/routes/send_payment/chainswap/widgets/bitcoin_address_text_form_field.dart @@ -49,9 +49,16 @@ class BitcoinAddressTextFormFieldState extends State Function(int amountSat) onTap; + + const OnchainPaymentLimits({ + required this.limitsResponse, + required this.minSendableSat, + required this.maxSendableSat, + required this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + final CurrencyCubit currencyCubit = context.read(); + final CurrencyState currencyState = currencyCubit.state; + + final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); + + if (limitsResponse == null) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: AutoSizeText( + texts.payment_limits_fetch_error_message, + maxLines: 3, + textAlign: TextAlign.left, + style: FieldTextStyle.labelStyle.copyWith( + color: themeData.isLightTheme ? Colors.red : themeData.colorScheme.error, + ), + ), + ); + } + + final int minNetworkLimit = limitsResponse!.send.minSat.toInt(); + final int maxNetworkLimit = limitsResponse!.send.maxSat.toInt(); + final int effectiveMinSat = min( + max(minNetworkLimit, minSendableSat), + maxNetworkLimit, + ); + final int effectiveMaxSat = max( + minNetworkLimit, + min(maxNetworkLimit, maxSendableSat), + ); + + // Displays the original range if range is outside payment limits + final String effMinSendableFormatted = currencyState.bitcoinCurrency.format( + (effectiveMinSat == effectiveMaxSat) ? minSendableSat : effectiveMinSat, + ); + final String effMaxSendableFormatted = currencyState.bitcoinCurrency.format( + (effectiveMinSat == effectiveMaxSat) ? maxSendableSat : effectiveMaxSat, + ); + + return RichText( + text: TextSpan( + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 14.3, + ), + children: [ + TextSpan( + text: texts.lnurl_fetch_invoice_min(effMinSendableFormatted), + recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMinSat), + ), + TextSpan( + text: texts.lnurl_fetch_invoice_and(effMaxSendableFormatted), + recognizer: TapGestureRecognizer()..onTap = () => onTap(effectiveMaxSat), + ), + ], + ), + ); + } +} diff --git a/lib/routes/send_payment/chainswap/widgets/widgets.dart b/lib/routes/send_payment/chainswap/widgets/widgets.dart index 248cbd36..5f04e208 100644 --- a/lib/routes/send_payment/chainswap/widgets/widgets.dart +++ b/lib/routes/send_payment/chainswap/widgets/widgets.dart @@ -1,5 +1,6 @@ export 'available_balance.dart'; export 'bitcoin_address_text_form_field.dart'; export 'fee/fee_widgets.dart'; +export 'onchain_payment_limits.dart'; export 'send_chainswap_button.dart'; export 'withdraw_funds_amount_text_form_field.dart'; diff --git a/lib/routes/send_payment/chainswap/widgets/withdraw_funds_amount_text_form_field.dart b/lib/routes/send_payment/chainswap/widgets/withdraw_funds_amount_text_form_field.dart index 87e66c11..4454c506 100644 --- a/lib/routes/send_payment/chainswap/widgets/withdraw_funds_amount_text_form_field.dart +++ b/lib/routes/send_payment/chainswap/widgets/withdraw_funds_amount_text_form_field.dart @@ -13,13 +13,15 @@ class WithdrawFundsAmountTextFormField extends AmountFormField { required super.bitcoinCurrency, required super.context, required TextEditingController super.controller, - required bool withdrawMaxValue, + required FocusNode super.focusNode, + required bool useEntireBalance, required WithdrawFundsPolicy policy, required BigInt balance, super.key, }) : super( texts: context.texts(), - readOnly: policy.withdrawKind == WithdrawKind.unexpectedFunds || withdrawMaxValue, + enableInteractiveSelection: !useEntireBalance, + readOnly: policy.withdrawKind == WithdrawKind.unexpectedFunds || useEntireBalance, validatorFn: (int amount) { _logger.info('Validator called for $amount'); return PaymentValidator( diff --git a/lib/routes/send_payment/lightning/ln_invoice_payment_page.dart b/lib/routes/send_payment/lightning/ln_invoice_payment_page.dart index 46bec07c..e063e38b 100644 --- a/lib/routes/send_payment/lightning/ln_invoice_payment_page.dart +++ b/lib/routes/send_payment/lightning/ln_invoice_payment_page.dart @@ -137,43 +137,73 @@ class LnPaymentPageState extends State { } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0), - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LnPaymentHeader( - payeeName: '', - totalAmount: amountSat! + (_prepareResponse?.feesSat.toInt() ?? 0), - errorMessage: errorMessage, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentAmount(amountSat: amountSat!), + padding: const EdgeInsets.fromLTRB(16, 32, 16, 40), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: Center(child: LNURLMetadataImage()), + ), + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: LnPaymentHeader( + payeeName: '', + totalAmount: amountSat! + (_prepareResponse?.feesSat.toInt() ?? 0), + errorMessage: errorMessage, ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentFee( - isCalculatingFees: _isCalculatingFees, - feesSat: errorMessage.isEmpty ? _prepareResponse?.feesSat.toInt() : null, + ), + Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), ), + color: Color.fromRGBO(40, 59, 74, 0.5), ), - if (widget.lnInvoice.description != null && - widget.lnInvoice.description!.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentDescription( - metadataText: widget.lnInvoice.description!, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LnPaymentAmount( + amountSat: amountSat!, + hasError: errorMessage.isNotEmpty, + ), ), - ), - ], - ], - ), + if (_prepareResponse != null && _prepareResponse!.feesSat.toInt() != 0) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LnPaymentFee( + isCalculatingFees: _isCalculatingFees, + feesSat: errorMessage.isEmpty ? _prepareResponse?.feesSat.toInt() : null, + ), + ), + ], + if (widget.lnInvoice.description != null && + widget.lnInvoice.description!.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LnPaymentDescription( + metadataText: widget.lnInvoice.description!, + ), + ), + ], + ].expand((Widget widget) sync* { + yield widget; + yield const Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, + ); + }).toList() + ..removeLast(), + ), + ), + ], ), ), ); @@ -189,23 +219,14 @@ class LnPaymentPageState extends State { _fetchLightningLimits(); }, ) - : errorMessage.isNotEmpty - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.ln_payment_action_close, - onPressed: () { - Navigator.of(context).pop(); - }, - ) - : _prepareResponse != null - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.ln_payment_action_send, - onPressed: () async { - Navigator.pop(context, _prepareResponse); - }, - ) - : const SizedBox.shrink(), + : SingleButtonBottomBar( + stickToBottom: true, + text: texts.ln_payment_action_send, + enabled: _prepareResponse != null && errorMessage.isEmpty, + onPressed: () async { + Navigator.pop(context, _prepareResponse); + }, + ), ); } @@ -227,14 +248,16 @@ class LnPaymentPageState extends State { final String networkLimit = '(${currencyState.bitcoinCurrency.format( effectiveMaxSat, )})'; + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? texts.valid_payment_error_exceeds_the_limit(networkLimit) - : texts.lnurl_payment_page_error_exceeds_limit(effectiveMaxSat); + : '${texts.lnurl_payment_page_error_exceeds_limit(effectiveMaxSat)} ${currencyState.bitcoinCurrency.displayName}'; } else if (amountSat < effectiveMinSat) { final String effMinSendableFormatted = currencyState.bitcoinCurrency.format(effectiveMinSat); + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? '${texts.invoice_payment_validator_error_payment_below_invoice_limit(effMinSendableFormatted)}.' - : texts.lnurl_payment_page_error_below_limit(effectiveMinSat); + : '${texts.lnurl_payment_page_error_below_limit(effectiveMinSat)} ${currencyState.bitcoinCurrency.displayName}'; } else { message = PaymentValidator( validatePayment: _validateLnUrlPayment, diff --git a/lib/routes/send_payment/lnurl/lnurl_payment_page.dart b/lib/routes/send_payment/lnurl/lnurl_payment_page.dart index 9d4e79f7..a2cb8919 100644 --- a/lib/routes/send_payment/lnurl/lnurl_payment_page.dart +++ b/lib/routes/send_payment/lnurl/lnurl_payment_page.dart @@ -35,9 +35,13 @@ class LnUrlPaymentPage extends StatefulWidget { class LnUrlPaymentPageState extends State { final GlobalKey _formKey = GlobalKey(); final GlobalKey _scaffoldKey = GlobalKey(); + final TextEditingController _descriptionController = TextEditingController(); + final FocusNode _descriptionFocusNode = FocusNode(); + final TextEditingController _amountController = TextEditingController(); final FocusNode _amountFocusNode = FocusNode(); + KeyboardDoneAction _doneAction = KeyboardDoneAction(); bool _isFixedAmount = false; @@ -49,6 +53,8 @@ class LnUrlPaymentPageState extends State { PrepareLnUrlPayResponse? _prepareResponse; + bool _useEntireBalance = false; + @override void initState() { super.initState(); @@ -234,12 +240,12 @@ class LnUrlPaymentPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(bottom: 32), child: Center(child: LNURLMetadataImage(base64String: base64String)), ), if (_isFixedAmount) ...[ Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), + padding: const EdgeInsets.only(bottom: 32), child: LnPaymentHeader( payeeName: payeeName, totalAmount: maxSendableSat + (_prepareResponse?.feesSat.toInt() ?? 0), @@ -247,103 +253,198 @@ class LnUrlPaymentPageState extends State { ), ), ], - if (!_isFixedAmount) ...[ - AmountFormField( - context: context, - texts: texts, - bitcoinCurrency: currencyState.bitcoinCurrency, - focusNode: _amountFocusNode, - autofocus: _isFormEnabled && errorMessage.isEmpty, - enabled: _isFormEnabled, - enableInteractiveSelection: _isFormEnabled, - controller: _amountController, - validatorFn: (int amountSat) => validatePayment( - amountSat: amountSat, - effectiveMinSat: effectiveMinSat, - effectiveMaxSat: effectiveMaxSat, - ), - returnFN: (String amountStr) async { - if (amountStr.isNotEmpty) { - final int amountSat = currencyState.bitcoinCurrency.parse(amountStr); - setState(() { - _amountController.text = currencyState.bitcoinCurrency.format( - amountSat, - includeDisplayName: false, - ); - }); - _formKey.currentState?.validate(); - } - }, - onFieldSubmitted: (String amountStr) async { - if (amountStr.isNotEmpty) { - _formKey.currentState?.validate(); - } - }, - style: FieldTextStyle.textStyle, - errorMaxLines: 3, - ), - ], - if (!_isFixedAmount) ...[ - Padding( - padding: const EdgeInsets.only(top: 8), - child: LnUrlPaymentLimits( - limitsResponse: _lightningLimits, - minSendableSat: minSendableSat, - maxSendableSat: maxSendableSat, - onTap: (int amountSat) async { - _amountFocusNode.unfocus(); - setState(() { - _amountController.text = currencyState.bitcoinCurrency.format( - amountSat, - includeDisplayName: false, - ); - }); - _formKey.currentState?.validate(); - }, - ), - ), - 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, + Container( + decoration: const ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), ), ), - ], - ], - if (_prepareResponse != null && _isFixedAmount) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentAmount(amountSat: maxSendableSat), + color: Color.fromRGBO(40, 59, 74, 0.5), ), - ], - if (_isFixedAmount) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentFee( - isCalculatingFees: _isCalculatingFees, - feesSat: errorMessage.isEmpty ? _prepareResponse?.feesSat.toInt() : null, - ), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Column( + children: [ + if (metadataText != null && metadataText.isNotEmpty) ...[ + LnPaymentDescription( + metadataText: metadataText, + ), + ], + if (!_isFixedAmount) ...[ + Column( + children: [ + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + focusNode: _amountFocusNode, + autofocus: _isFormEnabled && errorMessage.isEmpty, + enabled: _isFormEnabled || !_useEntireBalance, + enableInteractiveSelection: _isFormEnabled, + controller: _amountController, + validatorFn: (int amountSat) => validatePayment( + amountSat: amountSat, + effectiveMinSat: effectiveMinSat, + effectiveMaxSat: effectiveMaxSat, + ), + errorStyle: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + returnFN: (String amountStr) async { + if (amountStr.isNotEmpty) { + final int amountSat = currencyState.bitcoinCurrency.parse(amountStr); + setState(() { + _amountController.text = currencyState.bitcoinCurrency.format( + amountSat, + includeDisplayName: false, + ); + }); + _formKey.currentState?.validate(); + } + }, + onFieldSubmitted: (String amountStr) async { + if (amountStr.isNotEmpty) { + _formKey.currentState?.validate(); + } + }, + style: FieldTextStyle.textStyle, + errorMaxLines: 3, + ), + if (!_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty) ...[ + const SizedBox(height: 8.0), + AutoSizeText( + errorMessage, + maxLines: 3, + textAlign: TextAlign.left, + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 18.0, + color: themeData.colorScheme.error, + ), + ), + ], + Padding( + padding: const EdgeInsets.only(top: 12.0, bottom: 8), + child: LnUrlPaymentLimits( + limitsResponse: _lightningLimits, + minSendableSat: minSendableSat, + maxSendableSat: maxSendableSat, + onTap: (int amountSat) async { + if (_isFormEnabled) { + _amountFocusNode.unfocus(); + setState(() { + _amountController.text = currencyState.bitcoinCurrency.format( + amountSat, + includeDisplayName: false, + ); + }); + _formKey.currentState?.validate(); + } + }, + ), + ), + BlocBuilder( + builder: (BuildContext context, AccountState accountState) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + texts.withdraw_funds_use_all_funds, + style: const TextStyle( + color: Colors.white, + fontSize: 18.0, + height: 1.208, + fontWeight: FontWeight.w500, + fontFamily: 'IBMPlexSans', + ), + ), + subtitle: Text( + '${texts.available_balance_label} ${currencyState.bitcoinCurrency.format( + accountState.walletInfo!.balanceSat.toInt(), + )}', + style: const TextStyle( + color: Color.fromRGBO(182, 188, 193, 1), + fontSize: 14 + .0, + height: 1.182, + fontWeight: FontWeight.w400, + fontFamily: 'IBMPlexSans', + ), + ), + trailing: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Switch( + value: _useEntireBalance, + activeColor: Colors.white, + activeTrackColor: themeData.primaryColor, + onChanged: (bool value) async { + setState( + () { + setState(() { + _useEntireBalance = value; + }); + if (value) { + final String formattedAmount = currencyState.bitcoinCurrency + .format( + accountState.walletInfo!.balanceSat.toInt(), + includeDisplayName: false, + userInput: true, + ) + .formatBySatAmountFormFieldFormatter(); + setState(() { + _amountController.text = formattedAmount; + }); + _formKey.currentState?.validate(); + } else { + _amountController.text = ''; + } + }, + ); + }, + ), + ), + ); + }, + ), + ], + ), + ], + if (_prepareResponse != null && _isFixedAmount) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LnPaymentAmount( + amountSat: maxSendableSat, + hasError: !(_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty), + ), + ), + ], + if (_prepareResponse != null && _prepareResponse!.feesSat.toInt() != 0) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LnPaymentFee( + isCalculatingFees: _isCalculatingFees, + feesSat: errorMessage.isEmpty ? _prepareResponse?.feesSat.toInt() : null, + ), + ), + ], + if (widget.requestData.commentAllowed > 0) ...[ + LnUrlPaymentComment( + enabled: _isFormEnabled, + descriptionController: _descriptionController, + descriptionFocusNode: _descriptionFocusNode, + maxCommentLength: widget.requestData.commentAllowed.toInt(), + ), + ], + ].expand((Widget widget) sync* { + yield widget; + yield const Divider( + height: 32.0, + color: Color.fromRGBO(40, 59, 74, 1), + indent: 0.0, + endIndent: 0.0, + ); + }).toList() + ..removeLast(), ), - ], - if (metadataText != null && metadataText.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: LnPaymentDescription( - metadataText: metadataText, - ), - ), - ], - if (widget.requestData.commentAllowed > 0) ...[ - LnUrlPaymentComment( - enabled: _isFormEnabled, - descriptionController: _descriptionController, - maxCommentLength: widget.requestData.commentAllowed.toInt(), - ), - ], + ), ], ), ), @@ -361,33 +462,25 @@ class LnUrlPaymentPageState extends State { _fetchLightningLimits(); }, ) - : !_isFormEnabled || _isFixedAmount && errorMessage.isNotEmpty + : !_isFixedAmount ? SingleButtonBottomBar( stickToBottom: true, - text: texts.ln_payment_action_close, - onPressed: () { - Navigator.of(context).pop(); + text: texts.lnurl_payment_page_action_next, + enabled: _isFormEnabled, + onPressed: () async { + if (_formKey.currentState?.validate() ?? false) { + await _openConfirmationPage(); + } }, ) - : !_isFixedAmount - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.lnurl_payment_page_action_next, - onPressed: () async { - if (_formKey.currentState?.validate() ?? false) { - await _openConfirmationPage(); - } - }, - ) - : _prepareResponse != null - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.ln_payment_action_send, - onPressed: () async { - Navigator.pop(context, _prepareResponse); - }, - ) - : const SizedBox.shrink(), + : SingleButtonBottomBar( + stickToBottom: true, + enabled: _prepareResponse != null && errorMessage.isEmpty, + text: texts.ln_payment_action_send, + onPressed: () async { + Navigator.pop(context, _prepareResponse); + }, + ), ); } @@ -431,14 +524,16 @@ class LnUrlPaymentPageState extends State { final String networkLimit = '(${currencyState.bitcoinCurrency.format( effectiveMaxSat, )})'; + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? texts.valid_payment_error_exceeds_the_limit(networkLimit) - : texts.lnurl_payment_page_error_exceeds_limit(effectiveMaxSat); + : '${texts.lnurl_payment_page_error_exceeds_limit(effectiveMaxSat)} ${currencyState.bitcoinCurrency.displayName}'; } else if (amountSat < effectiveMinSat) { final String effMinSendableFormatted = currencyState.bitcoinCurrency.format(effectiveMinSat); + // TODO(erdemyerebasmaz): Add necessary messages to Breez-Translations that uses formatted string for amount message = throwError ? '${texts.invoice_payment_validator_error_payment_below_invoice_limit(effMinSendableFormatted)}.' - : texts.lnurl_payment_page_error_below_limit(effectiveMinSat); + : '${texts.lnurl_payment_page_error_below_limit(effectiveMinSat)} ${currencyState.bitcoinCurrency.displayName}'; } else { message = PaymentValidator( validatePayment: _validateLnUrlPayment, diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_amount.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_amount.dart index 14462acd..9a9c082f 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_amount.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_amount.dart @@ -7,8 +7,13 @@ import 'package:l_breez/cubit/cubit.dart'; class LnPaymentAmount extends StatelessWidget { final int amountSat; + final bool hasError; - const LnPaymentAmount({required this.amountSat, super.key}); + const LnPaymentAmount({ + required this.amountSat, + required this.hasError, + super.key, + }); @override Widget build(BuildContext context) { @@ -24,7 +29,10 @@ class LnPaymentAmount extends StatelessWidget { padding: const EdgeInsets.only(right: 8.0), child: AutoSizeText( texts.ln_payment_amount_label, - style: themeData.primaryTextTheme.headlineMedium?.copyWith(color: Colors.white), + style: themeData.primaryTextTheme.headlineMedium?.copyWith( + fontSize: 18.0, + color: Colors.white, + ), textAlign: TextAlign.left, maxLines: 1, ), @@ -35,7 +43,10 @@ class LnPaymentAmount extends StatelessWidget { reverse: true, child: AutoSizeText( currencyState.bitcoinCurrency.format(amountSat), - style: TextStyle(color: themeData.colorScheme.error), + style: TextStyle( + fontSize: 18.0, + color: hasError ? themeData.colorScheme.error : Colors.white, + ), textAlign: TextAlign.right, maxLines: 1, ), diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_comment.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_comment.dart index 165f911e..c53581e6 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_comment.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_comment.dart @@ -8,10 +8,12 @@ class LnUrlPaymentComment extends StatelessWidget { final bool enabled; final int maxCommentLength; final TextEditingController descriptionController; + final FocusNode descriptionFocusNode; const LnUrlPaymentComment({ required this.enabled, required this.descriptionController, + required this.descriptionFocusNode, required this.maxCommentLength, super.key, }); @@ -19,22 +21,31 @@ class LnUrlPaymentComment extends StatelessWidget { @override Widget build(BuildContext context) { final BreezTranslations texts = context.texts(); - final ThemeData themeData = Theme.of(context); - return TextFormField( - enabled: enabled, - readOnly: !enabled, - controller: descriptionController, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.done, - maxLines: null, - maxLength: maxCommentLength, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - decoration: InputDecoration( - labelText: texts.lnurl_payment_page_comment_label, - labelStyle: themeData.primaryTextTheme.headlineMedium?.copyWith(color: Colors.white), + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: TextFormField( + enabled: enabled, + readOnly: !enabled, + controller: descriptionController, + focusNode: descriptionFocusNode, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + maxLength: maxCommentLength, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + decoration: InputDecoration( + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), + ), + prefixIcon: const SizedBox.shrink(), + contentPadding: const EdgeInsets.only(left: 16, top: 16, bottom: 16), + border: const OutlineInputBorder(), + labelText: texts.lnurl_payment_page_comment_label, + counterStyle: descriptionFocusNode.hasFocus ? focusedCounterTextStyle : counterTextStyle, + ), + style: FieldTextStyle.textStyle, ), - style: themeData.paymentItemSubtitleTextStyle.copyWith(color: Colors.white70), ); } } diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_description.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_description.dart index 4e66a6bf..bb045181 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_description.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_description.dart @@ -3,6 +3,7 @@ import 'package:breez_translations/breez_translations_locales.dart'; import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:l_breez/routes/routes.dart'; +import 'package:l_breez/widgets/widgets.dart'; class LnPaymentDescription extends StatelessWidget { final String metadataText; @@ -19,13 +20,21 @@ class LnPaymentDescription extends StatelessWidget { children: [ AutoSizeText( texts.ln_payment_description_label, - style: themeData.primaryTextTheme.headlineMedium?.copyWith(color: Colors.white), + style: themeData.primaryTextTheme.headlineMedium?.copyWith( + fontSize: 18.0, + color: Colors.white, + ), textAlign: TextAlign.left, maxLines: 1, ), Padding( padding: const EdgeInsets.only(top: 8.0), - child: LNURLMetadataText(metadataText: metadataText), + child: WarningBox( + boxPadding: EdgeInsets.zero, + backgroundColor: themeData.primaryColorLight.withOpacity(0.1), + borderColor: themeData.primaryColorLight.withOpacity(0.7), + child: LNURLMetadataText(metadataText: metadataText), + ), ), ], ); diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_fee.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_fee.dart index e99550c5..c264f593 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_fee.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_fee.dart @@ -29,7 +29,10 @@ class LnPaymentFee extends StatelessWidget { padding: const EdgeInsets.only(right: 8.0), child: AutoSizeText( texts.ln_payment_fee_label, - style: themeData.primaryTextTheme.headlineMedium?.copyWith(color: Colors.white), + style: themeData.primaryTextTheme.headlineMedium?.copyWith( + fontSize: 18.0, + color: Colors.white, + ), textAlign: TextAlign.left, maxLines: 1, ), @@ -52,12 +55,11 @@ class LnPaymentFee extends StatelessWidget { : (feesSat != null) ? AutoSizeText( texts.ln_payment_fee_amount_positive( - currencyState.bitcoinCurrency.format( - feesSat!, - ), + currencyState.bitcoinCurrency.format(feesSat!), ), style: TextStyle( - color: themeData.colorScheme.error.withOpacity(0.4), + fontSize: 18.0, + color: themeData.colorScheme.error.withOpacity(0.8), ), textAlign: TextAlign.right, maxLines: 1, @@ -65,7 +67,8 @@ class LnPaymentFee extends StatelessWidget { : AutoSizeText( texts.ln_payment_fee_amount_unknown(currencyState.bitcoinCurrency.displayName), style: TextStyle( - color: themeData.colorScheme.error.withOpacity(0.4), + fontSize: 18.0, + color: themeData.colorScheme.error.withOpacity(0.8), ), textAlign: TextAlign.right, maxLines: 1, diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_header.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_header.dart index 189af0d7..22d54cd6 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_header.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_header.dart @@ -44,17 +44,20 @@ class _LnPaymentHeaderState extends State { children: [ Text( widget.payeeName, - style: Theme.of(context) - .primaryTextTheme - .headlineMedium! - .copyWith(fontSize: 16, color: Colors.white), + style: themeData.primaryTextTheme.headlineMedium!.copyWith( + fontSize: 18, + color: Colors.white, + ), textAlign: TextAlign.center, ), Text( widget.payeeName.isEmpty ? texts.payment_request_dialog_requested : texts.payment_request_dialog_requesting, - style: themeData.primaryTextTheme.displaySmall!.copyWith(fontSize: 16, color: Colors.white), + style: themeData.primaryTextTheme.displaySmall!.copyWith( + fontSize: 16, + color: Colors.white70, + ), textAlign: TextAlign.center, ), GestureDetector( @@ -73,42 +76,47 @@ class _LnPaymentHeaderState extends State { constraints: const BoxConstraints( minWidth: double.infinity, ), - child: _showFiatCurrency && fiatConversion != null - ? Text( - fiatConversion.format(widget.totalAmount), - style: balanceAmountTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ) - : RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: balanceAmountTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - text: currencyState.bitcoinCurrency.format( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: balanceAmountTextStyle.copyWith( + color: themeData.colorScheme.onSurface, + ), + text: _showFiatCurrency && fiatConversion != null + ? fiatConversion.format( + widget.totalAmount, + addCurrencySymbol: false, + includeDisplayName: true, + ) + : currencyState.bitcoinCurrency.format( widget.totalAmount, removeTrailingZeros: true, includeDisplayName: false, ), - children: [ - TextSpan( - text: ' ${currencyState.bitcoinCurrency.displayName}', - style: balanceCurrencyTextStyle.copyWith( - color: themeData.colorScheme.onSurface, - ), - ), - ], + children: [ + TextSpan( + text: _showFiatCurrency && fiatConversion != null + ? '' + : ' ${currencyState.bitcoinCurrency.displayName}', + style: balanceCurrencyTextStyle.copyWith( + color: themeData.colorScheme.onSurface, ), ), + ], + ), + ), ), ), /* if (fiatConversion != null) ...[ AutoSizeText( - "≈ ${fiatConversion.format(widget.totalAmount)}", + fiatConversion.format( + widget.totalAmount, + addCurrencySymbol: false, + includeDisplayName: true, + ), style: balanceFiatConversionTextStyle.copyWith( + fontSize: 18.0, color: themeData.colorScheme.onSurface.withOpacity(0.7), ), textAlign: TextAlign.center, @@ -120,7 +128,7 @@ class _LnPaymentHeaderState extends State { widget.errorMessage, textAlign: TextAlign.center, style: themeData.primaryTextTheme.displaySmall?.copyWith( - fontSize: 14.3, + fontSize: 18, color: themeData.colorScheme.error, ), ), diff --git a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_limits.dart b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_limits.dart index ba266d57..8628c1f7 100644 --- a/lib/routes/send_payment/lnurl/widgets/lnurl_payment_limits.dart +++ b/lib/routes/send_payment/lnurl/widgets/lnurl_payment_limits.dart @@ -67,7 +67,9 @@ class LnUrlPaymentLimits extends StatelessWidget { return RichText( text: TextSpan( - style: FieldTextStyle.labelStyle, + style: FieldTextStyle.labelStyle.copyWith( + fontSize: 14.3, + ), children: [ TextSpan( text: texts.lnurl_fetch_invoice_min(effMinSendableFormatted), diff --git a/lib/theme/src/breez_dark_theme.dart b/lib/theme/src/breez_dark_theme.dart index 8c9821a0..fef410bb 100644 --- a/lib/theme/src/breez_dark_theme.dart +++ b/lib/theme/src/breez_dark_theme.dart @@ -63,10 +63,10 @@ final ThemeData breezDarkTheme = ThemeData( ), titleLarge: TextStyle( color: Colors.white, - fontSize: 12.3, + fontSize: 14.0, fontWeight: FontWeight.w400, - letterSpacing: 0.25, - height: 1.22, + letterSpacing: 0.4, + height: 1.182, ), ), primaryTextTheme: TextTheme( diff --git a/lib/theme/src/breez_light_theme.dart b/lib/theme/src/breez_light_theme.dart index 7b0ad6d0..f8ef5920 100644 --- a/lib/theme/src/breez_light_theme.dart +++ b/lib/theme/src/breez_light_theme.dart @@ -64,10 +64,10 @@ final ThemeData breezLightTheme = ThemeData( ), titleLarge: const TextStyle( color: Colors.white, - fontSize: 12.3, + fontSize: 14.0, fontWeight: FontWeight.w400, - letterSpacing: 0.25, - height: 1.22, + letterSpacing: 0.4, + height: 1.182, ), ), primaryTextTheme: TextTheme( diff --git a/lib/theme/src/theme_extensions.dart b/lib/theme/src/theme_extensions.dart index 843e4974..0776e261 100644 --- a/lib/theme/src/theme_extensions.dart +++ b/lib/theme/src/theme_extensions.dart @@ -4,7 +4,12 @@ import 'package:l_breez/theme/theme.dart'; class FieldTextStyle { FieldTextStyle._(); - static TextStyle textStyle = TextStyle(color: BreezColors.white[500], fontSize: 16.4, letterSpacing: 0.15); + static TextStyle textStyle = TextStyle( + color: BreezColors.white[500], + fontSize: 18.0, + letterSpacing: 0.15, + height: 1.234, + ); static TextStyle labelStyle = TextStyle(color: BreezColors.white[200], letterSpacing: 0.4); } @@ -44,7 +49,14 @@ const TextStyle bottomSheetTextStyle = TextStyle( final TextStyle bottomSheetMenuItemStyle = TextStyle(color: BreezColors.white[400], fontSize: 14.3, letterSpacing: 0.55); final TextStyle blueLinkStyle = TextStyle(color: BreezColors.blue[500], fontSize: 16.0, height: 1.5); -final TextStyle textStyle = TextStyle(color: BreezColors.white[400], fontSize: 16.0); +final TextStyle textStyle = TextStyle(color: BreezColors.white[300], fontSize: 16.0); + +const TextStyle paymentLimitInformationTextStyle = TextStyle( + color: Colors.white54, + fontSize: 14.0, + height: 1.182, + fontWeight: FontWeight.w400, +); const TextStyle navigationDrawerHandleStyle = TextStyle( fontSize: 16.0, letterSpacing: 0.2, @@ -116,6 +128,20 @@ const TextStyle warningStyle = TextStyle( fontSize: 16.0, ); +const TextStyle counterTextStyle = TextStyle( + color: Colors.white54, + fontSize: 14.0, + height: 1.182, + fontWeight: FontWeight.w400, +); + +final TextStyle focusedCounterTextStyle = TextStyle( + color: BreezColors.white[500], + fontSize: 14.0, + height: 1.182, + fontWeight: FontWeight.w400, +); + extension ThemeExtensions on ThemeData { bool get isLightTheme => primaryColor == breezLightTheme.primaryColor; diff --git a/lib/utils/fiat_conversion.dart b/lib/utils/fiat_conversion.dart index e14ae6ad..ec7a40d9 100644 --- a/lib/utils/fiat_conversion.dart +++ b/lib/utils/fiat_conversion.dart @@ -39,13 +39,25 @@ class FiatConversion { return satoshies.toDouble() / 100000000 * exchangeRate; } - String format(int amount) { + String format( + int amount, { + bool includeDisplayName = false, + bool addCurrencySymbol = true, + bool removeTrailingZeros = false, + }) { final double fiatValue = satToFiat(amount); - return formatFiat(fiatValue); + return formatFiat( + fiatValue, + includeDisplayName: includeDisplayName, + addCurrencySymbol: addCurrencySymbol, + removeTrailingZeros: removeTrailingZeros, + ); } String formatFiat( double fiatAmount, { + bool includeDisplayName = false, + bool addCurrencySymbol = true, bool removeTrailingZeros = false, }) { final Locale locale = getSystemLocale(); @@ -70,7 +82,11 @@ class FiatConversion { formatter.maximumFractionDigits = fractionSize; formattedAmount = formatter.format(fiatAmount); } - formattedAmount = (symbolPosition == 1) ? formattedAmount + symbolText : symbolText + formattedAmount; + if (addCurrencySymbol) { + formattedAmount = (symbolPosition == 1) ? formattedAmount + symbolText : symbolText + formattedAmount; + } else if (includeDisplayName) { + formattedAmount += ' ${currencyData.id}'; + } if (removeTrailingZeros) { final RegExp removeTrailingZeros = RegExp(r'([.]0*)(?!.*\d)'); formattedAmount = formattedAmount.replaceAll(removeTrailingZeros, ''); diff --git a/lib/widgets/amount_form_field/amount_form_field.dart b/lib/widgets/amount_form_field/amount_form_field.dart index cab184c3..129805bf 100644 --- a/lib/widgets/amount_form_field/amount_form_field.dart +++ b/lib/widgets/amount_form_field/amount_form_field.dart @@ -1,15 +1,14 @@ import 'package:breez_translations/generated/breez_translations.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/models/currency.dart'; import 'package:l_breez/theme/theme.dart'; import 'package:l_breez/utils/fiat_conversion.dart'; import 'package:l_breez/widgets/widgets.dart'; -export 'currency_converter_dialog.dart'; -export 'sat_amount_form_field_formatter.dart'; +export 'currency_converter_bottom_sheet.dart'; +export 'input_formatter/sat_amount_form_field_formatter.dart'; +export 'widgets/widgets.dart'; class AmountFormField extends TextFormField { final FiatConversion? fiatConversion; @@ -42,15 +41,29 @@ class AmountFormField extends TextFormField { bool? readOnly, bool? autofocus, int? errorMaxLines, + TextStyle? labelStyle, + TextStyle? floatingLabelStyle, + TextStyle? errorStyle, }) : super( keyboardType: TextInputType.numberWithOptions( decimal: bitcoinCurrency != BitcoinCurrency.sat, ), autofocus: autofocus ?? false, decoration: InputDecoration( - labelText: texts.amount_form_denomination( - bitcoinCurrency.displayName, + border: const OutlineInputBorder(), + prefixIconConstraints: BoxConstraints.tight( + const Size(16, 56), ), + prefixIcon: const SizedBox.shrink(), + label: Text( + texts.amount_form_denomination( + bitcoinCurrency.displayName, + ), + style: labelStyle, + ), + contentPadding: EdgeInsets.zero, + floatingLabelStyle: floatingLabelStyle, + errorStyle: errorStyle, errorMaxLines: errorMaxLines, suffixIcon: (readOnly ?? false) ? null @@ -60,20 +73,25 @@ class AmountFormField extends TextFormField { ? fiatConversion!.logoPath : 'assets/icons/btc_convert.png', color: iconColor ?? BreezColors.white[500], + height: 24, ), - padding: const EdgeInsets.only(top: 21.0), + padding: const EdgeInsets.only(bottom: 12.0, right: 12.0), alignment: Alignment.bottomRight, - onPressed: () => showDialog( - useRootNavigator: false, + onPressed: () => showModalBottomSheet( context: context, - builder: (_) => CurrencyConverterDialog( - context.read(), - returnFN ?? - (String value) => controller!.text = bitcoinCurrency.format( - bitcoinCurrency.parse(value), - includeDisplayName: false, - ), - validatorFn, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + isScrollControlled: true, + builder: (BuildContext context) => CurrencyConverterBottomSheet( + onConvert: returnFN ?? + (String value) { + return controller!.text = bitcoinCurrency.format( + bitcoinCurrency.parse(value), + includeDisplayName: false, + ); + }, + validatorFn: validatorFn, ), ), ), diff --git a/lib/widgets/amount_form_field/currency_converter_bottom_sheet.dart b/lib/widgets/amount_form_field/currency_converter_bottom_sheet.dart new file mode 100644 index 00000000..44f9cf90 --- /dev/null +++ b/lib/widgets/amount_form_field/currency_converter_bottom_sheet.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:l_breez/widgets/widgets.dart'; + +class CurrencyConverterBottomSheet extends StatefulWidget { + final Function(String string) onConvert; + final String? Function(int amount) validatorFn; + + const CurrencyConverterBottomSheet({ + required this.onConvert, + required this.validatorFn, + super.key, + }); + + @override + State createState() => _CurrencyConverterBottomSheetState(); +} + +class _CurrencyConverterBottomSheetState extends State + with SingleTickerProviderStateMixin { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _fiatAmountController = TextEditingController(); + final FocusNode _fiatAmountFocusNode = FocusNode(); + KeyboardDoneAction _doneAction = KeyboardDoneAction(); + + Timer? _exchangeRateRefreshTimer; + AnimationController? _animationController; + Animation? _colorAnimation; + final ValueNotifier _exchangeRateNotifier = ValueNotifier(null); + + String? _selectedFiatCurrency; + + @override + void initState() { + super.initState(); + _fiatAmountController.addListener(() => setState(() {})); + _doneAction = KeyboardDoneAction(focusNodes: [_fiatAmountFocusNode]); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _setupAnimation(); + _fetchExchangeRates(); + _startExchangeRateRefreshTimer(); + }); + } + + void _setupAnimation() { + final ThemeData themeData = Theme.of(context); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + // Loop back to start and stop + _animationController?.addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _animationController?.reverse(); + } else if (status == AnimationStatus.dismissed) { + _animationController?.stop(); + } + }); + + _colorAnimation = ColorTween( + begin: themeData.primaryTextTheme.titleSmall!.color!.withOpacity(0.7), + end: themeData.textTheme.headlineMedium!.color, + ).animate(_animationController!) + ..addListener(() { + setState(() {}); + }); + } + + void _fetchExchangeRates() { + final CurrencyCubit currencyCubit = context.read(); + currencyCubit.fetchExchangeRates().catchError( + (Object value) { + if (mounted) { + final BreezTranslations texts = context.texts(); + setState(() { + Navigator.pop(context); + showFlushbar( + context, + message: texts.currency_converter_dialog_error_exchange_rate, + ); + }); + } + return {}; + }, + ); + } + + void _startExchangeRateRefreshTimer() { + final CurrencyCubit currencyCubit = context.read(); + // Refresh exchange rates every 30 seconds + _exchangeRateRefreshTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => currencyCubit.fetchExchangeRates(), + ); + } + + @override + void dispose() { + super.dispose(); + _doneAction.dispose(); + _animationController?.dispose(); + _exchangeRateNotifier.dispose(); + _exchangeRateRefreshTimer?.cancel(); + FocusManager.instance.primaryFocus?.unfocus(); + } + + @override + Widget build(BuildContext context) { + final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: BlocBuilder( + builder: (BuildContext context, CurrencyState state) { + if (state.preferredCurrencies.isEmpty || !state.fiatEnabled) { + return const Center(child: CircularProgressIndicator()); + } + + _updateExchangeRateIfNeeded(state.fiatExchangeRate); + + _selectedFiatCurrency ??= state.fiatId; + + return 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( + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + 'Select Fiat Currency:', + style: themeData.primaryTextTheme.headlineMedium!.copyWith( + fontSize: 18.0, + color: Colors.white, + ), + textAlign: TextAlign.left, + ), + ), + FiatCurrencyChips( + selectedCurrency: _selectedFiatCurrency, + onCurrencySelected: (String currency) { + final CurrencyCubit currencyCubit = context.read(); + setState(() { + _selectedFiatCurrency = currency; + currencyCubit.setFiatId(currency); + }); + }, + ), + const Divider( + height: 32.0, + color: Colors.white24, + indent: 16.0, + endIndent: 16.0, + ), + FiatInputField( + formKey: _formKey, + controller: _fiatAmountController, + focusNode: _fiatAmountFocusNode, + fiatConversion: state.fiatConversion(), + validatorFn: widget.validatorFn, + ), + const SizedBox(height: 8.0), + SatEquivalentLabel( + controller: _fiatAmountController, + ), + ExchangeRateLabel( + exchangeRateNotifier: _exchangeRateNotifier, + colorAnimation: _colorAnimation, + ), + const SizedBox(height: 8.0), + Align( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: SingleButtonBottomBar( + text: texts.currency_converter_dialog_action_done, + expand: true, + onPressed: () { + if (_fiatAmountController.text.isEmpty) { + Navigator.pop(context); + return; + } + if (_formKey.currentState?.validate() ?? false) { + final double inputAmount = double.tryParse(_fiatAmountController.text) ?? 0; + final int convertedAmount = state.fiatConversion()?.fiatToSat(inputAmount) ?? 0; + + widget.onConvert( + state.bitcoinCurrency.format( + convertedAmount, + includeDisplayName: false, + userInput: true, + ), + ); + Navigator.pop(context); + } + }, + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + void _updateExchangeRateIfNeeded(double? newRate) { + if (newRate != null && newRate != _exchangeRateNotifier.value) { + _exchangeRateNotifier.value = newRate; + + // Blink exchange rate label when exchange rate changes or a different fiat currency is selected. + if (!(_animationController?.isAnimating ?? true)) { + _animationController?.forward(); + } + } + } +} diff --git a/lib/widgets/amount_form_field/currency_converter_dialog.dart b/lib/widgets/amount_form_field/currency_converter_dialog.dart deleted file mode 100644 index a1180d9a..00000000 --- a/lib/widgets/amount_form_field/currency_converter_dialog.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:breez_translations/breez_translations_locales.dart'; -import 'package:breez_translations/generated/breez_translations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; -import 'package:l_breez/cubit/cubit.dart'; -import 'package:l_breez/theme/theme.dart'; -import 'package:l_breez/utils/fiat_conversion.dart'; -import 'package:l_breez/utils/min_font_size.dart'; -import 'package:l_breez/widgets/widgets.dart'; - -class CurrencyConverterDialog extends StatefulWidget { - final Function(String string) _onConvert; - final String? Function(int amount) validatorFn; - final CurrencyCubit _currencyCubit; - - const CurrencyConverterDialog(this._currencyCubit, this._onConvert, this.validatorFn, {super.key}); - - @override - CurrencyConverterDialogState createState() { - return CurrencyConverterDialogState(); - } -} - -class CurrencyConverterDialogState extends State - with SingleTickerProviderStateMixin { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _fiatAmountController = TextEditingController(); - final FocusNode _fiatAmountFocusNode = FocusNode(); - - AnimationController? _controller; - - double? _exchangeRate; - - final AutoSizeGroup _autoSizeGroup = AutoSizeGroup(); - - @override - void initState() { - super.initState(); - _fiatAmountController.addListener(() => setState(() {})); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 400), - ); - // Loop back to start and stop - _controller!.addStatusListener((AnimationStatus status) { - if (status == AnimationStatus.completed) { - _controller!.reverse(); - } else if (status == AnimationStatus.dismissed) { - _controller!.stop(); - } - }); - - widget._currencyCubit.fetchExchangeRates().catchError((Object value) { - if (mounted) { - final BreezTranslations texts = context.texts(); - setState(() { - Navigator.pop(context); - showFlushbar( - context, - message: texts.currency_converter_dialog_error_exchange_rate, - ); - }); - } - return {}; - }); - } - - @override - void dispose() { - _fiatAmountController.dispose(); - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (BuildContext context, CurrencyState currencyState) { - if (currencyState.preferredCurrencies.isEmpty || !currencyState.fiatEnabled) { - return const Loader(); - } - - final double exchangeRate = currencyState.fiatExchangeRate!; - _updateExchangeLabel(exchangeRate); - - return AlertDialog( - title: _dialogBody(currencyState), - titlePadding: const EdgeInsets.fromLTRB(24.0, 16.0, 16.0, 8.0), - contentPadding: const EdgeInsets.fromLTRB(24.0, 0.0, 24.0, 16.0), - content: _dialogContent(currencyState), - actions: _buildActions(currencyState), - ); - }, - ); - } - - Widget _dialogBody(CurrencyState currencyState) { - final ThemeData themeData = Theme.of(context); - final BreezTranslations texts = context.texts(); - - final Iterable> items = currencyState.preferredCurrencies.map((String value) { - final FiatCurrency fiatCurrencyData = - currencyState.fiatCurrenciesData.firstWhere((FiatCurrency c) => c.id == value); - return DropdownMenuItem( - value: fiatCurrencyData.id, - child: Material( - child: SizedBox( - width: 36, - child: AutoSizeText( - fiatCurrencyData.id, - textAlign: TextAlign.left, - style: themeData.dialogTheme.titleTextStyle, - maxLines: 1, - minFontSize: MinFontSize(context).minFontSize, - stepGranularity: 0.1, - group: _autoSizeGroup, - ), - ), - ), - ); - }); - - return SizedBox( - width: MediaQuery.of(context).size.width, - height: 50.0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 2.0), - child: AutoSizeText( - texts.currency_converter_dialog_title, - maxLines: 1, - minFontSize: MinFontSize(context).minFontSize, - stepGranularity: 0.1, - group: _autoSizeGroup, - ), - ), - ), - Theme( - data: themeData.copyWith( - canvasColor: themeData.colorScheme.surface, - ), - child: DropdownButtonHideUnderline( - child: ButtonTheme( - alignedDropdown: true, - child: DropdownButton( - onChanged: (String? value) => _selectFiatCurrency(value.toString()), - value: currencyState.fiatId, - iconEnabledColor: themeData.dialogTheme.titleTextStyle!.color!, - style: themeData.dialogTheme.titleTextStyle!, - items: items.toList(), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _dialogContent(CurrencyState currencyState) { - final ThemeData themeData = Theme.of(context); - final FiatCurrency? fiatCurrency = currencyState.fiatCurrency; - final double? fiatExchangeRate = currencyState.fiatExchangeRate; - if (fiatCurrency == null || fiatExchangeRate == null) { - return const Loader(); - } - - final FiatConversion fiatConversion = FiatConversion(fiatCurrency, fiatExchangeRate); - final Color borderColor = themeData.isLightTheme ? Colors.red : themeData.colorScheme.error; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Form( - key: _formKey, - child: TextFormField( - decoration: InputDecoration( - enabledBorder: UnderlineInputBorder( - borderSide: greyBorderSide, - ), - focusedBorder: UnderlineInputBorder( - borderSide: greyBorderSide, - ), - errorBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: borderColor, - ), - ), - focusedErrorBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: borderColor, - ), - ), - errorMaxLines: 2, - errorStyle: themeData.primaryTextTheme.bodySmall!.copyWith( - color: borderColor, - ), - prefix: Text( - fiatCurrency.info.symbol?.grapheme ?? '', - style: themeData.dialogTheme.contentTextStyle, - ), - ), - inputFormatters: [ - FilteringTextInputFormatter.allow( - fiatConversion.whitelistedPattern, - ), - TextInputFormatter.withFunction( - (_, TextEditingValue newValue) => newValue.copyWith( - text: newValue.text.replaceAll(',', '.'), - ), - ), - ], - keyboardType: const TextInputType.numberWithOptions(decimal: true), - focusNode: _fiatAmountFocusNode, - autofocus: true, - onEditingComplete: () => _fiatAmountFocusNode.unfocus(), - controller: _fiatAmountController, - validator: (_) { - return widget.validatorFn(_convertedSatoshies(currencyState)); - }, - style: themeData.dialogTheme.contentTextStyle, - ), - ), - Padding( - padding: const EdgeInsets.only(top: 24.0), - child: Column( - children: [ - Text( - _contentMessage(currencyState), - style: themeData.textTheme.headlineSmall!.copyWith( - fontSize: 16.0, - ), - ), - _buildExchangeRateLabel(fiatConversion), - ], - ), - ), - ], - ); - } - - List _buildActions(CurrencyState currencyState) { - final ThemeData themeData = Theme.of(context); - final BreezTranslations texts = context.texts(); - - final List actions = [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - texts.currency_converter_dialog_action_cancel, - style: themeData.primaryTextTheme.labelLarge, - ), - ), - ]; - - // Show done button only when the converted amount is bigger than 0 - if (_fiatAmountController.text.isNotEmpty && _convertedSatoshies(currencyState) > 0) { - actions.add( - TextButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - widget._onConvert( - currencyState.bitcoinCurrency.format( - _convertedSatoshies(currencyState), - includeDisplayName: false, - userInput: true, - ), - ); - Navigator.pop(context); - } - }, - child: Text( - texts.currency_converter_dialog_action_done, - style: themeData.primaryTextTheme.labelLarge, - ), - ), - ); - } - return actions; - } - - String _contentMessage(CurrencyState currencyState) { - final Object amount = _fiatAmountController.text.isNotEmpty - ? currencyState.bitcoinCurrency.format( - _convertedSatoshies(currencyState), - includeDisplayName: false, - ) - : 0; - final String symbol = currencyState.bitcoinCurrency.tickerSymbol; - return '$amount $symbol'; - } - - void _updateExchangeLabel(double exchangeRate) { - if (_exchangeRate != exchangeRate) { - // Blink exchange rate label when exchange rate changes (also switches between fiat currencies) - if (!_controller!.isAnimating) { - _controller!.forward(); - } - _exchangeRate = exchangeRate; - } - } - - int _convertedSatoshies(CurrencyState currencyState) { - final FiatConversion fiatConversion = - FiatConversion(currencyState.fiatCurrency!, currencyState.fiatExchangeRate!); - return _fiatAmountController.text.isNotEmpty - ? fiatConversion.fiatToSat( - double.parse(_fiatAmountController.text), - ) - : 0; - } - - Widget _buildExchangeRateLabel( - FiatConversion fiatConversion, - ) { - final ThemeData themeData = Theme.of(context); - final BreezTranslations texts = context.texts(); - - // Empty string widget is returned so that the dialogs height is not changed when the exchange rate is shown - return _exchangeRate == null - ? Text( - '', - style: themeData.primaryTextTheme.titleSmall, - ) - : Text( - texts.currency_converter_dialog_rate( - fiatConversion.formatFiat( - _exchangeRate!, - removeTrailingZeros: true, - ), - fiatConversion.currencyData.id, - ), - style: themeData.primaryTextTheme.titleSmall!, - ); - } - - void _selectFiatCurrency(String fiatId) { - widget._currencyCubit.setFiatId(fiatId); - } -} diff --git a/lib/widgets/amount_form_field/sat_amount_form_field_formatter.dart b/lib/widgets/amount_form_field/input_formatter/sat_amount_form_field_formatter.dart similarity index 100% rename from lib/widgets/amount_form_field/sat_amount_form_field_formatter.dart rename to lib/widgets/amount_form_field/input_formatter/sat_amount_form_field_formatter.dart diff --git a/lib/widgets/amount_form_field/widgets/exchange_rate_label.dart b/lib/widgets/amount_form_field/widgets/exchange_rate_label.dart new file mode 100644 index 00000000..51874dcb --- /dev/null +++ b/lib/widgets/amount_form_field/widgets/exchange_rate_label.dart @@ -0,0 +1,56 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +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/utils/fiat_conversion.dart'; + +class ExchangeRateLabel extends StatelessWidget { + final ValueNotifier exchangeRateNotifier; + final Animation? colorAnimation; + + const ExchangeRateLabel({ + required this.exchangeRateNotifier, + this.colorAnimation, + super.key, + }); + + @override + Widget build(BuildContext context) { + final CurrencyCubit currencyCubit = context.read(); + final CurrencyState currencyState = currencyCubit.state; + + final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); + + final FiatConversion? fiatConversion = currencyState.fiatConversion(); + + return ValueListenableBuilder( + valueListenable: exchangeRateNotifier, + builder: (BuildContext context, double? exchangeRate, Widget? child) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + fiatConversion != null + ? texts.currency_converter_dialog_rate( + fiatConversion.formatFiat( + currencyState.fiatExchangeRate!, + addCurrencySymbol: false, + removeTrailingZeros: true, + ), + fiatConversion.currencyData.id, + ) + : '', + style: themeData.primaryTextTheme.titleSmall!.copyWith( + fontSize: 13.0, + fontWeight: FontWeight.w400, + color: colorAnimation?.value, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/amount_form_field/widgets/fiat_currency_chips.dart b/lib/widgets/amount_form_field/widgets/fiat_currency_chips.dart new file mode 100644 index 00000000..0eeb37d5 --- /dev/null +++ b/lib/widgets/amount_form_field/widgets/fiat_currency_chips.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:l_breez/theme/theme.dart'; + +class FiatCurrencyChips extends StatelessWidget { + final String? selectedCurrency; + final ValueChanged onCurrencySelected; + + const FiatCurrencyChips({ + required this.selectedCurrency, + required this.onCurrencySelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + final CurrencyCubit currencyCubit = context.read(); + final CurrencyState currencyState = currencyCubit.state; + + final ThemeData themeData = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: currencyState.preferredCurrencies.map( + (String currencyId) { + final FiatCurrency fiatCurrency = currencyState.fiatCurrenciesData.firstWhere( + (FiatCurrency c) => c.id == currencyId, + ); + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + label: Text(fiatCurrency.id), + selected: selectedCurrency == fiatCurrency.id, + onSelected: (bool selected) { + if (selected) { + onCurrencySelected(fiatCurrency.id); + } + }, + selectedColor: themeData.chipTheme.backgroundColor, + backgroundColor: + themeData.isLightTheme ? themeData.primaryColorDark : Colors.white.withAlpha(0x1f), + labelStyle: TextStyle( + color: selectedCurrency == fiatCurrency.id + ? Colors.white + : themeData.textTheme.bodyMedium?.color, + ), + ), + ); + }, + ).toList(), + ), + ), + ); + } +} diff --git a/lib/widgets/amount_form_field/widgets/fiat_input_field.dart b/lib/widgets/amount_form_field/widgets/fiat_input_field.dart new file mode 100644 index 00000000..02c35245 --- /dev/null +++ b/lib/widgets/amount_form_field/widgets/fiat_input_field.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:l_breez/theme/theme.dart'; +import 'package:l_breez/utils/fiat_conversion.dart'; + +class FiatInputField extends StatelessWidget { + final GlobalKey formKey; + final TextEditingController controller; + final FocusNode focusNode; + final FiatConversion? fiatConversion; + final String? Function(int amount) validatorFn; + + const FiatInputField({ + required this.formKey, + required this.controller, + required this.focusNode, + required this.fiatConversion, + required this.validatorFn, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (fiatConversion == null) { + return const SizedBox.shrink(); + } + + final ThemeData themeData = Theme.of(context); + final Color errorBorderColor = themeData.isLightTheme ? Colors.red : themeData.colorScheme.error; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: formKey, + child: TextFormField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + labelText: 'Amount in sats', + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: errorBorderColor), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: errorBorderColor), + ), + errorMaxLines: 2, + errorStyle: themeData.primaryTextTheme.bodySmall!.copyWith( + color: errorBorderColor, + ), + prefix: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: Text( + fiatConversion!.currencyData.info.symbol?.grapheme ?? '', + ), + ), + border: const OutlineInputBorder(), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + fiatConversion!.whitelistedPattern, + ), + TextInputFormatter.withFunction( + (_, TextEditingValue newValue) => newValue.copyWith( + text: newValue.text.replaceAll(',', '.'), + ), + ), + ], + keyboardType: const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + onEditingComplete: () => focusNode.unfocus(), + validator: (_) { + final double inputAmount = double.tryParse(controller.text) ?? 0; + final int amountSat = fiatConversion!.fiatToSat(inputAmount); + return validatorFn(amountSat); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/amount_form_field/widgets/sat_equivalent_label.dart b/lib/widgets/amount_form_field/widgets/sat_equivalent_label.dart new file mode 100644 index 00000000..560fab81 --- /dev/null +++ b/lib/widgets/amount_form_field/widgets/sat_equivalent_label.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/cubit/cubit.dart'; + +class SatEquivalentLabel extends StatelessWidget { + final TextEditingController controller; + + const SatEquivalentLabel({ + required this.controller, + super.key, + }); + + @override + Widget build(BuildContext context) { + final CurrencyCubit currencyCubit = context.read(); + final CurrencyState currencyState = currencyCubit.state; + + final ThemeData themeData = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + getSatoshiValue(currencyState), + style: themeData.primaryTextTheme.titleSmall!.copyWith( + fontSize: 18.0, + ), + ), + ), + ); + } + + String getSatoshiValue(CurrencyState currencyState) { + final double inputAmount = double.tryParse(controller.text) ?? 0; + + if (inputAmount == 0 || currencyState.fiatConversion() == null) { + return '0 ${currencyState.bitcoinCurrency.tickerSymbol}'; + } + + final int amountSat = currencyState.fiatConversion()!.fiatToSat(inputAmount); + final String formattedAmount = currencyState.bitcoinCurrency.format( + amountSat, + includeDisplayName: false, + ); + + return '$formattedAmount ${currencyState.bitcoinCurrency.tickerSymbol}'; + } +} diff --git a/lib/widgets/amount_form_field/widgets/widgets.dart b/lib/widgets/amount_form_field/widgets/widgets.dart new file mode 100644 index 00000000..cac75f6b --- /dev/null +++ b/lib/widgets/amount_form_field/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'exchange_rate_label.dart'; +export 'fiat_currency_chips.dart'; +export 'fiat_input_field.dart'; +export 'sat_equivalent_label.dart'; diff --git a/lib/widgets/scrollable_error_message_widget.dart b/lib/widgets/scrollable_error_message_widget.dart index ac8503e1..6771929b 100644 --- a/lib/widgets/scrollable_error_message_widget.dart +++ b/lib/widgets/scrollable_error_message_widget.dart @@ -3,15 +3,23 @@ import 'package:flutter/material.dart'; import 'package:l_breez/theme/theme.dart'; class ScrollableErrorMessageWidget extends StatefulWidget { - final EdgeInsets? padding; + final EdgeInsets padding; + final EdgeInsets contentPadding; final String? title; final String message; + final TextStyle? titleStyle; + final TextStyle? errorTextStyle; + final bool showIcon; const ScrollableErrorMessageWidget({ required this.message, super.key, - this.padding, + this.padding = const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 16.0), + this.contentPadding = EdgeInsets.zero, this.title, + this.titleStyle, + this.errorTextStyle, + this.showIcon = false, }); @override @@ -26,38 +34,58 @@ class _ScrollableErrorMessageWidgetState extends State[ + if (widget.showIcon) + Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 32.0), + child: Icon( + Icons.warning, + size: 100.0, + color: Theme.of(context).iconTheme.color, + ), + ), + ), if (widget.title != null && widget.title!.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: AutoSizeText( widget.title!, - style: themeData.textTheme.labelMedium, + style: widget.titleStyle ?? + themeData.textTheme.labelMedium!.copyWith( + fontSize: 18.0, + ), textAlign: TextAlign.left, maxLines: 1, ), ), - Container( - constraints: const BoxConstraints( - maxHeight: 100, - minWidth: double.infinity, - ), - child: Scrollbar( - controller: _scrollController, - radius: const Radius.circular(16.0), - thumbVisibility: true, - child: SingleChildScrollView( + Padding( + padding: widget.contentPadding, + child: Container( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: double.infinity, + ), + child: Scrollbar( controller: _scrollController, - child: AutoSizeText( - widget.message, - style: themeData.errorTextStyle, - textAlign: widget.message.length > 40 && !widget.message.contains('\n') - ? TextAlign.start - : TextAlign.left, + radius: const Radius.circular(16.0), + thumbVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + child: AutoSizeText( + widget.message, + style: widget.errorTextStyle ?? + themeData.errorTextStyle.copyWith( + fontSize: 16.0, + ), + textAlign: widget.message.length > 40 && !widget.message.contains('\n') + ? TextAlign.start + : TextAlign.left, + ), ), ), ), diff --git a/lib/widgets/single_button_bottom_bar.dart b/lib/widgets/single_button_bottom_bar.dart index 2efa2c71..b6adfb42 100644 --- a/lib/widgets/single_button_bottom_bar.dart +++ b/lib/widgets/single_button_bottom_bar.dart @@ -5,12 +5,16 @@ class SingleButtonBottomBar extends StatelessWidget { final VoidCallback? onPressed; final String text; final bool stickToBottom; + final bool enabled; + final bool expand; const SingleButtonBottomBar({ required this.text, super.key, this.onPressed, this.stickToBottom = false, + this.enabled = true, + this.expand = false, }); @override @@ -20,7 +24,7 @@ class SingleButtonBottomBar extends StatelessWidget { bottom: stickToBottom ? MediaQuery.of(context).viewInsets.bottom + 40.0 : 40.0, ), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ ConstrainedBox( constraints: const BoxConstraints( @@ -30,6 +34,8 @@ class SingleButtonBottomBar extends StatelessWidget { child: SubmitButton( text, onPressed, + enabled: enabled, + expand: expand, ), ), ], @@ -42,17 +48,21 @@ class SubmitButton extends StatelessWidget { final VoidCallback? onPressed; final String text; final bool enabled; + final bool expand; const SubmitButton( this.text, this.onPressed, { super.key, this.enabled = true, + this.expand = false, }); @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); + final double screenWidth = MediaQuery.of(context).size.width; + return ConstrainedBox( constraints: const BoxConstraints( minHeight: 48.0, @@ -60,13 +70,15 @@ class SubmitButton extends StatelessWidget { ), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: enabled ? themeData.primaryColor : themeData.disabledColor, + backgroundColor: themeData.primaryColor, elevation: 0.0, + disabledBackgroundColor: themeData.disabledColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), + minimumSize: expand ? Size(screenWidth, 48) : null, ), - onPressed: onPressed, + onPressed: enabled ? onPressed : null, child: AutoSizeText( text, maxLines: 1, diff --git a/lib/widgets/warning_box.dart b/lib/widgets/warning_box.dart index ac10c815..5a48ac61 100644 --- a/lib/widgets/warning_box.dart +++ b/lib/widgets/warning_box.dart @@ -1,3 +1,4 @@ +import 'package:dotted_decoration/dotted_decoration.dart'; import 'package:flutter/material.dart'; import 'package:l_breez/theme/theme.dart'; @@ -28,19 +29,22 @@ class WarningBox extends StatelessWidget { return Padding( padding: boxPadding, child: Container( - padding: contentPadding, - width: MediaQuery.of(context).size.width, decoration: BoxDecoration( - color: _backgroundColor(), - borderRadius: const BorderRadius.all(Radius.circular(6)), - border: Border.all( + color: backgroundColor ?? warningBoxColor, + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + ), + child: Container( + padding: contentPadding, + width: MediaQuery.of(context).size.width, + decoration: DottedDecoration( + shape: Shape.box, + dash: const [3, 2], color: borderColor ?? Theme.of(context).warningBoxBorderColor, + borderRadius: const BorderRadius.all(Radius.circular(4.0)), ), + child: child, ), - child: child, ), ); } - - Color _backgroundColor() => backgroundColor ?? warningBoxColor; } diff --git a/pubspec.lock b/pubspec.lock index 94ca5783..44e113ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -403,6 +403,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + dotted_box: + dependency: "direct main" + description: + name: dotted_box + sha256: "18ef5239b810009a5fa3562fc337fb7f25e57d24982f780f3debed4319d1197c" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + dotted_decoration: + dependency: "direct main" + description: + name: dotted_decoration + sha256: a5c5771367690b4f64ebfa7911954ab472b9675f025c373f514e32ac4bb81d5e + url: "https://pub.dev" + source: hosted + version: "2.0.0" drag_and_drop_lists: dependency: "direct main" description: @@ -480,10 +496,10 @@ packages: dependency: transitive description: name: file_selector_linux - sha256: b2b91daf8a68ecfa4a01b778a6f52edef9b14ecd506e771488ea0f2e0784198b + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+2" file_selector_macos: dependency: transitive description: @@ -644,10 +660,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" flutter_inappwebview_ios: dependency: transitive description: @@ -809,10 +825,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "578bd8c508144fdaffd4f77b8ef2d8c523602275cd697cc3db284dbd762ef4ce" + sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.16" flutter_test: dependency: "direct dev" description: flutter @@ -1576,10 +1592,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" shimmer: dependency: "direct main" description: @@ -1869,10 +1885,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "773c9522d66d523e1c7b25dfb95cc91c26a1e17b107039cfe147285e92de7878" + sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" url: "https://pub.dev" source: hosted - version: "1.1.14" + version: "1.1.15" vector_graphics_codec: dependency: transitive description: @@ -1885,10 +1901,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: ab9ff38fc771e9ee1139320adbe3d18a60327370c218c60752068ebee4b49ab1 + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" url: "https://pub.dev" source: hosted - version: "1.1.15" + version: "1.1.16" vector_math: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index dbbdf3d3..8138befc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: url: https://github.com/breez/DragAndDropLists ref: 38cc3155a6161730b4ec476d781873afb7ec4846 duration: ^4.0.3 + dotted_box: ^0.0.3 + dotted_decoration: ^2.0.0 email_validator: ^3.0.0 extended_image: ^9.0.7 ffi: ^2.1.3