tx-confirmation-options-on-dev [cw-1463] (#3289)

* add note and contact options to tx confirmation screen

* fix closing animation for confirm sheet

* fix note button for lightning

* sizing fix

* add local tx info for evm/sol/trx

* fix for xmr/zec

* merge

* fixes
This commit is contained in:
malik1004x
2026-06-04 11:32:19 +02:00
committed by GitHub
parent 1704bc5172
commit 5099fa3b9c
22 changed files with 458 additions and 65 deletions
+6 -4
View File
@@ -1417,10 +1417,12 @@ Future<void> setup({
}
);
getIt.registerFactoryParam<TransactionDetailsModal, TransactionInfo, void>(
(transactionInfo, _) => TransactionDetailsModal(transactionDetailsViewModel: getIt.get<TransactionDetailsViewModel>(
param1: [transactionInfo, false]))
);
getIt.registerFactoryParam<TransactionDetailsModal, TransactionInfo, bool?>(
(transactionInfo, highlightNoteField) => TransactionDetailsModal(
transactionDetailsViewModel:
getIt.get<TransactionDetailsViewModel>(param1: [transactionInfo, false]),
highlightNoteField: highlightNoteField ?? false,
));
getIt.registerFactoryParam<TransactionDetailsPage, TransactionInfo, void>(
(TransactionInfo transactionInfo, _) => TransactionDetailsPage(
+40
View File
@@ -143,6 +143,39 @@ class CWEVM extends EVM {
);
}
@override
TransactionInfo getTransactionInfo(
{
required String id,
required int height,
required BigInt ethAmount,
required BigInt ethFee,
required String tokenSymbol,
int exponent = 18,
required TransactionDirection direction,
required bool isPending,
required DateTime date,
required int confirmations,
String? to,
String? from,
String? evmSignatureName,
String? contractAddress,
required int chainId,
}) =>
EVMChainTransactionInfo(id: id,
height: height,
ethAmount: ethAmount,
ethFee: ethFee,
tokenSymbol: tokenSymbol,
direction: direction,
isPending: isPending,
date: date,
confirmations: confirmations,
to: to,
from: from,
chainId: chainId);
@override
int formatterEVMParseAmount(String amount) => EVMChainFormatter.parseEVMChainAmount(amount);
@@ -432,6 +465,13 @@ class CWEVM extends EVM {
return null;
}
@override
BigInt getPendingTransactionFee(PendingTransaction tx) => (tx as PendingEVMChainTransaction).fee;
@override
String getPendingTransactionAmount(PendingTransaction tx) => (tx as PendingEVMChainTransaction).amount;
// Registry helper methods
static final EvmChainRegistry _registry = EvmChainRegistry();
+1 -1
View File
@@ -1315,7 +1315,7 @@ class _NewSendPageState extends State<NewSendPage> {
// height: MediaQuery.of(context).size.height*0.4,
child: ModalNavigator(
parentContext: modalContext,
heightMode: ModalHeightModes.autoLock,
heightMode: ModalHeightModes.natural,
rootPage: Material(
child: NewPicker(
title: S.of(context).set_fees,
@@ -16,9 +16,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class TransactionDetailsModal extends StatefulWidget {
const TransactionDetailsModal({super.key, required this.transactionDetailsViewModel});
const TransactionDetailsModal({super.key, required this.transactionDetailsViewModel, this.highlightNoteField = false});
final TransactionDetailsViewModel transactionDetailsViewModel;
final bool highlightNoteField;
@override
State<TransactionDetailsModal> createState() => _TransactionDetailsModalState();
@@ -38,6 +39,10 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
widget.transactionDetailsViewModel.updateNote(noteController.text);
}
});
if(widget.highlightNoteField) {
noteFocusNode.requestFocus();
}
}
@override
@@ -2,17 +2,25 @@ import 'dart:io';
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/core/utilities.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/animated_dropdown.dart';
import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart';
import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/view_model/send/send_view_model.dart';
import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cw_core/cake_hive.dart';
import 'package:cw_core/crypto_amount_format.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
@@ -31,12 +39,14 @@ class SendConfirmSheet extends StatefulWidget {
}
class _SendConfirmSheetState extends State<SendConfirmSheet> {
bool _committed = false;
void initState() {
super.initState();
reaction((context) => widget.sendViewModel.state, (state) {
if (state is TransactionCommitted) {
Future.delayed(const Duration(seconds: 2), () {
if (mounted) Navigator.of(context).maybePop();
reaction((_)=>widget.sendViewModel.state, (state){
if(state is TransactionCommitted) {
setState(() {
_committed = true;
});
}
});
@@ -60,33 +70,48 @@ class _SendConfirmSheetState extends State<SendConfirmSheet> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: SafeArea(
child: Observer(
child: Observer(
builder: (_) {
final commited = widget.sendViewModel.state is TransactionCommitted;
return Stack(
fit: StackFit.loose,
children: [
Positioned.fill(
return AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
clipBehavior: Clip.hardEdge,
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.topCenter,
heightFactor: _committed ? 0.0 : 1.0,
child: AnimatedSlide(
offset: commited ? Offset.zero : const Offset(1, 0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: const TransactionCommitedScreen(),
)),
AnimatedSlide(
offset: commited ? const Offset(-1, 0) : Offset.zero,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: SendTransactionDetails(
sendViewModel: widget.sendViewModel,
isPage: widget.isPage,
title: widget.title,
iconPath: widget.iconPath),
),
],
offset: _committed ? const Offset(-1, 0) : Offset.zero,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: SendTransactionDetails(
sendViewModel: widget.sendViewModel,
isPage: widget.isPage,
title: widget.title,
iconPath: widget.iconPath,
),
),
),
Align(
alignment: Alignment.topCenter,
heightFactor: _committed ? 1.0 : 0.0,
child: AnimatedSlide(
offset: _committed ? Offset.zero : const Offset(1, 0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: TransactionCommitedScreen(
sendViewModel: widget.sendViewModel,
),
),
),
],
),
);
},
),
)
),
),
),
@@ -178,7 +203,7 @@ class SendTransactionDetails extends StatelessWidget {
}),
sendViewModel.selectedCryptoCurrency)
: sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
formatAmount(transaction.amountFormatted), sendViewModel.selectedCryptoCurrency);
formatAmount(transaction.amountFormatted), sendViewModel.selectedCryptoCurrency).withMaxDecimals(8);
final fee =
"${(transaction == null) ? sendViewModel.amountParsingProxy.getDisplayCryptoStringFromBigInt(sumByBigInt(
@@ -404,29 +429,150 @@ class SendTransactionDetails extends StatelessWidget {
}
}
class TransactionCommitedScreen extends StatelessWidget {
const TransactionCommitedScreen({super.key});
class TransactionCommitedScreen extends StatefulWidget {
const TransactionCommitedScreen({super.key, this.sendViewModel});
final SendViewModel? sendViewModel;
@override
State<TransactionCommitedScreen> createState() => _TransactionCommitedScreenState();
}
class _TransactionCommitedScreenState extends State<TransactionCommitedScreen> {
bool _isNoteButtonLoading = false;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Column(
return Observer(
builder: (_) => Column(
spacing: 12,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
SizedBox(height: 12,),
Text(
S.of(context).transaction_sent_new,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
),
Image.asset(width: 256, height: 256, "assets/images/birthday_cake.png")
SizedBox(),
CakeImageWidget(width: 200, height: 200, imageUrl: "assets/new-ui/birthday_cake.svg"),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
spacing: 12,
children: [
if (widget.sendViewModel != null)
Row(
spacing: 8,
children: [
if (!(widget.sendViewModel!.checkIfAddressIsAContact(
widget.sendViewModel!.outputs.first.address)) &&
!(widget.sendViewModel!.outputs.first.isParsedAddress))
TransactionCommittedScreenActionButton(
text: S.of(context).save_contact,
iconPath: "assets/new-ui/save_contact.svg",
onTap: () {
Navigator.of(context).pushNamed(Routes.addressBookAddContact,
arguments: ContactRecord(
CakeHive.box<Contact>(Contact.boxName),
Contact(
name: "",
address: widget.sendViewModel!.outputs.first.address,
type: widget.sendViewModel!.wallet.currency)));
}),
// lightning has to be hacked in here as it doesn't get added to tx history for a few secs after committing.
if (widget.sendViewModel!.transactionInfo != null ||
widget.sendViewModel!.currency == CryptoCurrency.btcln)
TransactionCommittedScreenActionButton(
text: S.of(context).add_a_note,
iconPath: "assets/new-ui/add_note.svg",
isLoading: _isNoteButtonLoading,
onTap: () async {
setState(() {
_isNoteButtonLoading = true;
});
// for ln, we want to show the button and just have it wait until it appears in tx history
// for other currs this is instant
await asyncWhen((_) => widget.sendViewModel!.transactionInfo != null);
setState(() {
_isNoteButtonLoading = false;
});
final page = getIt.get<TransactionDetailsModal>(
param1: widget.sendViewModel!.transactionInfo!, param2: true);
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => page);
}),
],
),
NewPrimaryButton(
onPressed: Navigator.of(context).maybePop,
text: S.of(context).done,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary),
SizedBox(height: 12,)
],
),
),
],
),
);
}
}
class TransactionCommittedScreenActionButton extends StatelessWidget {
const TransactionCommittedScreenActionButton(
{super.key,
required this.text,
required this.iconPath,
required this.onTap,
this.isLoading = false});
final String text;
final String iconPath;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) {
return Flexible(
child: GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [
isLoading
? CupertinoActivityIndicator()
: CakeImageWidget(
imageUrl: iconPath,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500),
)
],
),
),
)));
}
}
class MultiSendAddressPreview extends StatefulWidget {
const MultiSendAddressPreview(
+29
View File
@@ -409,4 +409,33 @@ class CWSolana extends Solana {
await Future.wait(tokenChecks);
} catch (_) {}
}
@override
TransactionInfo getTransactionInfo({
required String id,
required DateTime blockTime,
required String to,
required String from,
String? tokenSymbol,
required TransactionDirection direction,
required double solAmount,
required bool isPending,
required double txFee,
}) =>
SolanaTransactionInfo(
id: id,
blockTime: blockTime,
to: to,
from: from,
tokenSymbol: tokenSymbol ?? "SOL",
direction: direction,
solAmount: solAmount,
isPending: isPending,
txFee: txFee);
@override
double getPendingTransactionAmount(PendingTransaction tx) => (tx as PendingSolanaTransaction).amount;
@override
double getPendingTransactionFee(PendingTransaction tx) => (tx as PendingSolanaTransaction).fee;
}
+28
View File
@@ -153,4 +153,32 @@ class CWTron extends Tron {
return tronWallet.tronTokenCurrencies
.any((element) => element.contractAddress == contractAddress);
}
@override
TransactionInfo getTransactionInfo({
required String id,
required BigInt tronAmount,
int? txFee,
String? tokenSymbol,
required TransactionDirection direction,
required DateTime blockTime,
String? to,
String? from,
required bool isPending,
}) =>
TronTransactionInfo(id: id,
tronAmount: tronAmount,
tokenSymbol: tokenSymbol ?? "TRX",
txFee: txFee,
direction: direction,
blockTime: blockTime,
to: to,
from: from,
isPending: isPending);
@override
String getPendingTransactionAmount(PendingTransaction tx) => (tx as PendingTronTransaction).amount;
@override
String getPendingTransactionFee(PendingTransaction tx) => (tx as PendingTronTransaction).amount;
}
@@ -326,6 +326,10 @@ abstract class DashboardViewModelBase with Store {
appStore.wallet!.transactionHistory.transactions.values.last.confirmations +
1;
} catch (_) {}
} else {
confirmations = appStore.wallet!.transactionHistory.transactions.values
.map((item) => item.isPending)
.fold(0, (val, pending) => pending ? val + 1 : val);
}
return length * confirmations;
}, _transactionDisposerCallback, delay: 300);
@@ -490,16 +494,23 @@ abstract class DashboardViewModelBase with Store {
}
// printV("Transaction disposer callback (relevantTxs: ${relevantTxs.length} current: ${transactions.length})");
// TODO(malik) update this in a saner way during the vm refactor
String _txIdentityString(String txHash, TransactionDirection direction) => "${txHash}_$direction";
String _txIdentityStringConfirmations(String txHash, TransactionDirection direction, int confirmations) => "${txHash}_${direction}_$confirmations";
String _txIdentityStringConfirmations(
String txHash, TransactionDirection direction, int confirmations, bool isPending) =>
"${txHash}_${direction}_${confirmations}_$isPending";
final existingKeys = transactions
.map((item) => _txIdentityStringConfirmations(item.transaction.txHash, item.transaction.direction, item.transaction.confirmations))
.map((item) => _txIdentityStringConfirmations(
item.transaction.txHash,
item.transaction.direction,
item.transaction.confirmations,
item.transaction.isPending))
.toSet();
final newTransactions = relevantTxs
.where((tx) => !existingKeys.contains(_txIdentityStringConfirmations(tx.txHash, tx.direction, tx.confirmations)))
.where((tx) => !existingKeys.contains(_txIdentityStringConfirmations(
tx.txHash, tx.direction, tx.confirmations, tx.isPending)))
.map((tx) => TransactionListItem(
transaction: tx,
balanceViewModel: balanceViewModel,
@@ -1291,6 +1302,10 @@ abstract class DashboardViewModelBase with Store {
appStore.wallet!.transactionHistory.transactions.values.last.confirmations +
1;
} catch (_) {}
} else {
confirmations = appStore.wallet!.transactionHistory.transactions.values
.map((item) => item.isPending)
.fold(0, (val, pending) => pending ? val + 1 : val);
}
return length * confirmations;
}, _transactionDisposerCallback, delay: 300);
@@ -218,7 +218,7 @@ class TransactionListItem extends ActionListItem with Keyable {
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
final asset = evm!.assetOfTransaction(balanceViewModel.wallet, transaction);
final asset = assetOfTransaction;
final price = balanceViewModel.fiatConversionStore.prices[asset];
amount = calculateFiatAmountRaw(
cryptoAmount: evm!.formatterEVMAmountToDouble(transaction: transaction),
+63
View File
@@ -8,6 +8,7 @@ import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/core/open_crypto_pay/exceptions.dart';
import 'package:cake_wallet/core/open_crypto_pay/models.dart';
import 'package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart';
import 'package:cake_wallet/core/utilities.dart';
import 'package:cake_wallet/core/validator.dart';
import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
import 'package:cake_wallet/decred/decred.dart';
@@ -59,6 +60,7 @@ import 'package:cw_core/exceptions.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/unspent_coin_type.dart';
@@ -488,6 +490,25 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
bool get shouldDisplayTOTP2FAForSendsToInternalWallet =>
_settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets;
@computed
TransactionInfo? get transactionInfo {
if(isEVMCompatibleChain(walletType)) {
return wallet.transactionHistory.transactions[pendingTransaction?.evmTxHashFromRawHex];
}
if (walletType == WalletType.monero) {
// monero tx history keys are in format txhash_amount_accountindex_addressindex
return wallet.transactionHistory.transactions[wallet.transactionHistory.transactions.keys
.firstWhereOrNull((item) => item.split("_").first == pendingTransaction?.id)];
}
if (walletType == WalletType.zcash) {
// zcash has a txHash getter, but the pendingTransaction id contains quotation marks for whatever reason?
final txIdNoQuotes = pendingTransaction?.id.replaceAll("\"", "");
return wallet.transactionHistory.transactions.values
.firstWhereOrNull((item) => item.txHash == txIdNoQuotes);
}
return wallet.transactionHistory.transactions[pendingTransaction?.id];
}
//* Still open to further optimize these checks
//* It works but can be made better
@action
@@ -1078,6 +1099,48 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
});
}
// FIXME(malik) ideally, this should be done wallet-side.
// it is required because evm, solana and tron don't actually save the transaction info when you send something.
// instead, they rely on the tx to eventually get fetched at sync time, which can take a while
if(isEVMWallet) {
wallet.transactionHistory.addOne(evm!.getTransactionInfo(
id: pendingTransaction!.evmTxHashFromRawHex!,
height: 0,
// FIXME(malik) this is even more critical given all the issues we had with decimal point parsing. with money it'll be easy
ethAmount: selectedCryptoCurrency.parseAmount(outputs.first.cryptoAmount),
ethFee: CryptoCurrency.eth.parseAmount(pendingTransaction!.feeFormattedValue),
tokenSymbol: selectedCryptoCurrency.title,
direction: TransactionDirection.outgoing,
isPending: true,
date: DateTime.now(),
confirmations: 0,
chainId: wallet.chainId ?? 0));
}
if(walletType == WalletType.solana) {
wallet.transactionHistory.addOne(solana!.getTransactionInfo(
id: pendingTransaction!.id,
blockTime: DateTime.now(),
tokenSymbol: selectedCryptoCurrency.title,
to: "",
from: "",
direction: TransactionDirection.outgoing,
solAmount: solana!.getPendingTransactionAmount(pendingTransaction!),
isPending: true,
txFee: solana!.getPendingTransactionFee(pendingTransaction!)));
}
if (walletType == WalletType.tron) {
wallet.transactionHistory.addOne(tron!.getTransactionInfo(
id: pendingTransaction!.id,
blockTime: DateTime.now(),
tokenSymbol: selectedCryptoCurrency.title,
direction: TransactionDirection.outgoing,
tronAmount: selectedCryptoCurrency.parseAmount(outputs.first.cryptoAmount),
isPending: true,
txFee: CryptoCurrency.trx.parseAmount(outputs.first.estimatedFee).toInt()));
}
if (pendingTransaction!.id.isNotEmpty) {
_addTransactionDescription();
}
@@ -127,6 +127,7 @@ class TxDetailRowDefinition {
.firstOrNull ??
"";
case WalletType.tron:
if(vm.transactionInfo.to != null)
ret = tron!.getTronBase58Address(vm.transactionInfo.to!, vm.wallet);
default:
break;
@@ -315,6 +316,7 @@ abstract class TransactionDetailsViewModelBase with Store {
TransactionPriority? transactionPriority;
CryptoCurrency get transactionAsset {
if (isEVMCompatibleChain(wallet.type)) {
return evm!.assetOfTransaction(wallet, transactionInfo);
}