diff --git a/lib/pages/send_view/multisig/multisig_send_view.dart b/lib/pages/send_view/multisig/multisig_send_view.dart new file mode 100644 index 000000000..a6f6102ad --- /dev/null +++ b/lib/pages/send_view/multisig/multisig_send_view.dart @@ -0,0 +1,581 @@ +/* + * 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart index 008856e4e..991a9dc64 100644 --- a/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart +++ b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart @@ -484,8 +484,10 @@ class _MultisigSetupViewState extends ConsumerState { 'cosignerXpubs': xpubControllers.map((e) => e.text).toList(), }); + final newCoin = BIP48Bitcoin(parentWallet.cryptoCurrency.network); + final info = WalletInfo.createNew( - coin: BIP48Bitcoin(parentWallet.cryptoCurrency.network), + coin: newCoin, name: 'widget.walletName', // TODO [prio=high]: Add wallet name input field to multisig setup view and pass it to the coordinator view here. restoreHeight: await parentWallet.chainHeight, @@ -524,7 +526,7 @@ class _MultisigSetupViewState extends ConsumerState { node ??= parentWallet.cryptoCurrency.defaultNode; await ref .read(nodeServiceChangeNotifierProvider) - .setPrimaryNodeFor(coin: parentWallet.cryptoCurrency, node: node); + .setPrimaryNodeFor(coin: newCoin, node: node); final txTracker = TransactionNotificationTracker(walletId: info.walletId); diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 51753bb20..b658e0a9b 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -45,10 +45,12 @@ import '../../utilities/enums/sync_type_enum.dart'; import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/coins/bip48_bitcoin.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/interfaces/bip48_currency_interface.dart'; import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/impl/bip48_bitcoin_wallet.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; @@ -93,6 +95,7 @@ import '../paynym/paynym_claim_view.dart'; import '../paynym/paynym_home_view.dart'; import '../receive_view/receive_view.dart'; import '../send_view/frost_ms/frost_send_view.dart'; +import '../send_view/multisig/multisig_send_view.dart'; import '../send_view/send_view.dart'; import '../settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; @@ -1057,7 +1060,9 @@ class _WalletViewState extends ConsumerState { Navigator.of(context).pushNamed( wallet is BitcoinFrostWallet ? FrostSendView.routeName - : SendView.routeName, + : wallet is BIP48BitcoinWallet + ? MultisigSendView.routeName + : SendView.routeName, arguments: ( walletId: walletId, coin: coin, diff --git a/lib/wallets/wallet/impl/bip48_wallet.dart b/lib/wallets/wallet/impl/bip48_bitcoin_wallet.dart similarity index 100% rename from lib/wallets/wallet/impl/bip48_wallet.dart rename to lib/wallets/wallet/impl/bip48_bitcoin_wallet.dart diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 4445ba7a9..d08152fcf 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -22,10 +22,12 @@ import '../../utilities/flutter_secure_storage_interface.dart'; import '../../utilities/logger.dart'; import '../../utilities/paynym_is_api.dart'; import '../../utilities/prefs.dart'; +import '../crypto_currency/coins/bip48_bitcoin.dart'; import '../crypto_currency/crypto_currency.dart'; import '../isar/models/wallet_info.dart'; import '../models/tx_data.dart'; import 'impl/banano_wallet.dart'; +import 'impl/bip48_bitcoin_wallet.dart'; import 'impl/bitcoin_frost_wallet.dart'; import 'impl/bitcoin_wallet.dart'; import 'impl/bitcoincash_wallet.dart'; @@ -335,6 +337,9 @@ abstract class Wallet { case const (Banano): return BananoWallet(net); + case const (BIP48Bitcoin): + return BIP48BitcoinWallet(net); + case const (Bitcoin): return BitcoinWallet(net);