/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. * Generated by Cypher Stack on 2023-05-26 * */ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; import '../../../frost_route_generator.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../providers/frost_wallet/frost_wallet_providers.dart'; import '../../../providers/providers.dart'; import '../../../providers/wallet/public_private_balance_state_provider.dart'; import '../../../themes/coin_icon_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/models/tx_data.dart'; import '../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/fee_slider.dart'; import '../../../widgets/frost_scaffold.dart'; import '../../../widgets/icon_widgets/x_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_dialog.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; import '../../coin_control/coin_control_view.dart'; import 'recipient.dart'; class MultisigSendView extends ConsumerStatefulWidget { const MultisigSendView({ super.key, required this.walletId, required this.coin, }); static const String routeName = "/frostSendView"; final String walletId; final CryptoCurrency coin; @override ConsumerState createState() => _MultisigSendViewState(); } class _MultisigSendViewState extends ConsumerState { final List recipientWidgetIndexes = [0]; int _greatestWidgetIndex = 0; late final String walletId; late final CryptoCurrency coin; late TextEditingController noteController; late TextEditingController onChainNoteController; final _noteFocusNode = FocusNode(); Set selectedUTXOs = {}; bool _createSignLock = false; Future _loadingFuture() async { final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; final recipients = recipientWidgetIndexes .map((i) => ref.read(pRecipient(i).state).state) .map((e) => (address: e!.address, amount: e!.amount!, isChange: false)) .toList(growable: false); final txData = await wallet.frostCreateSignConfig( txData: TxData(recipients: recipients), feePerWeight: customFeeRate, ); return txData; } Future _createSignConfig() async { if (_createSignLock) { return; } _createSignLock = true; try { // wait for keyboard to disappear FocusScope.of(context).unfocus(); await Future.delayed( const Duration(milliseconds: 100), ); TxData? txData; if (mounted) { txData = await showLoading( whileFuture: _loadingFuture(), context: context, message: "Generating sign config", rootNavigator: Util.isDesktop, onException: (e) { throw e; }, ); } final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet; if (mounted && txData != null) { ref.read(pFrostTxData.notifier).state = txData; ref.read(pFrostScaffoldArgs.state).state = ( info: ( walletName: wallet.info.name, frostCurrency: wallet.cryptoCurrency, ), walletId: walletId, stepRoutes: FrostRouteGenerator.sendFrostTxStepRoutes, parentNav: Navigator.of(context), frostInterruptionDialogType: FrostInterruptionDialogType.transactionCreation, callerRouteName: MultisigSendView.routeName, ); await Navigator.of(context).pushNamed( FrostStepScaffold.routeName, ); } } catch (e) { if (mounted) { unawaited( showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { return StackDialog( title: "Create sign config failed", message: e.toString(), rightButton: TextButton( style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), child: Text( "Ok", style: STextStyles.button(context).copyWith( color: Theme.of(context) .extension()! .accentColorDark, ), ), onPressed: () { Navigator.of(context).pop(); }, ), ); }, ), ); } } finally { _createSignLock = false; } } int customFeeRate = 1; bool _buttonEnabled = false; bool _validateRecipientFormStatesHelper() { for (final i in recipientWidgetIndexes) { final state = ref.read(pRecipient(i)); if (state?.amount == null || state?.address == null || state!.address.isEmpty) { return false; } } return true; } void _validateRecipientFormStates() { setState(() { _buttonEnabled = _validateRecipientFormStatesHelper(); }); } @override void initState() { coin = widget.coin; walletId = widget.walletId; noteController = TextEditingController(); super.initState(); } @override void dispose() { noteController.dispose(); _noteFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); final wallet = ref.watch(pWallets).getWallet(walletId); final showCoinControl = wallet is CoinControlInterface && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, ), ) && (coin is Firo ? ref.watch(publicPrivateBalanceStateProvider) == FiroType.public : true); return ConditionalParent( condition: !Util.isDesktop, builder: (child) => Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 50)); } if (context.mounted) { Navigator.of(context).pop(); } }, ), title: Text( "Sends ${coin.ticker}", style: STextStyles.navBarTitle(context), ), ), body: LayoutBuilder( builder: (builderContext, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( // subtract top and bottom padding set in parent minHeight: constraints.maxHeight, ), child: IntrinsicHeight( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: child, ), ), ), ); }, ), ), ), child: ConditionalParent( condition: Util.isDesktop, builder: (child) => Padding( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 14, ), child: child, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!Util.isDesktop) Container( decoration: BoxDecoration( color: Theme.of(context).extension()!.popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), child: Padding( padding: const EdgeInsets.all(12.0), child: Row( children: [ SvgPicture.file( File( ref.watch( coinIconProvider(coin), ), ), width: 22, height: 22, ), const SizedBox( width: 6, ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( ref.watch(pWalletName(walletId)), style: STextStyles.titleBold12(context) .copyWith(fontSize: 14), overflow: TextOverflow.ellipsis, maxLines: 1, ), // const SizedBox( // height: 2, // ), Text( "Available balance", style: STextStyles.label(context) .copyWith(fontSize: 10), ), ], ), Util.isDesktop ? const SizedBox( height: 24, ) : const Spacer(), GestureDetector( onTap: () {}, child: Container( color: Colors.transparent, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( ref.watch(pAmountFormatter(coin)).format( ref .watch(pWalletBalance(walletId)) .spendable, ), style: STextStyles.titleBold12(context).copyWith( fontSize: 10, ), textAlign: TextAlign.right, ), ], ), ), ), ], ), ), ), SizedBox( height: recipientWidgetIndexes.length > 1 ? 8 : 16, ), Column( children: [ for (int i = 0; i < recipientWidgetIndexes.length; i++) ConditionalParent( condition: recipientWidgetIndexes.length > 1, builder: (child) => Padding( padding: const EdgeInsets.only(top: 8), child: child, ), child: Recipient( key: Key( "recipientKey_${recipientWidgetIndexes[i]}", ), index: recipientWidgetIndexes[i], displayNumber: i + 1, coin: coin, onChanged: () { _validateRecipientFormStates(); }, remove: i == 0 && recipientWidgetIndexes.length == 1 ? null : () { ref .read( pRecipient(recipientWidgetIndexes[i]) .notifier, ) .state = null; recipientWidgetIndexes.removeAt(i); setState(() {}); _validateRecipientFormStates(); }, addAnotherRecipientTapped: () { // used for tracking recipient forms _greatestWidgetIndex++; recipientWidgetIndexes.add(_greatestWidgetIndex); setState(() {}); _validateRecipientFormStates(); }, sendAllTapped: () { return ref.read(pAmountFormatter(coin)).format( ref.read(pWalletBalance(walletId)).spendable, withUnitName: false, ); }, ), ), ], ), if (recipientWidgetIndexes.length > 1) const SizedBox( height: 12, ), if (recipientWidgetIndexes.length > 1) SecondaryButton( width: double.infinity, label: "Add recipient", onPressed: () { // used for tracking recipient forms _greatestWidgetIndex++; recipientWidgetIndexes.add(_greatestWidgetIndex); setState(() {}); }, ), if (showCoinControl) const SizedBox( height: 8, ), if (showCoinControl) RoundedWhiteContainer( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Coin control", style: STextStyles.w500_14(context).copyWith( color: Theme.of(context) .extension()! .textSubtitle1, ), ), CustomTextButton( text: selectedUTXOs.isEmpty ? "Select coins" : "Selected coins (${selectedUTXOs.length})", onTap: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); await Future.delayed( const Duration(milliseconds: 100), ); } if (mounted) { // TODO: [prio=high] make sure this coincontrol works correctly Amount? amount; final result = await Navigator.of(context).pushNamed( CoinControlView.routeName, arguments: Tuple4( walletId, CoinControlViewType.use, amount, selectedUTXOs, ), ); if (result is Set) { setState(() { selectedUTXOs = result; }); } } }, ), ], ), ), const SizedBox( height: 12, ), Text( "Note (optional)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), const SizedBox( height: 8, ), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), child: TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), decoration: standardInputDecoration( "Type something...", _noteFocusNode, context, ).copyWith( suffixIcon: noteController.text.isNotEmpty ? Padding( padding: const EdgeInsets.only(right: 0), child: UnconstrainedBox( child: Row( children: [ TextFieldIconButton( child: const XIcon(), onTap: () async { setState(() { noteController.text = ""; }); }, ), ], ), ), ) : null, ), ), ), const SizedBox( height: 12, ), Padding( padding: const EdgeInsets.only( bottom: 12, top: 16, ), child: FeeSlider( coin: coin, showWU: true, onSatVByteChanged: (rate) { customFeeRate = rate; }, ), ), Util.isDesktop ? const SizedBox( height: 12, ) : const Spacer(), const SizedBox( height: 12, ), PrimaryButton( label: "Create multisig transaction", enabled: _buttonEnabled, onPressed: _createSignConfig, ), const SizedBox( height: 16, ), ], ), ), ); } }