diff --git a/lib/routes/chainswap/send/fee/fee_breakdown/fee_breakdown.dart b/lib/routes/chainswap/send/fee/fee_breakdown/fee_breakdown.dart new file mode 100644 index 00000000..5c020969 --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_breakdown/fee_breakdown.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/recipient_amount.dart'; +import 'package:l_breez/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/sender_amount.dart'; +import 'package:l_breez/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/transaction_fee.dart'; + +class FeeBreakdown extends StatelessWidget { + final PreparePayOnchainResponse feeOption; + + const FeeBreakdown({required this.feeOption, super.key}); + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all( + color: themeData.colorScheme.onSurface.withOpacity(0.4), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SenderAmount(amountSat: (feeOption.receiverAmountSat + feeOption.feesSat).toInt()), + TransactionFee(txFeeSat: feeOption.feesSat.toInt()), + RecipientAmount(amountSat: feeOption.receiverAmountSat.toInt()) + ], + ), + ); + } +} diff --git a/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/boltz_service_fee.dart b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/boltz_service_fee.dart new file mode 100644 index 00000000..686150d2 --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/boltz_service_fee.dart @@ -0,0 +1,37 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:l_breez/models/currency.dart'; +import 'package:l_breez/utils/min_font_size.dart'; + +class BoltzServiceFee extends StatelessWidget { + final int boltzServiceFee; + + const BoltzServiceFee({required this.boltzServiceFee, super.key}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + final minFont = MinFontSize(context); + + return ListTile( + title: AutoSizeText( + texts.reverse_swap_confirmation_boltz_fee, + style: TextStyle(color: Colors.white.withOpacity(0.4)), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + trailing: AutoSizeText( + texts.reverse_swap_confirmation_boltz_fee_value( + BitcoinCurrency.SAT.format(boltzServiceFee), + ), + style: TextStyle(color: themeData.colorScheme.error.withOpacity(0.4)), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + ); + } +} diff --git a/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/recipient_amount.dart b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/recipient_amount.dart new file mode 100644 index 00000000..2568ff15 --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/recipient_amount.dart @@ -0,0 +1,49 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_state.dart'; +import 'package:l_breez/models/currency.dart'; +import 'package:l_breez/utils/min_font_size.dart'; + +class RecipientAmount extends StatelessWidget { + final int amountSat; + + const RecipientAmount({required this.amountSat, super.key}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + final minFont = MinFontSize(context); + + return ListTile( + title: AutoSizeText( + texts.sweep_all_coins_label_receive, + style: const TextStyle(color: Colors.white), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + trailing: BlocBuilder(builder: (context, currency) { + final fiatConversion = currency.fiatConversion(); + + return AutoSizeText( + fiatConversion == null + ? texts.sweep_all_coins_amount_no_fiat( + BitcoinCurrency.SAT.format(amountSat), + ) + : texts.sweep_all_coins_amount_with_fiat( + BitcoinCurrency.SAT.format(amountSat), + fiatConversion.format(amountSat), + ), + style: TextStyle(color: themeData.colorScheme.error), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ); + }), + ); + } +} diff --git a/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/sender_amount.dart b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/sender_amount.dart new file mode 100644 index 00000000..b9d17ca8 --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/sender_amount.dart @@ -0,0 +1,50 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_state.dart'; +import 'package:l_breez/models/currency.dart'; +import 'package:l_breez/utils/min_font_size.dart'; + +class SenderAmount extends StatelessWidget { + final int amountSat; + + const SenderAmount({required this.amountSat, super.key}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + final minFont = MinFontSize(context); + + return ListTile( + title: AutoSizeText( + texts.sweep_all_coins_label_send, + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + trailing: BlocBuilder( + builder: (context, currency) { + final fiatConversion = currency.fiatConversion(); + + return AutoSizeText( + fiatConversion == null + ? texts.sweep_all_coins_amount_no_fiat( + BitcoinCurrency.SAT.format(amountSat), + ) + : texts.sweep_all_coins_amount_with_fiat( + BitcoinCurrency.SAT.format(amountSat), + fiatConversion.format(amountSat), + ), + style: TextStyle(color: themeData.colorScheme.error), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ); + }, + ), + ); + } +} diff --git a/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/transaction_fee.dart b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/transaction_fee.dart new file mode 100644 index 00000000..71afac2f --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_breakdown/widgets/fee_breakdown_widgets/transaction_fee.dart @@ -0,0 +1,37 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:l_breez/models/currency.dart'; +import 'package:l_breez/utils/min_font_size.dart'; + +class TransactionFee extends StatelessWidget { + final int txFeeSat; + + const TransactionFee({required this.txFeeSat, super.key}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + final minFont = MinFontSize(context); + + return ListTile( + title: AutoSizeText( + texts.sweep_all_coins_label_transaction_fee, + style: TextStyle(color: Colors.white.withOpacity(0.4)), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + trailing: AutoSizeText( + texts.sweep_all_coins_fee( + BitcoinCurrency.SAT.format(txFeeSat), + ), + style: TextStyle(color: themeData.colorScheme.error.withOpacity(0.4)), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + ); + } +} diff --git a/lib/routes/chainswap/send/fee/fee_option.dart b/lib/routes/chainswap/send/fee/fee_option.dart new file mode 100644 index 00000000..3d24f38f --- /dev/null +++ b/lib/routes/chainswap/send/fee/fee_option.dart @@ -0,0 +1,7 @@ +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; + +extension PreparePayOnchainResponseAffordable on PreparePayOnchainResponse { + bool isAffordable({required int balance}) { + return balance >= (receiverAmountSat + feesSat).toInt(); + } +} diff --git a/lib/routes/chainswap/send/send_chainswap_button.dart b/lib/routes/chainswap/send/send_chainswap_button.dart new file mode 100644 index 00000000..8f765851 --- /dev/null +++ b/lib/routes/chainswap/send/send_chainswap_button.dart @@ -0,0 +1,56 @@ +import 'package:breez_translations/breez_translations_locales.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/bloc/chainswap/chainswap_bloc.dart'; +import 'package:l_breez/utils/exceptions.dart'; +import 'package:l_breez/widgets/error_dialog.dart'; +import 'package:l_breez/widgets/loader.dart'; +import 'package:l_breez/widgets/single_button_bottom_bar.dart'; + +class SendChainSwapButton extends StatelessWidget { + final String recipientAddress; + final PreparePayOnchainResponse preparePayOnchainResponse; + + const SendChainSwapButton({ + super.key, + required this.recipientAddress, + required this.preparePayOnchainResponse, + }); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + + return SingleButtonBottomBar( + text: texts.sweep_all_coins_action_confirm, + onPressed: () => _payOnchain(context), + ); + } + + Future _payOnchain(BuildContext context) async { + final texts = context.texts(); + final themeData = Theme.of(context); + final chainSwapBloc = context.read(); + + final navigator = Navigator.of(context); + var loaderRoute = createLoaderRoute(context); + navigator.push(loaderRoute); + try { + final req = PayOnchainRequest(address: recipientAddress, prepareRes: preparePayOnchainResponse); + await chainSwapBloc.payOnchain(req: req); + navigator.pushNamedAndRemoveUntil("/", (Route route) => false); + } catch (e) { + navigator.pop(loaderRoute); + if (!context.mounted) return; + promptError( + context, + null, + Text( + extractExceptionMessage(e, texts), + style: themeData.dialogTheme.contentTextStyle, + ), + ); + } + } +} diff --git a/lib/routes/chainswap/send/send_chainswap_confirmation_page.dart b/lib/routes/chainswap/send/send_chainswap_confirmation_page.dart new file mode 100644 index 00000000..5305cf96 --- /dev/null +++ b/lib/routes/chainswap/send/send_chainswap_confirmation_page.dart @@ -0,0 +1,125 @@ +import 'package:breez_liquid/breez_liquid.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/bloc/account/account_bloc.dart'; +import 'package:l_breez/bloc/chainswap/chainswap_bloc.dart'; +import 'package:l_breez/routes/chainswap/send/fee/fee_breakdown/fee_breakdown.dart'; +import 'package:l_breez/routes/chainswap/send/fee/fee_option.dart'; +import 'package:l_breez/routes/chainswap/send/send_chainswap_button.dart'; +import 'package:l_breez/widgets/loader.dart'; + +class SendChainSwapConfirmationPage extends StatefulWidget { + final int amountSat; + final String onchainRecipientAddress; + final bool isMaxValue; + + const SendChainSwapConfirmationPage({ + super.key, + required this.amountSat, + required this.onchainRecipientAddress, + required this.isMaxValue, + }); + + @override + State createState() => _SendChainSwapConfirmationPageState(); +} + +class _SendChainSwapConfirmationPageState extends State { + bool isAffordable = false; + PreparePayOnchainResponse? feeOption; + + late Future _preparePayOnchainResponseFuture; + + @override + void initState() { + super.initState(); + _preparePayOnchainResponse(); + } + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + + return Scaffold( + appBar: AppBar( + title: Text(texts.csv_exporter_fee), + ), + body: FutureBuilder( + future: _preparePayOnchainResponseFuture, + builder: (context, snapshot) { + if (snapshot.error != null) { + return _ErrorMessage( + message: (snapshot.error is PaymentError_InsufficientFunds) + ? texts.reverse_swap_confirmation_error_funds_fee + : texts.reverse_swap_confirmation_error_fetch_fee, + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: Loader()); + } + + if (isAffordable) { + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 24.0, 16.0, 40.0), + child: FeeBreakdown(feeOption: snapshot.data!), + ); + } else { + return _ErrorMessage(message: texts.reverse_swap_confirmation_error_funds_fee); + } + }, + ), + bottomNavigationBar: (isAffordable) + ? SafeArea( + child: SendChainSwapButton( + recipientAddress: widget.onchainRecipientAddress, + preparePayOnchainResponse: feeOption!, + ), + ) + : null, + ); + } + + void _preparePayOnchainResponse() { + final chainSwapBloc = context.read(); + final preparePayOnchainRequest = PreparePayOnchainRequest( + receiverAmountSat: BigInt.from(widget.amountSat), + ); + _preparePayOnchainResponseFuture = chainSwapBloc.preparePayOnchain( + req: preparePayOnchainRequest, + ); + _preparePayOnchainResponseFuture.then((feeOption) { + final account = context.read().state; + setState(() { + this.feeOption = feeOption; + isAffordable = feeOption.isAffordable(balance: account.balance); + }); + }, onError: (error, stackTrace) { + setState(() { + isAffordable = false; + feeOption = null; + }); + }); + } +} + +class _ErrorMessage extends StatelessWidget { + final String message; + + const _ErrorMessage({ + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0), + child: Text( + message, + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/lib/routes/chainswap/send/send_chainswap_form.dart b/lib/routes/chainswap/send/send_chainswap_form.dart new file mode 100644 index 00000000..3ee00870 --- /dev/null +++ b/lib/routes/chainswap/send/send_chainswap_form.dart @@ -0,0 +1,121 @@ +import 'package:breez_translations/breez_translations_locales.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/chainswap/send/validator_holder.dart'; +import 'package:l_breez/routes/chainswap/send/widgets/bitcoin_address_text_form_field.dart'; +import 'package:l_breez/routes/chainswap/send/widgets/withdraw_funds_amount_text_form_field.dart'; +import 'package:l_breez/routes/chainswap/send/withdraw_funds_model.dart'; +import 'package:l_breez/widgets/amount_form_field/sat_amount_form_field_formatter.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("SendChainSwapForm"); + +class SendChainSwapForm extends StatefulWidget { + final GlobalKey formKey; + final TextEditingController amountController; + final TextEditingController addressController; + final bool withdrawMaxValue; + final ValueChanged onChanged; + final BitcoinAddressData? btcAddressData; + final BitcoinCurrency bitcoinCurrency; + final OnchainPaymentLimitsResponse paymentLimits; + + const SendChainSwapForm({ + super.key, + required this.formKey, + required this.amountController, + required this.addressController, + required this.onChanged, + required this.withdrawMaxValue, + this.btcAddressData, + required this.bitcoinCurrency, + required this.paymentLimits, + }); + + @override + State createState() => _SendChainSwapFormState(); +} + +class _SendChainSwapFormState extends State { + final _validatorHolder = ValidatorHolder(); + + @override + void initState() { + super.initState(); + if (widget.btcAddressData != null) { + _fillBtcAddressData(widget.btcAddressData!); + } + } + + void _fillBtcAddressData(BitcoinAddressData addressData) { + _log.info("Filling BTC Address data for ${addressData.address}"); + widget.addressController.text = addressData.address; + if (addressData.amountSat != null) { + _setAmount(addressData.amountSat!.toInt()); + } + } + + void _setAmount(int amountSats) { + setState(() { + widget.amountController.text = widget.bitcoinCurrency + .format(amountSats, includeDisplayName: false, userInput: true) + .formatBySatAmountFormFieldFormatter(); + }); + } + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Form( + key: widget.formKey, + child: Column( + children: [ + BitcoinAddressTextFormField( + context: context, + controller: widget.addressController, + validatorHolder: _validatorHolder, + ), + WithdrawFundsAmountTextFormField( + context: context, + bitcoinCurrency: widget.bitcoinCurrency, + controller: widget.amountController, + withdrawMaxValue: widget.withdrawMaxValue, + balance: widget.paymentLimits.send.maxSat, + policy: WithdrawFundsPolicy( + WithdrawKind.withdraw_funds, + 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 (widget.withdrawMaxValue) { + _setAmount(widget.paymentLimits.send.maxSat.toInt()); + } else { + widget.amountController.text = ""; + } + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/chainswap/send/send_chainswap_form_page.dart b/lib/routes/chainswap/send/send_chainswap_form_page.dart new file mode 100644 index 00000000..8d0f4fff --- /dev/null +++ b/lib/routes/chainswap/send/send_chainswap_form_page.dart @@ -0,0 +1,123 @@ +import 'package:breez_translations/breez_translations_locales.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/chainswap/send/send_chainswap_confirmation_page.dart'; +import 'package:l_breez/routes/chainswap/send/send_chainswap_form.dart'; +import 'package:l_breez/routes/chainswap/send/widgets/chainswap_available_btc.dart'; +import 'package:l_breez/utils/exceptions.dart'; +import 'package:l_breez/widgets/flushbar.dart'; +import 'package:l_breez/widgets/loader.dart'; +import 'package:l_breez/widgets/route.dart'; +import 'package:l_breez/widgets/single_button_bottom_bar.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("SendChainSwapFormPage"); + +class SendChainSwapFormPage extends StatefulWidget { + final BitcoinAddressData? btcAddressData; + final BitcoinCurrency bitcoinCurrency; + final OnchainPaymentLimitsResponse paymentLimits; + + const SendChainSwapFormPage({ + super.key, + this.btcAddressData, + required this.bitcoinCurrency, + required this.paymentLimits, + }); + + @override + State createState() => _SendChainSwapFormPageState(); +} + +class _SendChainSwapFormPageState extends State { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _addressController = TextEditingController(); + bool _withdrawMaxValue = false; + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + 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 WithdrawFundsAvailableBtc(), + Expanded(child: Container()), + SingleButtonBottomBar( + text: texts.withdraw_funds_action_next, + onPressed: _prepareSendChainSwap, + ), + ], + ), + ), + ); + } + + int _getAmount() { + int amount = 0; + try { + amount = widget.bitcoinCurrency.parse(_amountController.text); + } catch (e) { + _log.warning("Failed to parse the input amount", e); + } + return amount; + } + + void _prepareSendChainSwap() async { + final texts = context.texts(); + final navigator = Navigator.of(context); + if (_formKey.currentState?.validate() ?? false) { + var loaderRoute = createLoaderRoute(context); + navigator.push(loaderRoute); + try { + 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); + } + _log.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/chainswap/send/send_chainswap_page.dart b/lib/routes/chainswap/send/send_chainswap_page.dart new file mode 100644 index 00000000..3eab762a --- /dev/null +++ b/lib/routes/chainswap/send/send_chainswap_page.dart @@ -0,0 +1,87 @@ +import 'package:breez_translations/breez_translations_locales.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/bloc/chainswap/chainswap_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/routes/chainswap/send/send_chainswap_form_page.dart'; +import 'package:l_breez/utils/exceptions.dart'; +import 'package:l_breez/widgets/back_button.dart' as back_button; +import 'package:l_breez/widgets/loader.dart'; + +class SendChainSwapPage extends StatefulWidget { + final BitcoinAddressData? btcAddressData; + const SendChainSwapPage({super.key, required this.btcAddressData}); + + @override + State createState() => _SendChainSwapPageState(); +} + +class _SendChainSwapPageState extends State { + final _scaffoldKey = GlobalKey(); + + Future? _onchainPaymentLimitsFuture; + @override + void initState() { + super.initState(); + _fetchOnchainLimits(); + } + + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _fetchOnchainLimits(); + } + } + + Future _fetchOnchainLimits() async { + final chainSwapBloc = context.read(); + setState(() { + _onchainPaymentLimitsFuture = chainSwapBloc.fetchOnchainLimits(); + }); + } + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + leading: const back_button.BackButton(), + title: Text(texts.bottom_action_bar_send_btc_address), + ), + body: FutureBuilder( + future: _onchainPaymentLimitsFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 0, 32, 0), + child: Text( + texts.reverse_swap_upstream_generic_error_message( + extractExceptionMessage(snapshot.error!, texts), + ), + textAlign: TextAlign.center, + ), + ), + ); + } + if (snapshot.connectionState != ConnectionState.done && !snapshot.hasData) { + return Center( + child: Loader( + color: themeData.primaryColor.withOpacity(0.5), + ), + ); + } + + return SendChainSwapFormPage( + bitcoinCurrency: context.read().state.bitcoinCurrency, + btcAddressData: widget.btcAddressData, + paymentLimits: snapshot.data!, + ); + }, + ), + ); + } +} diff --git a/lib/routes/chainswap/send/validator_holder.dart b/lib/routes/chainswap/send/validator_holder.dart new file mode 100644 index 00000000..5258cc6d --- /dev/null +++ b/lib/routes/chainswap/send/validator_holder.dart @@ -0,0 +1,11 @@ +import 'package:synchronized/synchronized.dart'; + +class ValidatorHolder { + final lock = Lock(); + var valid = false; + + @override + String toString() { + return 'ValidatorHolder{valid: $valid, inLock: ${lock.inLock}, locked ${lock.locked}, hash: $hashCode}'; + } +} diff --git a/lib/routes/chainswap/send/widgets/bitcoin_address_text_form_field.dart b/lib/routes/chainswap/send/widgets/bitcoin_address_text_form_field.dart new file mode 100644 index 00000000..b173c33d --- /dev/null +++ b/lib/routes/chainswap/send/widgets/bitcoin_address_text_form_field.dart @@ -0,0 +1,85 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/models/bitcoin_address_info.dart'; +import 'package:l_breez/routes/chainswap/send/validator_holder.dart'; +import 'package:l_breez/theme/theme_provider.dart' as theme; +import 'package:l_breez/widgets/flushbar.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("BitcoinAddressTextFormField"); + +class BitcoinAddressTextFormField extends TextFormField { + BitcoinAddressTextFormField({ + super.key, + required BuildContext context, + required TextEditingController super.controller, + required ValidatorHolder validatorHolder, + }) : super( + decoration: InputDecoration( + labelText: context.texts().withdraw_funds_btc_address, + suffixIcon: IconButton( + alignment: Alignment.bottomRight, + icon: Image( + image: const AssetImage("src/icon/qr_scan.png"), + color: theme.BreezColors.white[500], + fit: BoxFit.contain, + width: 24.0, + height: 24.0, + ), + tooltip: context.texts().withdraw_funds_scan_barcode, + onPressed: () async { + Navigator.pushNamed(context, "/qr_scan").then( + (barcode) { + _log.info("Scanned string: '$barcode'"); + final address = BitcoinAddressInfo.fromScannedString(barcode).address; + _log.info("BitcoinAddressInfoFromScannedString: '$address'"); + if (address == null) return; + if (address.isEmpty) { + showFlushbar( + context, + message: context.texts().withdraw_funds_error_qr_code_not_detected, + ); + return; + } + controller.text = address; + _onAddressChanged(context, validatorHolder, address); + }, + ); + }, + ), + ), + style: theme.FieldTextStyle.textStyle, + onChanged: (address) => _onAddressChanged(context, validatorHolder, address), + validator: (address) { + _log.info("validator called for $address, $validatorHolder"); + if (validatorHolder.valid) { + return null; + } else { + return context.texts().withdraw_funds_error_invalid_address; + } + }, + ); + + static void _onAddressChanged( + BuildContext context, + ValidatorHolder holder, + String address, + ) async { + _log.info("Address changed $address"); + await holder.lock.synchronized(() async { + _log.info("Calling validator for $address"); + holder.valid = await isValidBitcoinAddress(address); + _log.info("Address $address validation result $holder"); + }); + } + + static Future isValidBitcoinAddress(String address) async { + try { + final inputType = await parse(input: address); + return inputType is InputType_BitcoinAddress; + } catch (e) { + return false; + } + } +} diff --git a/lib/routes/chainswap/send/widgets/chainswap_available_btc.dart b/lib/routes/chainswap/send/widgets/chainswap_available_btc.dart new file mode 100644 index 00000000..862461d1 --- /dev/null +++ b/lib/routes/chainswap/send/widgets/chainswap_available_btc.dart @@ -0,0 +1,42 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/bloc/account/account_bloc.dart'; +import 'package:l_breez/bloc/account/account_state.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_state.dart'; +import 'package:l_breez/theme/theme_provider.dart' as theme; + +class WithdrawFundsAvailableBtc extends StatelessWidget { + const WithdrawFundsAvailableBtc({super.key}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + + return Padding( + padding: const EdgeInsets.only(top: 36.0), + child: BlocBuilder(builder: (context, account) { + return Row( + children: [ + Text( + texts.withdraw_funds_balance, + style: theme.textStyle, + ), + Padding( + padding: const EdgeInsets.only(left: 3.0), + child: BlocBuilder( + builder: (context, currencyState) { + return Text( + currencyState.bitcoinCurrency.format(account.balance), + style: theme.textStyle, + ); + }, + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/routes/chainswap/send/widgets/withdraw_funds_amount_text_form_field.dart b/lib/routes/chainswap/send/widgets/withdraw_funds_amount_text_form_field.dart new file mode 100644 index 00000000..d7b6bb56 --- /dev/null +++ b/lib/routes/chainswap/send/widgets/withdraw_funds_amount_text_form_field.dart @@ -0,0 +1,43 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:l_breez/bloc/account/payment_error.dart'; +import 'package:l_breez/routes/chainswap/send/withdraw_funds_model.dart'; +import 'package:l_breez/utils/payment_validator.dart'; +import 'package:l_breez/widgets/amount_form_field/amount_form_field.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("WithdrawFundsAmountTextFormField"); + +class WithdrawFundsAmountTextFormField extends AmountFormField { + WithdrawFundsAmountTextFormField({ + super.key, + required super.bitcoinCurrency, + required super.context, + required TextEditingController super.controller, + required bool withdrawMaxValue, + required WithdrawFundsPolicy policy, + required BigInt balance, + }) : super( + texts: context.texts(), + readOnly: policy.withdrawKind == WithdrawKind.unexpected_funds || withdrawMaxValue, + validatorFn: (amount) { + _log.info("Validator called for $amount"); + return PaymentValidator( + currency: bitcoinCurrency, + texts: context.texts(), + validatePayment: (amount, outgoing) { + _log.info("Validating $amount $policy"); + if (amount < policy.minValue.toInt()) { + throw PaymentBelowLimitError(policy.minValue); + } + if (amount > policy.maxValue.toInt()) { + throw PaymentExceededLimitError(policy.maxValue); + } + if (amount > balance.toInt()) { + throw const InsufficientLocalBalanceError(); + } + }, + ).validateOutgoing(amount); + }, + ); +} diff --git a/lib/routes/chainswap/send/withdraw_funds_model.dart b/lib/routes/chainswap/send/withdraw_funds_model.dart new file mode 100644 index 00000000..2f9573a1 --- /dev/null +++ b/lib/routes/chainswap/send/withdraw_funds_model.dart @@ -0,0 +1,21 @@ +enum WithdrawKind { + withdraw_funds, + unexpected_funds, +} + +class WithdrawFundsPolicy { + final WithdrawKind withdrawKind; + final BigInt minValue; + final BigInt maxValue; + + const WithdrawFundsPolicy( + this.withdrawKind, + this.minValue, + this.maxValue, + ); + + @override + String toString() { + return 'WithdrawFundsPolicy{withdrawKind: $withdrawKind, minValue: $minValue, maxValue: $maxValue}'; + } +} diff --git a/lib/routes/home/widgets/bottom_actions_bar/send_options_bottom_sheet.dart b/lib/routes/home/widgets/bottom_actions_bar/send_options_bottom_sheet.dart index 5e61fac3..ba9c0554 100644 --- a/lib/routes/home/widgets/bottom_actions_bar/send_options_bottom_sheet.dart +++ b/lib/routes/home/widgets/bottom_actions_bar/send_options_bottom_sheet.dart @@ -35,6 +35,21 @@ class _SendOptionsBottomSheetState extends State { ), onTap: () => _showEnterPaymentInfoDialog(context, widget.firstPaymentItemKey), ), + const SizedBox(height: 8.0), + ListTile( + leading: const BottomActionItemImage( + iconAssetPath: "src/icon/bitcoin.png", + ), + title: Text( + texts.bottom_action_bar_send_btc_address, + style: theme.bottomSheetTextStyle, + ), + onTap: () { + final navigatorState = Navigator.of(context); + navigatorState.pop(); + navigatorState.pushNamed("/send_chainswap"); + }, + ), ], ); } diff --git a/lib/user_app.dart b/lib/user_app.dart index deb57e4e..3cb2ec90 100644 --- a/lib/user_app.dart +++ b/lib/user_app.dart @@ -2,6 +2,7 @@ import 'package:breez_translations/breez_translations_locales.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/bloc/account/account_bloc.dart'; import 'package:l_breez/bloc/account/account_state.dart'; import 'package:l_breez/bloc/ext/block_builder_extensions.dart'; @@ -10,6 +11,7 @@ import 'package:l_breez/bloc/security/security_state.dart'; import 'package:l_breez/bloc/user_profile/user_profile_bloc.dart'; import 'package:l_breez/bloc/user_profile/user_profile_state.dart'; import 'package:l_breez/routes/chainswap/receive/receive_chainswap_page.dart'; +import 'package:l_breez/routes/chainswap/send/send_chainswap_page.dart'; import 'package:l_breez/routes/create_invoice/create_invoice_page.dart'; import 'package:l_breez/routes/dev/developers_view.dart'; import 'package:l_breez/routes/fiat_currencies/fiat_currency_settings.dart'; @@ -145,6 +147,13 @@ class UserApp extends StatelessWidget { builder: (_) => const ReceiveChainSwapPage(), settings: settings, ); + case '/send_chainswap': + return FadeInRoute( + builder: (_) => SendChainSwapPage( + btcAddressData: settings.arguments as BitcoinAddressData?, + ), + settings: settings, + ); case '/fiat_currency': return FadeInRoute( builder: (_) => const FiatCurrencySettings(),