mirror of
https://github.com/cypherstack/stack_wallet.git
synced 2025-01-10 04:34:34 +00:00
582 lines
20 KiB
Dart
582 lines
20 KiB
Dart
|
/*
|
||
|
* 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<MultisigSendView> createState() => _MultisigSendViewState();
|
||
|
}
|
||
|
|
||
|
class _MultisigSendViewState extends ConsumerState<MultisigSendView> {
|
||
|
final List<int> recipientWidgetIndexes = [0];
|
||
|
int _greatestWidgetIndex = 0;
|
||
|
|
||
|
late final String walletId;
|
||
|
late final CryptoCurrency coin;
|
||
|
|
||
|
late TextEditingController noteController;
|
||
|
late TextEditingController onChainNoteController;
|
||
|
|
||
|
final _noteFocusNode = FocusNode();
|
||
|
|
||
|
Set<UTXO> selectedUTXOs = {};
|
||
|
|
||
|
bool _createSignLock = false;
|
||
|
|
||
|
Future<TxData> _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<void> _createSignConfig() async {
|
||
|
if (_createSignLock) {
|
||
|
return;
|
||
|
}
|
||
|
_createSignLock = true;
|
||
|
|
||
|
try {
|
||
|
// wait for keyboard to disappear
|
||
|
FocusScope.of(context).unfocus();
|
||
|
await Future<void>.delayed(
|
||
|
const Duration(milliseconds: 100),
|
||
|
);
|
||
|
|
||
|
TxData? txData;
|
||
|
if (mounted) {
|
||
|
txData = await showLoading<TxData>(
|
||
|
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<dynamic>(
|
||
|
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<StackColors>()!
|
||
|
.getSecondaryEnabledButtonStyle(context),
|
||
|
child: Text(
|
||
|
"Ok",
|
||
|
style: STextStyles.button(context).copyWith(
|
||
|
color: Theme.of(context)
|
||
|
.extension<StackColors>()!
|
||
|
.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<StackColors>()!.background,
|
||
|
appBar: AppBar(
|
||
|
leading: AppBarBackButton(
|
||
|
onPressed: () async {
|
||
|
if (FocusScope.of(context).hasFocus) {
|
||
|
FocusScope.of(context).unfocus();
|
||
|
await Future<void>.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<StackColors>()!.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<StackColors>()!
|
||
|
.textSubtitle1,
|
||
|
),
|
||
|
),
|
||
|
CustomTextButton(
|
||
|
text: selectedUTXOs.isEmpty
|
||
|
? "Select coins"
|
||
|
: "Selected coins (${selectedUTXOs.length})",
|
||
|
onTap: () async {
|
||
|
if (FocusScope.of(context).hasFocus) {
|
||
|
FocusScope.of(context).unfocus();
|
||
|
await Future<void>.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<UTXO>) {
|
||
|
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,
|
||
|
),
|
||
|
],
|
||
|
),
|
||
|
),
|
||
|
);
|
||
|
}
|
||
|
}
|