diff --git a/lib/routes/satscard_balance/broadcast_slot_sweep_transaction.dart b/lib/routes/satscard_balance/broadcast_slot_sweep_transaction.dart new file mode 100644 index 000000000..000bd1036 --- /dev/null +++ b/lib/routes/satscard_balance/broadcast_slot_sweep_transaction.dart @@ -0,0 +1,156 @@ +import 'dart:typed_data'; + +import 'package:breez/bloc/account/account_actions.dart'; +import 'package:breez/bloc/account/account_bloc.dart'; +import 'package:breez/bloc/blocs_provider.dart'; +import 'package:breez/bloc/satscard/satscard_actions.dart'; +import 'package:breez/bloc/satscard/satscard_bloc.dart'; +import 'package:breez/routes/satscard_balance/satscard_balance_page.dart'; +import 'package:breez/services/breezlib/data/messages.pb.dart'; +import 'package:breez/services/injector.dart'; +import 'package:breez/widgets/back_button.dart' as backBtn; +import 'package:breez/widgets/error_dialog.dart'; +import 'package:breez/widgets/flushbar.dart'; +import 'package:breez/widgets/link_launcher.dart'; +import 'package:breez/widgets/single_button_bottom_bar.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:flutter/material.dart'; + +class BroadcastSlotSweepTransactionPage extends StatefulWidget { + final Function() onBack; + final Function() onDone; + final AddressInfo Function() getAddressInfo; + final Uint8List Function() getPrivateKey; + final RawSlotSweepTransaction Function() getTransaction; + + const BroadcastSlotSweepTransactionPage( + {@required this.onBack, + @required this.onDone, + @required this.getAddressInfo, + @required this.getPrivateKey, + @required this.getTransaction}); + + @override + State createState() => + BroadcastSlotSweepTransactionPageState(); +} + +class BroadcastSlotSweepTransactionPageState + extends State { + TransactionDetails _signedTransaction; + Future _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _broadcastTransaction(context); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + final themeData = Theme.of(context); + final texts = context.texts(); + final showError = snapshot.hasError; + return Scaffold( + appBar: AppBar( + title: Text( + texts.satscard_broadcast_title, + style: themeData.appBarTheme.titleTextStyle, + ), + leading: backBtn.BackButton( + onPressed: widget.onBack, + ), + ), + bottomNavigationBar: + !snapshot.hasError || _signedTransaction == null + ? null + : Padding( + padding: const EdgeInsets.only(top: 10), + child: SingleButtonBottomBar( + stickToBottom: true, + text: texts.satscard_balance_button_retry_label, + onPressed: () => _broadcastTransaction(context), + ), + ), + body: showError + ? buildErrorBody( + themeData, _getErrorText(texts, snapshot.error)) + : buildLoaderBody(themeData, _getLoaderText(texts)), + ); + }); + } + + String _getErrorText(BreezTranslations texts, Object error) { + return _signedTransaction == null + ? texts.satscard_broadcast_error_signing(error) + : texts.satscard_broadcast_error_broadcasting(error); + } + + String _getLoaderText(BreezTranslations texts) { + return _signedTransaction == null + ? texts.satscard_broadcast_signing_label + : texts.satscard_broadcast_broadcasting_label; + } + + void _broadcastTransaction(BuildContext context) { + if (!context.mounted) { + return; + } + setState(() { + _future = Future.sync(() { + // Sign the selected transaction if we haven't already + if (_signedTransaction == null) { + final satscardBloc = AppBlocsProvider.of(context); + final info = widget.getAddressInfo(); + final tx = widget.getTransaction(); + final key = widget.getPrivateKey(); + final action = SignSlotSweepTransaction(info, tx, key); + satscardBloc.actionsSink.add(action); + return action.future.then((result) { + if (context.mounted) { + setState(() { + _signedTransaction = result as TransactionDetails; + }); + } + }); + } + }).then((_) { + final accountBloc = AppBlocsProvider.of(context); + final action = PublishTransaction(_signedTransaction.tx); + accountBloc.userActionsSink.add(action); + return action.future; + }).then((_) { + if (context.mounted) { + final texts = context.texts(); + final tx = _signedTransaction; + + widget.onDone(); + promptMessage( + context, + texts.satscard_broadcast_complete_title, + Builder( + builder: (context) => LinkLauncher( + linkName: tx.txHash, + linkAddress: "https://blockstream.info/tx/${tx.txHash}", + onCopy: () { + ServiceInjector().device.setClipboardText(tx.txHash); + showFlushbar( + context, + message: texts.add_funds_transaction_id_copied, + duration: const Duration(seconds: 3), + ); + }, + ), + ), + contentPadding: + const EdgeInsets.symmetric(vertical: 12.0, horizontal: 32.0), + ); + } + }); + }); + } +} diff --git a/lib/routes/satscard_balance/satscard_balance_page.dart b/lib/routes/satscard_balance/satscard_balance_page.dart new file mode 100644 index 000000000..5d61c1bfd --- /dev/null +++ b/lib/routes/satscard_balance/satscard_balance_page.dart @@ -0,0 +1,200 @@ +import 'dart:typed_data'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez/bloc/account/account_model.dart'; +import 'package:breez/routes/satscard_balance/broadcast_slot_sweep_transaction.dart'; +import 'package:breez/routes/satscard_balance/slot_balance_page.dart'; +import 'package:breez/routes/satscard_balance/sweep_slot_page.dart'; +import 'package:breez/services/breezlib/data/messages.pb.dart'; +import 'package:breez/services/injector.dart'; +import 'package:breez/theme_data.dart' as theme; +import 'package:breez/utils/min_font_size.dart'; +import 'package:breez/widgets/circular_progress.dart'; +import 'package:breez/widgets/flushbar.dart'; +import 'package:breez/widgets/warning_box.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:cktap_protocol/cktapcard.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; + +class SatscardBalancePage extends StatefulWidget { + final Satscard _card; + final Slot _slot; + + const SatscardBalancePage(this._card, this._slot); + + @override + State createState() => SatscardBalancePageState(); +} + +class SatscardBalancePageState extends State { + final _pageController = PageController(); + + AddressInfo _recentAddressInfo; + RawSlotSweepTransaction _selectedTransaction; + Uint8List _slotPrivateKey; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + SlotBalancePage( + widget._card, + widget._slot, + onBack: () => Navigator.pop(context), + onSweep: (balance) { + _recentAddressInfo = balance; + _pageController.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + ), + SweepSlotPage( + widget._card, + widget._slot, + onBack: () => _pageController.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ), + onUnsealed: (transaction, privateKey) { + _selectedTransaction = transaction; + _slotPrivateKey = privateKey; + _pageController.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + }, + getAddressInfo: () => _recentAddressInfo, + getCachedPrivateKey: () { + // Allow for unsealed slots + if (_slotPrivateKey != null && _slotPrivateKey.isEmpty) { + return widget._slot.privkey; + } + return _slotPrivateKey; + }, + ), + BroadcastSlotSweepTransactionPage( + onBack: () => _pageController.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ), + onDone: () => Navigator.of(context).pop(), + getAddressInfo: () => _recentAddressInfo, + getPrivateKey: () => _slotPrivateKey, + getTransaction: () => _selectedTransaction, + ), + ], + ), + ); + } +} + +Widget buildErrorBody(ThemeData themeData, String title) { + return Stack( + children: [ + Positioned.fill( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + buildWarning(themeData, title: title), + ], + ), + ), + ], + ); +} + +Widget buildLoaderBody(ThemeData themeData, String title) { + return Stack( + children: [ + Positioned.fill( + child: buildIndicator(themeData, title: title), + ), + ], + ); +} + +Widget buildIndicator(ThemeData themeData, {String title}) { + return CircularProgress( + size: 64, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + color: themeData.progressIndicatorTheme.color, + title: title, + ); +} + +ListTile buildSlotPageTextTile( + BuildContext context, + MinFontSize minFont, { + String titleText, + Color titleColor, + String trailingText, + Color trailingColor, + String copyMessage, +}) { + final style = theme.FieldTextStyle.labelStyle + .copyWith(color: titleColor ?? Colors.white); + final trailing = AutoSizeText( + trailingText ?? "", + style: style.copyWith(color: trailingColor ?? Colors.white), + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ); + + return ListTile( + visualDensity: const VisualDensity(horizontal: 0, vertical: -4), + title: AutoSizeText( + titleText ?? "", + style: style, + maxLines: 1, + minFontSize: minFont.minFontSize, + stepGranularity: 0.1, + ), + trailing: copyMessage == null + ? trailing + : GestureDetector( + onTap: () { + ServiceInjector().device.setClipboardText(trailingText); + showFlushbar(context, message: copyMessage); + }, + child: trailing, + ), + ); +} + +Widget buildWarning(ThemeData themeData, {String title}) { + return WarningBox( + child: Text( + title, + style: themeData.textTheme.titleLarge, + textAlign: TextAlign.left, + ), + ); +} + +String formatBalanceValue( + BreezTranslations texts, AccountModel acc, Int64 sats) { + if (acc == null) { + return sats.toString(); + } + final satsString = acc.currency.format(sats); + if (acc.fiatCurrency == null || sats <= 0) { + return texts.satscard_balance_value_no_fiat(satsString); + } else { + final fiat = acc.fiatCurrency.format(sats); + return texts.satscard_balance_value_with_fiat(satsString, fiat); + } +} diff --git a/lib/routes/satscard_balance/slot_balance_page.dart b/lib/routes/satscard_balance/slot_balance_page.dart new file mode 100644 index 000000000..40bf4ce07 --- /dev/null +++ b/lib/routes/satscard_balance/slot_balance_page.dart @@ -0,0 +1,229 @@ +import 'package:breez/bloc/account/account_bloc.dart'; +import 'package:breez/bloc/account/account_model.dart'; +import 'package:breez/bloc/blocs_provider.dart'; +import 'package:breez/bloc/satscard/satscard_actions.dart'; +import 'package:breez/bloc/satscard/satscard_bloc.dart'; +import 'package:breez/routes/add_funds/address_widget.dart'; +import 'package:breez/routes/satscard_balance/satscard_balance_page.dart'; +import 'package:breez/services/breezlib/data/messages.pb.dart'; +import 'package:breez/utils/min_font_size.dart'; +import 'package:breez/widgets/back_button.dart' as backBtn; +import 'package:breez/widgets/error_dialog.dart'; +import 'package:breez/widgets/single_button_bottom_bar.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:cktap_protocol/cktapcard.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class SlotBalancePage extends StatefulWidget { + final Satscard _card; + final Slot _slot; + final Function() onBack; + final Function(AddressInfo) onSweep; + + const SlotBalancePage(this._card, this._slot, + {@required this.onBack, @required this.onSweep}); + + @override + State createState() => SlotBalancePageState(); +} + +class SlotBalancePageState extends State { + SatscardBloc _satscardBloc; + Future _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _satscardBloc = AppBlocsProvider.of(context); + _retrieveBalance(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, infoSnapshot) => StreamBuilder( + stream: AppBlocsProvider.of(context).accountStream, + builder: (context, accSnapshot) { + final texts = context.texts(); + final themeData = Theme.of(context); + + final acc = accSnapshot.data; + final info = infoSnapshot.data; + final error = infoSnapshot.error ?? accSnapshot.error; + final showError = error != null; + final showLoader = + !showError && (!infoSnapshot.hasData || !accSnapshot.hasData); + final canSweep = !showError && + !showLoader && + infoSnapshot.data.confirmedBalance > 0; + + return Scaffold( + appBar: AppBar( + leading: backBtn.BackButton(onPressed: widget.onBack), + title: Text(texts.satscard_balance_title), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.only(top: 10), + child: SingleButtonBottomBar( + stickToBottom: true, + text: showError + ? texts.satscard_balance_button_retry_label + : texts.satscard_balance_button_label, + onPressed: () => _onButtonPressed( + context, + themeData, + texts, + info, + showError: showError, + canSweep: canSweep, + ), + ), + ), + body: Scrollbar( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AddressWidget( + widget._slot.address, + isGeneric: true, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), + child: _buildBalanceBody( + context, + themeData, + texts, + acc, + info, + error: error, + showError: showError, + showLoader: showLoader, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildBalanceBody( + BuildContext context, + ThemeData themeData, + BreezTranslations texts, + AccountModel acc, + AddressInfo info, { + Object error, + bool showError, + bool showLoader, + }) { + if (showError) { + return buildWarning( + themeData, + title: texts.satscard_balance_error_address_info(error.toString()), + ); + } else if (showLoader) { + return Padding( + padding: const EdgeInsets.only(top: 24), + child: buildIndicator( + themeData, + title: info == null + ? texts.satscard_balance_awaiting_balance_label + : texts.satscard_balance_awaiting_account_label, + ), + ); + } + final minFont = MinFontSize(context); + return Column( + children: [ + buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_balance_confirmed_label, + trailingText: formatBalanceValue(texts, acc, info.confirmedBalance), + trailingColor: themeData.colorScheme.error, + ), + info.unconfirmedBalance == 0 + ? null + : buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_balance_unconfirmed_label, + trailingText: + formatBalanceValue(texts, acc, info.unconfirmedBalance), + trailingColor: Colors.white.withOpacity(0.4), + ), + buildSlotPageTextTile(context, minFont, + titleText: texts.satscard_balance_slot_label, + trailingText: + "${widget._card.activeSlotIndex + 1} / ${widget._card.numSlots}"), + buildSlotPageTextTile(context, minFont, + titleText: texts.satscard_balance_version_label, + trailingText: widget._card.appletVersion), + buildSlotPageTextTile(context, minFont, + titleText: texts.satscard_balance_birth_height_label, + trailingText: widget._card.birthHeight.toString()), + buildSlotPageTextTile(context, minFont, + titleText: texts.satscard_balance_card_id_label, + trailingText: widget._card.ident, + copyMessage: texts.satscard_card_id_copied), + ].whereNotNull().toList(), + ); + } + + void _onButtonPressed( + BuildContext context, + ThemeData themeData, + BreezTranslations texts, + AddressInfo info, { + bool showError, + bool canSweep, + }) async { + if (showError) { + _retrieveBalance(); + return; + } else if (!canSweep) { + promptError( + context, + texts.satscard_balance_warning_no_funds_title, + Text( + texts.satscard_balance_warning_no_funds_body, + style: themeData.dialogTheme.contentTextStyle, + ), + ); + return; + } else if (info.unconfirmedBalance > 0) { + final confirm = await promptAreYouSure( + context, + texts.satscard_balance_warning_unconfirmed_title, + Text( + texts.satscard_balance_warning_unconfirmed_body, + style: themeData.dialogTheme.contentTextStyle, + ), + okText: texts.satscard_dialog_ok, + cancelText: texts.satscard_dialog_cancel, + ); + if (!confirm) { + return; + } + } + if (context.mounted) { + widget.onSweep(info); + } + } + + void _retrieveBalance() { + setState(() { + final action = GetAddressInfo(widget._slot.address); + _satscardBloc.actionsSink.add(action); + _future = action.future.then((result) => result as AddressInfo); + }); + } +} diff --git a/lib/routes/satscard_balance/sweep_slot_page.dart b/lib/routes/satscard_balance/sweep_slot_page.dart new file mode 100644 index 000000000..af3d3dbdc --- /dev/null +++ b/lib/routes/satscard_balance/sweep_slot_page.dart @@ -0,0 +1,488 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:breez/bloc/account/account_bloc.dart'; +import 'package:breez/bloc/account/account_model.dart'; +import 'package:breez/bloc/account/add_funds_bloc.dart'; +import 'package:breez/bloc/account/add_funds_model.dart'; +import 'package:breez/bloc/blocs_provider.dart'; +import 'package:breez/bloc/lsp/lsp_bloc.dart'; +import 'package:breez/bloc/lsp/lsp_model.dart'; +import 'package:breez/bloc/satscard/satscard_actions.dart'; +import 'package:breez/bloc/satscard/satscard_bloc.dart'; +import 'package:breez/bloc/satscard/satscard_op_status.dart'; +import 'package:breez/routes/add_funds/conditional_deposit.dart'; +import 'package:breez/routes/satscard_balance/satscard_balance_page.dart'; +import 'package:breez/services/breezlib/data/messages.pb.dart'; +import 'package:breez/theme_data.dart' as theme; +import 'package:breez/utils/min_font_size.dart'; +import 'package:breez/utils/stream_builder_extensions.dart'; +import 'package:breez/widgets/back_button.dart' as backBtn; +import 'package:breez/widgets/fee_chooser.dart'; +import 'package:breez/widgets/satscard/satscard_operation_dialog.dart'; +import 'package:breez/widgets/satscard/spend_code_field.dart'; +import 'package:breez/widgets/single_button_bottom_bar.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:cktap_protocol/cktapcard.dart'; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; + +class SweepSlotPage extends StatefulWidget { + final Satscard _card; + final Slot _slot; + final Function() onBack; + final Function(RawSlotSweepTransaction, Uint8List) onUnsealed; + final AddressInfo Function() getAddressInfo; + final Uint8List Function() getCachedPrivateKey; + + const SweepSlotPage(this._card, this._slot, + {@required this.onBack, + @required this.onUnsealed, + @required this.getAddressInfo, + @required this.getCachedPrivateKey}); + + @override + State createState() => SweepSlotPageState(); +} + +class SweepSlotPageState extends State { + final _formKey = GlobalKey(); + final _spendCodeController = TextEditingController(); + final _spendCodeFocusNode = FocusNode(); + final _incorrectCodes = + HashSet(equals: (a, b) => a == b, hashCode: (a) => a.hashCode); + + AddFundsBloc _addFundsBloc; + SatscardBloc _satscardBloc; + AddressInfo _addressInfo; + Future _transactionFuture; + Object _fundError; + AddFundResponse _fundResponse; + CreateSlotSweepResponse _createResponse; + int _selectedFeeIndex = 1; + + RawSlotSweepTransaction get _transaction => + _createResponse != null ? _createResponse.txs[_selectedFeeIndex] : null; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _addFundsBloc = BlocProvider.of(context); + _satscardBloc = AppBlocsProvider.of(context); + _addressInfo = widget.getAddressInfo(); + _requestDepositAddress(); + } + + @override + Widget build(BuildContext context) { + final accountBloc = AppBlocsProvider.of(context); + final lspBloc = AppBlocsProvider.of(context); + final texts = context.texts(); + final displayIndex = widget._card.activeSlotIndex + 1; + return ConditionalDeposit( + title: texts.satscard_sweep_title(displayIndex), + enabledChild: FutureBuilder( + future: _transactionFuture, + builder: (context, futureSnapshot) => StreamBuilder2( + streamA: accountBloc.accountStream, + streamB: lspBloc.lspStatusStream, + builder: (context, accSnapshot, lspSnapshot) { + _createResponse = futureSnapshot.data; + + // Handle logic separately + final texts = context.texts(); + final info = _BuildInfo.create( + texts, + accSnapshot.data, + lspSnapshot.data, + _fundResponse, + _createResponse, + _selectedFeeIndex, + error: futureSnapshot.error ?? + _fundError ?? + lspSnapshot.error ?? + accSnapshot.error, + ); + + return Scaffold( + appBar: AppBar( + leading: backBtn.BackButton( + onPressed: () { + if (_spendCodeFocusNode.hasFocus) { + _spendCodeFocusNode.unfocus(); + } + widget.onBack(); + }, + ), + title: Text(texts.satscard_sweep_title(displayIndex)), + ), + bottomNavigationBar: _buildButton(context, texts, info), + body: _buildBody(context, texts, accSnapshot.data, info), + ); + }, + ), + ), + ); + } + + Widget _buildBody(BuildContext context, BreezTranslations texts, + AccountModel acc, _BuildInfo info) { + final feeOptions = _getFeeOptions(); + final themeData = Theme.of(context); + final minFont = MinFontSize(context); + + if (info.showError) { + return buildErrorBody(themeData, info.outText); + } else if (info.showLoader) { + return buildLoaderBody(themeData, info.outText); + } + return Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 10.0), + child: Scrollbar( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SpendCodeFormField( + context: context, + texts: texts, + focusNode: _spendCodeFocusNode, + controller: _spendCodeController, + style: theme.FieldTextStyle.textStyle, + validatorFn: (code) { + if (_incorrectCodes.contains(code)) { + return texts.satscard_spend_code_incorrect_code_hint; + } + return null; + }, + ), + Padding( + padding: const EdgeInsets.only(top: 24, bottom: 24), + child: FeeChooser( + economyFee: feeOptions[0], + regularFee: feeOptions[1], + priorityFee: feeOptions[2], + selectedIndex: _selectedFeeIndex, + onSelect: (index) => setState(() { + _selectedFeeIndex = index; + if (_spendCodeFocusNode.hasFocus) { + _spendCodeFocusNode.unfocus(); + } + }), + ), + ), + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all( + color: themeData.colorScheme.onSurface.withOpacity(0.4), + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_sweep_balance_label, + trailingText: + formatBalanceValue(texts, acc, _transaction.input), + trailingColor: themeData.colorScheme.error, + ), + buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_sweep_chain_fee_label, + trailingText: texts.satscard_sweep_fee_value( + acc.currency.format(_transaction.fees), + ), + titleColor: Colors.white.withOpacity(0.4), + trailingColor: Colors.white.withOpacity(0.4), + ), + !info.willOpenChannel + ? null + : buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_sweep_lsp_fee_label, + trailingText: texts.satscard_sweep_fee_value( + acc.currency.format(info.lspFee), + ), + titleColor: Colors.white.withOpacity(0.4), + trailingColor: Colors.white.withOpacity(0.4), + ), + buildSlotPageTextTile( + context, + minFont, + titleText: texts.satscard_sweep_receive_label, + trailingText: + formatBalanceValue(texts, acc, info.receiveAmount), + trailingColor: themeData.colorScheme.error, + ), + info.canSweep + ? null + : buildSlotPageTextTile( + context, + minFont, + titleText: info.failureLabel, + trailingText: formatBalanceValue( + texts, acc, info.failureAmount), + trailingColor: themeData.colorScheme.error, + ), + ].whereNotNull().toList(), + ), + ), + info.outText.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 12), + child: buildWarning( + themeData, + title: info.outText, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildButton( + BuildContext context, BreezTranslations texts, _BuildInfo info) { + String text = ""; + Function() onPressed; + + switch (info.buttonAction) { + case _ButtonAction.None: + return null; + + case _ButtonAction.Cancel: + text = texts.satscard_sweep_button_cancel_label; + onPressed = widget.onBack; + break; + + case _ButtonAction.Retry: + text = texts.satscard_balance_button_retry_label; + onPressed = () { + if (_fundResponse == null || _fundResponse.errorMessage.isNotEmpty) { + _requestDepositAddress(); + } else { + _updateFundResponse(_fundResponse); + } + }; + break; + + case _ButtonAction.Sweep: + text = texts.satscard_sweep_button_confirm_label; + onPressed = () { + Uint8List cachedKey = widget.getCachedPrivateKey(); + if (cachedKey != null && cachedKey.isNotEmpty) { + widget.onUnsealed(_transaction, cachedKey); + return; + } + if (_formKey.currentState.validate()) { + final spendCode = _spendCodeController.text; + if (widget._slot.status == SlotStatus.sealed) { + _satscardBloc.actionsSink + .add(UnsealSlot(widget._card, spendCode)); + } else { + _satscardBloc.actionsSink + .add(GetSlot(widget._card, widget._slot.index, spendCode)); + } + if (_spendCodeFocusNode.hasFocus) { + _spendCodeFocusNode.unfocus(); + } + showSatscardOperationDialog( + context, + _satscardBloc, + widget._card.ident, + ).then((r) { + if (r is SatscardOpStatusBadAuth) { + _incorrectCodes.add(spendCode); + _formKey.currentState.validate(); + } + if (r is SatscardOpStatusSuccess) { + widget.onUnsealed(_transaction, r.slot.privkey); + } + }); + } + }; + break; + } + return SingleButtonBottomBar( + stickToBottom: false, + text: text, + onPressed: onPressed, + ); + } + + List _getFeeOptions() { + if (_createResponse == null) { + return List.empty(); + } + return List.generate( + _createResponse.txs.length, + (i) => FeeOption( + _createResponse.txs[i].fees.toInt(), + _createResponse.txs[i].targetConfirmations, + ), + ); + } + + void _requestDepositAddress() { + setState(() => _fundError = null); + + final addFundsAction = AddFundsInfo(true, false); + _addFundsBloc.addFundRequestSink.add(addFundsAction); + _addFundsBloc.addFundResponseStream + .listen((response) => _updateFundResponse(response)); + _addFundsBloc.addFundResponseStream.handleError((e) { + if (context.mounted) { + setState(() => _fundError = e); + } + }); + } + + Future _requestSlotSweepTransactions(String depositAddress) { + final action = CreateSlotSweepTransactions(_addressInfo, depositAddress); + _satscardBloc.actionsSink.add(action); + return action.future; + } + + void _updateFundResponse(AddFundResponse response) { + if (!context.mounted) { + return; + } + setState(() { + _fundResponse = response; + if (_fundResponse == null || _fundResponse.errorMessage.isNotEmpty) { + _transactionFuture = null; + } else { + _transactionFuture = _requestSlotSweepTransactions(response.address); + } + }); + } +} + +enum _ButtonAction { + None, + Cancel, + Retry, + Sweep, +} + +class _BuildInfo { + _ButtonAction buttonAction = _ButtonAction.None; + bool showError = false; + bool showLoader = false; + String outText = ""; + Int64 receiveAmount = Int64.ZERO; + Int64 lspFee = Int64.ZERO; + String failureLabel = ""; + Int64 failureAmount = Int64.ZERO; + + bool get canSweep => failureLabel.isEmpty; + bool get willOpenChannel => lspFee > 0; + + _BuildInfo.sweepable(this.outText, this.receiveAmount, this.lspFee) + : buttonAction = _ButtonAction.Sweep; + + _BuildInfo.failure(this.outText, this.receiveAmount, this.lspFee, + this.failureLabel, this.failureAmount) + : buttonAction = _ButtonAction.Cancel; + + _BuildInfo.error(this.outText) + : buttonAction = _ButtonAction.Retry, + showError = true; + + _BuildInfo.loading(this.outText) + : buttonAction = _ButtonAction.None, + showLoader = true; + + factory _BuildInfo.create( + BreezTranslations texts, + AccountModel acc, + LSPStatus lsp, + AddFundResponse fund, + CreateSlotSweepResponse sweep, + int selectedTxIndex, { + Object error, + }) { + // Handle errors + if (error != null) { + return _BuildInfo.error(sweep == null + ? texts.satscard_sweep_error_create_transactions(error) + : texts.satscard_sweep_error_deposit_address(error)); + } else if (fund != null && fund.errorMessage.isNotEmpty) { + return _BuildInfo.error( + texts.satscard_sweep_error_deposit_address(fund.errorMessage)); + } + + // Handle loading + if (acc == null) { + return _BuildInfo.loading(texts.satscard_balance_awaiting_account_label); + } else if (lsp == null) { + return _BuildInfo.loading(texts.satscard_sweep_awaiting_lsp_label); + } else if (fund == null) { + return _BuildInfo.loading(texts.satscard_sweep_awaiting_deposit_label); + } else if (sweep == null) { + return _BuildInfo.loading(texts.satscard_sweep_awaiting_fees_label); + } + + // Figure out if we need a new channel + final liquidity = acc.connected ? acc.maxInboundLiquidity : Int64.ZERO; + final tx = sweep.txs[selectedTxIndex]; + var receive = tx.output; + var lspFee = Int64.ZERO; + var outText = ""; + if (liquidity < receive) { + final minFee = lsp.currentLSP.cheapestOpeningFeeParams.minMsat ~/ 1000; + final propFee = tx.output * + lsp.currentLSP.cheapestOpeningFeeParams.proportional ~/ + 1000000; + lspFee = minFee > propFee ? minFee : propFee; + receive -= lspFee; + + // Format our warning message + final sats = acc.currency.format(liquidity); + final fee = acc.currency.format(minFee); + final percent = + (lsp.currentLSP.cheapestOpeningFeeParams.proportional / 10000) + .toString(); + outText = acc.maxInboundLiquidity == 0 + ? texts.satscard_sweep_warning_lsp_fee_no_liquidity_label( + fee, percent) + : texts.satscard_sweep_warning_lsp_fee_label(fee, percent, sats); + } + + // Verify the sweep meets all the deposit conditions + final minimum = + fund.minAllowedDeposit > lspFee ? fund.minAllowedDeposit : lspFee; + if (tx.output < minimum) { + return _BuildInfo.failure(texts.satscard_sweep_warning_not_valid, receive, + lspFee, texts.satscard_sweep_balance_too_low_label, minimum); + } else if (tx.output > fund.maxAllowedDeposit) { + return _BuildInfo.failure( + texts.satscard_sweep_warning_not_valid, + receive, + lspFee, + texts.satscard_sweep_balance_too_high_label, + fund.maxAllowedDeposit); + } else if (receive < fund.requiredReserve) { + return _BuildInfo.failure( + texts.satscard_sweep_warning_not_valid, + receive, + lspFee, + texts.satscard_sweep_reserve_not_met_label, + fund.requiredReserve); + } + return _BuildInfo.sweepable(outText, receive, lspFee); + } +} diff --git a/lib/user_app.dart b/lib/user_app.dart index 3ceaa7be7..662377044 100644 --- a/lib/user_app.dart +++ b/lib/user_app.dart @@ -34,6 +34,7 @@ import 'package:breez/routes/payment_options/payment_options_page.dart'; import 'package:breez/routes/podcast/theme.dart'; import 'package:breez/routes/podcast_history/podcast_history.dart'; import 'package:breez/routes/qr_scan.dart'; +import 'package:breez/routes/satscard_balance/satscard_balance_page.dart'; import 'package:breez/routes/security_pin/lock_screen.dart'; import 'package:breez/routes/security_pin/security_and_backup/security_and_backup_page.dart'; import 'package:breez/routes/settings/pos_settings_page.dart'; @@ -375,6 +376,16 @@ class UserApp extends StatelessWidget { InitializeSatscardPage(settings.arguments as Satscard), settings: settings, ); + case '/satscard_balance': + return FadeInRoute ( + builder: (_) { + final arguments = settings.arguments as Map; + final card = arguments["card"] as Satscard; + final slot = arguments["slot"] as Slot; + return SatscardBalancePage(card, slot); + }, + settings: settings, + ); } assert(false); },