Merge branch 'dev' into cw-1440-new-buy-sell-flow

This commit is contained in:
Robert Malikowski
2026-08-11 13:03:39 +02:00
108 changed files with 5195 additions and 2920 deletions
+6
View File
@@ -317,6 +317,12 @@ class CWBitcoin extends Bitcoin {
return bitcoinWallet.walletAddresses.addressPageType == SilentPaymentsAddresType.p2sp;
}
@override
bool hasSelectedLightning(Object wallet) {
final bitcoinWallet = wallet as ElectrumWallet;
return bitcoinWallet.walletAddresses.addressPageType is LightningAddressType;
}
@override
BitcoinAddressType getBitcoinAddressType(ReceivePageOption option) {
switch (option) {
@@ -11,8 +11,7 @@ class EnsAddressProvider extends AddressLookupProvider {
AddressSource get source => AddressSource.ens;
@override
List<CryptoCurrency> get supportedCurrencies =>
[CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth];
List<CryptoCurrency> get supportedCurrencies => [CryptoCurrency.eth];
@override
bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsENS;
+30 -17
View File
@@ -1,11 +1,11 @@
import 'package:cake_wallet/evm/evm.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:ens_dart/ens_dart.dart';
import 'package:web3dart/web3dart.dart';
import "package:cake_wallet/evm/evm.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/utils/proxy_wrapper.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_type.dart";
import "package:ens_dart/ens_dart.dart";
import "package:web3dart/web3dart.dart";
class EnsRecord {
static Future<String> fetchEnsAddress(
@@ -20,36 +20,49 @@ class EnsRecord {
}
_client ??= Web3Client(
'https://ethereum-rpc.publicnode.com',
"https://ethereum-rpc.publicnode.com",
ProxyWrapper().getHttpIOClient(),
);
final ens = Ens(client: _client);
final registryEns = Ens(client: _client).withName(name);
final coinType = getEnsCoinType(cur);
if (coinType == null) {
printV('Unsupported currency for ENS: $cur');
return '';
printV("Unsupported currency for ENS: $cur");
return "";
}
try {
if (coinType == CoinType.ETH || coinType == CoinType.MATIC) {
return (await ens.withName(name).getAddress()).hex;
// Check if the ENS name has its resolver set. If not, return an empty string.
final resolverAddress = await registryEns.getResolverAddress(registryEns.nodeHash);
if (isZeroAddress(resolverAddress)) {
printV("No resolver set for ENS name: $name");
return "";
}
final resolverEns = Ens(client: _client,address: resolverAddress).withName(name);
if (coinType == CoinType.ETH) {
return (await resolverEns.getAddress()).hex;
} else {
return await ens.withName(name).getCoinAddress(coinType);
return await resolverEns.getCoinAddress(coinType);
}
} catch (e) {
printV(e);
return '';
return "";
}
}
static bool isZeroAddress(EthereumAddress address) =>
address.addressBytes.every((byte) => byte == 0);
static CoinType? getEnsCoinType(CryptoCurrency cur) => switch (cur) {
CryptoCurrency.xmr => CoinType.XMR,
CryptoCurrency.btc => CoinType.BTC,
CryptoCurrency.ltc => CoinType.LTC,
CryptoCurrency.eth => CoinType.ETH,
CryptoCurrency.matic => CoinType.MATIC,
CryptoCurrency.maticpoly => CoinType.MATIC,
_ => null,
};
}
@@ -218,6 +218,7 @@ class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
bridgeViewModel.executeBridge();
},
swiperText: S.of(context).swipe_to_bridge,
accessibleNavigationModeButtonText: S.of(context).confirm,
);
},
),
+12
View File
@@ -97,6 +97,10 @@ class _NewReceivePageState extends State<NewReceivePage> {
) as WalletAddressListItem?;
reaction((_) => widget.receiveOptionViewModel.selectedReceiveOption, (option) {
// The infobox depends on the selected address type, so the page has to be
// rebuilt when it changes.
if (mounted) setState(() {});
if (widget.dashboardViewModel.type == WalletType.bitcoin &&
bitcoin!.isBitcoinReceivePageOption(option)) {
widget.addressListViewModel.setAddressType(bitcoin!.getOptionToType(option));
@@ -179,6 +183,7 @@ class _NewReceivePageState extends State<NewReceivePage> {
autoGenerateSubaddressStatus: widget.lightningMode
? AutoGenerateSubaddressStatus.disabled
: widget.dashboardViewModel.settingsStore.autoGenerateSubaddressStatus,
addressRotates: _selectedAddressRotates,
);
return Container(
@@ -366,6 +371,13 @@ class _NewReceivePageState extends State<NewReceivePage> {
);
}
/// Zcash is the only wallet type that also offers static address types, so
/// the rotation notice must not be shown for it unless the disposable
/// transparent type is the one currently selected.
bool get _selectedAddressRotates =>
widget.addressListViewModel.type != WalletType.zcash ||
zcash!.isRotatingAddressOption(widget.receiveOptionViewModel.selectedReceiveOption);
void _showLabelModal() {
showMaterialModalBottomSheet(
context: context,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:flutter/material.dart";
class EvmAddressDetectedSheet extends StatelessWidget {
const EvmAddressDetectedSheet({super.key, required this.networks});
final List<RecipientNetworkItem> networks;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => EvmAddressDetectedSheet(networks: networks),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
StackedNetworkIcons(iconPaths: networks.map((n) => n.iconPath).toList()),
const SizedBox(height: 24),
Text(
S.of(context).evm_address_detected,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_for_tx,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 64),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: NetworkListCard(
rows: [
for (final network in networks)
RecipientNetworkListRow(
item: network,
imageOverride: network.chainBadgeIconPath,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}
class StackedNetworkIcons extends StatelessWidget {
const StackedNetworkIcons({
required this.iconPaths,
this.size = 50,
this.step = 34,
});
final List<String> iconPaths;
final double size;
final double step;
@override
Widget build(BuildContext context) {
if (iconPaths.isEmpty) return const SizedBox.shrink();
final colors = Theme.of(context).colorScheme;
final width = size + (iconPaths.length - 1) * step;
return SizedBox(
height: size,
width: width,
child: Stack(
children: [
for (int i = 0; i < iconPaths.length; i++)
Positioned(
left: i * step,
child: Container(
width: size,
height: size,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainer,
border: Border.all(color: colors.surface, width: 2),
),
child: CakeImageWidget(imageUrl: iconPaths[i], fit: BoxFit.contain),
),
),
],
),
);
}
}
@@ -0,0 +1,100 @@
import "package:cake_wallet/evm/evm.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import "package:flutter/material.dart";
class RecipientNetworkSelector extends StatelessWidget {
const RecipientNetworkSelector({
super.key,
required this.wallet,
required this.onTap,
});
final WalletBase wallet;
final void Function(ChainInfo currentChain) onTap;
@override
Widget build(BuildContext context) {
if (evm == null) return const SizedBox.shrink();
final currentChainId =
evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type);
final currentChain = evm!.getChainInfoByChainId(currentChainId);
if (currentChain == null) {
printV("RecipientNetworkSelector: no chain info for chainId $currentChainId");
return const SizedBox.shrink();
}
return _RecipientNetworkRow(
networkName: currentChain.name,
networkIconPath: symbolIconPathForWalletType(wallet.type) ?? "",
onTap: () => onTap(currentChain),
);
}
}
class _RecipientNetworkRow extends StatelessWidget {
const _RecipientNetworkRow({
required this.networkName,
required this.networkIconPath,
required this.onTap,
});
final String networkName;
final String networkIconPath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).network_prefix_on,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(width: 12),
CakeImageWidget(
imageUrl: networkIconPath,
width: 16,
height: 16,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(networkIconPath) ? colors.primary : null,
),
const SizedBox(width: 8),
Text(
networkName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
letterSpacing: -0.07,
color: colors.primary,
),
),
const SizedBox(width: 8),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: colors.surfaceContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.keyboard_arrow_down,
size: 18,
color: colors.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -0,0 +1,243 @@
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class RecipientNetworkItem {
const RecipientNetworkItem({
required this.chainId,
required this.name,
required this.iconPath,
this.chainBadgeIconPath,
});
final int chainId;
final String name;
final String iconPath;
final String? chainBadgeIconPath;
}
class SelectRecipientNetworkSheet extends StatelessWidget {
const SelectRecipientNetworkSheet({
super.key,
required this.networks,
required this.currentChainId,
});
final List<RecipientNetworkItem> networks;
final int currentChainId;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
required int currentChainId,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (_) => SelectRecipientNetworkSheet(
networks: networks,
currentChainId: currentChainId,
),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
RecipientNetworkItem? current;
final compatible = <RecipientNetworkItem>[];
for (final network in networks) {
if (network.chainId == currentChainId && current == null) {
current = network;
} else {
compatible.add(network);
}
}
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
CakeImageWidget(
imageUrl: "assets/new-ui/network_cube.svg",
width: 75,
height: 75,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
const SizedBox(height: 24),
Text(
S.of(context).select_recipient_network,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 32),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (current != null) ...[
NetworkListCard(
rows: [
RecipientNetworkListRow(
item: current,
subtitle: S.of(context).current_network,
onTap: () => Navigator.of(context).pop(current!.chainId),
),
],
),
const SizedBox(height: 24),
],
if (compatible.isNotEmpty) ...[
Text(
S.of(context).compatible_networks,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.06),
),
const SizedBox(height: 12),
NetworkListCard(
rows: [
for (final network in compatible)
RecipientNetworkListRow(
item: network,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
],
const SizedBox(height: 16),
],
),
),
),
],
),
),
);
}
}
class NetworkListCard extends StatelessWidget {
const NetworkListCard({required this.rows});
final List<Widget> rows;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < rows.length; i++) ...[
rows[i],
if (i != rows.length - 1)
Divider(
height: 1,
thickness: 1,
color: colors.surfaceContainerHigh,
indent: 48,
endIndent: 12,
),
],
],
),
);
}
}
class RecipientNetworkListRow extends StatelessWidget {
const RecipientNetworkListRow(
{super.key, required this.item, required this.onTap, this.subtitle, this.imageOverride});
final String? subtitle;
final String? imageOverride;
final VoidCallback onTap;
final RecipientNetworkItem item;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
children: [
CakeImageWidget(
imageUrl: imageOverride ?? item.iconPath,
width: 24,
height: 24,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(imageOverride ?? item.iconPath) ? colors.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
item.name,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w400, letterSpacing: -0.07),
),
if (subtitle != null)
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant, letterSpacing: -0.06, fontSize: 12),
),
],
),
),
Icon(Icons.chevron_right, size: 16, color: colors.onSurfaceVariant),
],
),
),
);
}
}
@@ -0,0 +1,122 @@
import "package:cake_wallet/generated/i18n.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/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class SendToNetworkPage extends StatelessWidget {
const SendToNetworkPage({
required this.title,
required this.destinationNetworkName,
required this.destinationIconPath,
required this.currentNetworkName,
required this.onSwitchWallet,
required this.onSwap,
super.key,
});
final String title;
final String destinationNetworkName;
final String destinationIconPath;
final String currentNetworkName;
final VoidCallback onSwitchWallet;
final VoidCallback onSwap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
trailingIcon: const Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CakeImageWidget(
imageUrl: destinationIconPath,
width: 75,
height: 75,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
const SizedBox(height: 24),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -0.1,
),
),
const SizedBox(height: 16),
Text(
S.of(context).send_to_network_description(
destinationNetworkName,
currentNetworkName,
),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NewPrimaryButton(
onPressed: onSwitchWallet,
image: CakeImageWidget(
imageUrl: "assets/new-ui/wallet_filled.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.onPrimary, BlendMode.srcIn),
),
text: S.of(context).switch_to_x_wallet(destinationNetworkName),
color: colors.primary,
textColor: colors.onPrimary,
),
const SizedBox(height: 12),
NewPrimaryButton(
onPressed: onSwap,
image: CakeImageWidget(
imageUrl: "assets/new-ui/swap_arrows.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn),
),
text: S.of(context).swap_from_network(currentNetworkName),
color: colors.surfaceContainer,
textColor: colors.primary,
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,140 @@
import "package:cake_wallet/generated/i18n.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/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class SwapFromNetworkPage extends StatelessWidget {
const SwapFromNetworkPage({
required this.title,
required this.primaryButtonText,
required this.destinationNetworkName,
required this.destinationNetworkIconPath,
required this.currentNetworkName,
required this.currentNetworkIconPath,
required this.onProceed,
super.key,
this.primaryHasSwapIcon = false,
});
final String title;
final String primaryButtonText;
final bool primaryHasSwapIcon;
final String destinationNetworkName;
final String destinationNetworkIconPath;
final String currentNetworkName;
final String currentNetworkIconPath;
final VoidCallback onProceed;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
trailingIcon: const Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
CakeImageWidget(
imageUrl: currentNetworkIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(currentNetworkIconPath)
? colors.primary
: null,
),
Icon(Icons.arrow_forward, color: colors.primary, size: 28),
CakeImageWidget(
imageUrl: destinationNetworkIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationNetworkIconPath)
? colors.primary
: null,
),
],
),
const SizedBox(height: 24),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -0.1,
),
),
const SizedBox(height: 16),
Text(
S.of(context).swap_from_network_description(
destinationNetworkName,
currentNetworkName,
),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NewPrimaryButton(
onPressed: onProceed,
image: primaryHasSwapIcon
? CakeImageWidget(
imageUrl: "assets/new-ui/swap_arrows.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.onPrimary, BlendMode.srcIn),
)
: null,
text: primaryButtonText,
color: colors.primary,
textColor: colors.onPrimary,
),
const SizedBox(height: 12),
NewPrimaryButton(
onPressed: () => Navigator.of(context).maybePop(),
text: S.of(context).cancel,
color: colors.surfaceContainer,
textColor: colors.primary,
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,108 @@
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/new-ui/widgets/send_page/l2_action_wallet_selector.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class SwitchNetworkWalletPage extends StatelessWidget {
const SwitchNetworkWalletPage({
required this.wallets,
required this.networkName,
required this.destinationIconPath,
super.key,
});
final String networkName;
final String destinationIconPath;
final List<WalletInfo> wallets;
static Future<WalletInfo?> push({
required BuildContext context,
required String networkName,
required String targetIconPath,
required List<WalletInfo> wallets,
}) =>
Navigator.of(context).push<WalletInfo>(
CupertinoPageRoute(
builder: (_) => SwitchNetworkWalletPage(
networkName: networkName,
destinationIconPath: targetIconPath,
wallets: wallets,
),
),
);
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 124),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.arrow_forward, color: colors.primary, size: 32),
const SizedBox(width: 8),
CakeImageWidget(
imageUrl: destinationIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
],
),
const SizedBox(height: 24),
Text(
S.of(context).switch_to_x_wallet(networkName),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Text(
S.of(context).select_compatible_wallet,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(height: 24),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: wallets.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, index) {
final wallet = wallets[index];
return WalletRow(
currencyIconPath: getCryptoCurrencyIconForWalletListItem(wallet.type),
walletName: wallet.name,
onTap: () => Navigator.of(context).pop(wallet),
);
},
),
),
],
),
),
);
}
}
@@ -117,7 +117,12 @@ class HistoryTradeTile extends StatelessWidget {
to!.title,
),
if (toChainIcon?.isNotEmpty ?? false)
CakeImageWidget(imageUrl: toChainIcon, width: 12, height: 12)
CakeImageWidget(
imageUrl: toChainIcon,
width: 12,
height: 12,
color: Theme.of(context).colorScheme.onSurface,
)
],
),
leadingIcon: _getLeadingStack(context),
+33 -2
View File
@@ -1,12 +1,23 @@
import 'dart:math';
import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:flutter/material.dart';
class ConfirmSwiper extends StatefulWidget {
final VoidCallback onConfirmed;
final String swiperText;
const ConfirmSwiper({super.key, required this.onConfirmed, required this.swiperText});
/// Label of the plain button rendered instead of the swiper when the platform
/// reports accessible navigation (VoiceOver / TalkBack), which intercepts the
/// horizontal drag the swiper depends on. Falls back to [swiperText].
final String? accessibleNavigationModeButtonText;
const ConfirmSwiper({
super.key,
required this.onConfirmed,
required this.swiperText,
this.accessibleNavigationModeButtonText,
});
@override
State<ConfirmSwiper> createState() => _ConfirmSwiperState();
@@ -30,6 +41,15 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
@override
Widget build(BuildContext context) {
if (MediaQuery.of(context).accessibleNavigation) {
return NewPrimaryButton(
onPressed: widget.onConfirmed,
text: widget.accessibleNavigationModeButtonText ?? widget.swiperText,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
final radius = (pillSize + pillHorizontalPadding * 2) / 2;
return LayoutBuilder(
@@ -41,7 +61,7 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
final maxDrag = areaWidth - pillSize - pillHorizontalPadding;
final triggerAt = maxDrag - _triggerThreshold;
return GestureDetector(
final swiper = GestureDetector(
onHorizontalDragUpdate: (d) {
setState(() {
drag = max(pillHorizontalPadding, min(maxDrag, drag + d.delta.dx));
@@ -98,6 +118,17 @@ class _ConfirmSwiperState extends State<ConfirmSwiper> {
),
),
);
// A single accessibility node for the whole control: the flowing label and
// the arrow knob are decorative and must not become separate stops. No
// semantics action is exposed here; a screen reader gets the button
// variant above instead.
return Semantics(
container: true,
excludeSemantics: true,
label: widget.swiperText,
child: swiper,
);
},
);
}
@@ -1,13 +1,18 @@
import 'package:cake_wallet/evm/evm.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/currency_groups.dart';
import 'package:cw_core/wallet_type.dart';
import "package:cake_wallet/evm/evm.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/currency_groups.dart";
import "package:cw_core/erc20_token.dart";
import "package:cw_core/spl_token.dart";
import "package:cw_core/tron_token.dart";
import "package:cw_core/wallet_type.dart";
String chainNameForCurrency(CryptoCurrency c) {
if (c == CryptoCurrency.btcln) return 'Lightning';
if (c == CryptoCurrency.btcln) {
return "Lightning";
}
final wt = cryptoCurrencyOrTokenToWalletType(c);
return wt != null ? walletTypeToString(wt) : (c.tag ?? '');
return wt != null ? walletTypeToString(wt) : (c.tag ?? "");
}
final Set<String> _stablecoinSymbols = {
@@ -27,19 +32,25 @@ const _kEvmDefaultTokenNatives = <CryptoCurrency>[
];
void appendEvmDefaultTokens(List<CryptoCurrency> into) {
if (evm == null) return;
if (evm == null) {
return;
}
String keyFor(CryptoCurrency c) {
final wt = cryptoCurrencyOrTokenToWalletType(c);
final chain = wt?.toString() ?? (c.tag ?? '').toUpperCase();
return '${c.title.toUpperCase()}|$chain';
final chain = wt?.toString() ?? (c.tag ?? "").toUpperCase();
return "${c.title.toUpperCase()}|$chain";
}
final seen = <String>{for (final c in into) keyFor(c)};
for (final native in _kEvmDefaultTokenNatives) {
final chainId = getChainIdByCryptoCurrency(native);
if (chainId == null) continue;
if (chainId == null) {
continue;
}
for (final t in evm!.getDefaultTokensByChainId(chainId)) {
if (seen.add(keyFor(t))) into.add(t);
if (seen.add(keyFor(t))) {
into.add(t);
}
}
}
}
@@ -52,6 +63,55 @@ class CurrencyPickerBalance {
final double? fiatValue;
}
String? _assetAddressKey(CryptoCurrency c) {
if (c is Erc20Token) {
return c.contractAddress.toLowerCase();
}
if (c is TronToken) {
return c.contractAddress.toLowerCase();
}
if (c is SPLToken) {
return c.mintAddress.toLowerCase();
}
return null;
}
CurrencyPickerBalance? balanceForAsset(
Map<CryptoCurrency, CurrencyPickerBalance>? balances,
CryptoCurrency asset,
) {
if (balances == null || balances.isEmpty) {
return null;
}
final direct = balances[asset];
if (direct != null) {
return direct;
}
final addressKey = _assetAddressKey(asset);
if (addressKey != null) {
for (final entry in balances.entries) {
if (_assetAddressKey(entry.key) == addressKey) {
return entry.value;
}
}
return null;
}
final title = asset.title.toUpperCase();
CurrencyPickerBalance? byTitle;
for (final entry in balances.entries) {
if (entry.key.title.toUpperCase() == title) {
if (byTitle != null) {
return null;
}
byTitle = entry.value;
}
}
return byTitle;
}
enum RecentsSource { none, trades, orders }
class CurrencyPickerArgs {
@@ -63,6 +123,7 @@ class CurrencyPickerArgs {
required this.onSelected,
required this.symbolResolver,
this.recentsSource = RecentsSource.none,
this.useSingleNetworkLayout = false,
});
final CryptoCurrency? selected;
@@ -72,6 +133,5 @@ class CurrencyPickerArgs {
final String Function(CryptoCurrency) symbolResolver;
final Map<CryptoCurrency, CurrencyPickerBalance>? balanceByAsset;
final RecentsSource recentsSource;
bool get isPreFiltered => filterByNetwork != null;
final bool useSingleNetworkLayout;
}
@@ -47,7 +47,7 @@ class CurrencyPickerSheet extends StatelessWidget {
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: args.isPreFiltered
child: args.useSingleNetworkLayout
? SingleNetworkCurrencyPicker(args: args)
: MultiNetworkCurrencyPicker(args: args),
),
@@ -1,17 +1,20 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart';
import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart';
import 'package:cake_wallet/new-ui/widgets/currency_picker/picker_section_header.dart';
import 'package:cake_wallet/reactions/wallet_utils.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/picker_section_header.dart";
import "package:cake_wallet/reactions/wallet_utils.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
class SingleNetworkCurrencyPicker extends StatefulWidget {
const SingleNetworkCurrencyPicker({super.key, required this.args});
const SingleNetworkCurrencyPicker({
required this.args,
super.key,
});
final CurrencyPickerArgs args;
@@ -41,24 +44,36 @@ class _SingleNetworkCurrencyPickerState extends State<SingleNetworkCurrencyPicke
Navigator.of(context).maybePop();
}
double _fiatValueFor(CryptoCurrency c) => _args.balanceByAsset?[c]?.fiatValue ?? 0;
CurrencyPickerBalance? _balanceFor(CryptoCurrency c) => balanceForAsset(_args.balanceByAsset, c);
double _fiatValueFor(CryptoCurrency c) => _balanceFor(c)?.fiatValue ?? 0;
@override
Widget build(BuildContext context) {
final native = walletTypeToCryptoCurrency(_network);
final query = _searchController.text.trim();
final tokens =
_args.items.where((c) => c != native).where((c) => currencyMatchesQuery(c, query)).toList();
final tokens = _args.items
.where(
(c) =>
c != native &&
cryptoCurrencyOrTokenToWalletType(c) == _network &&
currencyMatchesQuery(c, query),
)
.toList();
final insertOrder = {for (var i = 0; i < tokens.length; i++) tokens[i]: i};
tokens.sort((a, b) {
final av = _fiatValueFor(a);
final bv = _fiatValueFor(b);
if (bv != av) return bv.compareTo(av);
if (bv != av) {
return bv.compareTo(av);
}
return (insertOrder[a] ?? 0).compareTo(insertOrder[b] ?? 0);
});
final nativeMatches = currencyMatchesQuery(native, query);
final showSectionHeaders = _network != WalletType.bitcoin;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
@@ -66,40 +81,61 @@ class _SingleNetworkCurrencyPickerState extends State<SingleNetworkCurrencyPicke
child: (nativeMatches || tokens.isNotEmpty)
? ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
if (nativeMatches) ...[
PickerSectionHeader(title: S.of(context).picker_section_gas_token),
CurrencyPickerListContainer(
rows: [
CurrencyPickerRow(
currency: native,
isSelected: _args.selected == native,
trailing: _BalanceTrailing(balance: _args.balanceByAsset?[native]),
onTap: () => _selectCurrency(native),
children: showSectionHeaders
? [
if (nativeMatches) ...[
PickerSectionHeader(title: S.of(context).picker_section_gas_token),
CurrencyPickerListContainer(
rows: [
CurrencyPickerRow(
currency: native,
isSelected: _args.selected == native,
trailing: _BalanceTrailing(balance: _balanceFor(native)),
onTap: () => _selectCurrency(native),
),
],
),
const SizedBox(height: 16),
],
if (tokens.isNotEmpty) ...[
PickerSectionHeader(
title: S
.of(context)
.picker_section_tokens_standard(tokenStandardFor(_network)),
),
CurrencyPickerListContainer(
rows: [
for (final t in tokens)
CurrencyPickerRow(
currency: t,
isSelected: _args.selected == t,
trailing: _BalanceTrailing(balance: _balanceFor(t)),
onTap: () => _selectCurrency(t),
),
],
),
],
]
: [
CurrencyPickerListContainer(
rows: [
if (nativeMatches)
CurrencyPickerRow(
currency: native,
isSelected: _args.selected == native,
trailing: _BalanceTrailing(balance: _balanceFor(native)),
onTap: () => _selectCurrency(native),
),
for (final t in tokens)
CurrencyPickerRow(
currency: t,
isSelected: _args.selected == t,
trailing: _BalanceTrailing(balance: _balanceFor(t)),
onTap: () => _selectCurrency(t),
),
],
),
],
),
const SizedBox(height: 16),
],
if (tokens.isNotEmpty) ...[
PickerSectionHeader(
title: S
.of(context)
.picker_section_tokens_standard(tokenStandardFor(_network)),
),
CurrencyPickerListContainer(
rows: [
for (final t in tokens)
CurrencyPickerRow(
currency: t,
isSelected: _args.selected == t,
trailing: _BalanceTrailing(balance: _args.balanceByAsset?[t]),
onTap: () => _selectCurrency(t),
),
],
),
],
],
)
: Center(
child: Padding(
@@ -131,7 +167,7 @@ class _BalanceTrailing extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final amount = balance?.amount ?? '—';
final amount = balance?.amount ?? "—";
final fiat = balance?.fiat;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
+36 -39
View File
@@ -1,14 +1,13 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class NewPrimaryButton extends StatelessWidget {
const NewPrimaryButton({
required this.onPressed,
this.image,
required this.text,
required this.color,
required this.textColor,
this.image,
this.isLoading = false,
this.borderColor = Colors.transparent,
this.disabled = false,
@@ -18,48 +17,46 @@ class NewPrimaryButton extends StatelessWidget {
final VoidCallback onPressed;
final bool isLoading;
final bool disabled;
final SvgPicture? image;
final Widget? image;
final Color color;
final Color textColor;
final Color borderColor;
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.0,
child: TextButton(
onPressed: disabled ? null : onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
disabled ? color.withAlpha(128) : color,
),
shape: WidgetStateProperty.all<RoundedSuperellipseBorder>(
RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18.0),
Widget build(BuildContext context) => SizedBox(
height: 52,
child: TextButton(
onPressed: disabled ? null : onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
disabled ? color.withAlpha(128) : color,
),
shape: WidgetStateProperty.all<RoundedSuperellipseBorder>(
RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
child: Center(
child: isLoading
? const CupertinoActivityIndicator()
: Row(
spacing: 10,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (image != null) image!,
Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
),
),
child: Center(
child: isLoading
? CupertinoActivityIndicator()
: Row(
spacing: 10,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (image != null) image!,
Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
)
],
),
),
),
);
}
);
}
@@ -16,10 +16,15 @@ class ReceiveInfoBox extends StatelessWidget {
required this.onDismissed,
this.bottomWidget});
/// [addressRotates] tells whether the address type currently selected on the
/// receive page actually rotates. Most wallet types only ever expose rotating
/// address types, so it defaults to true; Zcash also offers static types, for
/// which the rotation notice would be wrong.
static ReceiveInfoBox? forWalletType(WalletType type,
{required VoidCallback onDismissed,
required AutoGenerateSubaddressStatus autoGenerateSubaddressStatus,
List<CryptoCurrency>? supportedCurrencies}) {
List<CryptoCurrency>? supportedCurrencies,
bool addressRotates = true}) {
switch (type) {
case WalletType.nano:
return null;
@@ -43,6 +48,7 @@ class ReceiveInfoBox extends StatelessWidget {
));
default:
if (autoGenerateSubaddressStatus == AutoGenerateSubaddressStatus.disabled) return null;
if (!addressRotates) return null;
return ReceiveInfoBox(
iconPath: "assets/new-ui/info.svg",
message: S.current.infobox_auto_address,
@@ -47,7 +47,8 @@ class SendConfirmBottomWidget extends StatelessWidget {
onConfirmed: () {
sendViewModel.commitTransaction(context);
},
swiperText: "${S.of(context).swipe_to_send}");
swiperText: "${S.of(context).swipe_to_send}",
accessibleNavigationModeButtonText: S.of(context).send);
case IsExecutingState:
return LoadingBottomWidget(
text: "${S.of(context).generating_transaction}...",
@@ -0,0 +1,106 @@
import "package:auto_size_text/auto_size_text.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/swap_page/provider_selector_page.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/view_model/exchange/exchange_view_model.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class AnyPaySwapFooter extends StatelessWidget {
const AnyPaySwapFooter({required this.exchangeViewModel, super.key});
final ExchangeViewModel exchangeViewModel;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Observer(
builder: (_) {
if (exchangeViewModel.depositAmount.isEmpty) {
return const SizedBox.shrink();
}
final provider = exchangeViewModel.forcedProvider ?? exchangeViewModel.providerDisplay;
final depositAmount = exchangeViewModel.depositAmount;
final depositSymbol =
exchangeViewModel.amountParsingProxy.getCryptoSymbol(exchangeViewModel.depositCurrency);
final depositFiat = exchangeViewModel.roundedDepositAmountFiat(2);
final showFiat = !exchangeViewModel.isFiatDisabled && depositFiat.isNotEmpty;
return Row(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: colors.primary),
),
child: AutoSizeText.rich(
TextSpan(
children: [
TextSpan(text: "$depositAmount $depositSymbol"),
if (showFiat)
TextSpan(
text: " ≈ ${exchangeViewModel.fiat.symbol}$depositFiat",
style: TextStyle(color: colors.onSurfaceVariant),
),
],
),
maxLines: 1,
minFontSize: 10,
style: textTheme.bodySmall
?.copyWith(fontWeight: FontWeight.w500, letterSpacing: -0.06),
),
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: provider == null
? null
: () => Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (context) => Material(
child: ProviderSelectorPage(exchangeViewModel: exchangeViewModel),
),
),
),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (provider != null)
CakeImageWidget(imageUrl: provider.description.image, width: 24, height: 24)
else
const CupertinoActivityIndicator(),
const SizedBox(width: 8),
Text(
provider?.title ?? "${S.of(context).finding_provider}...",
style: textTheme.bodyMedium?.copyWith(letterSpacing: -0.07),
),
const SizedBox(width: 8),
CakeImageWidget(
imageUrl: "assets/new-ui/chooser.svg",
width: 12,
height: 12,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
],
),
),
),
],
);
},
);
}
}
@@ -0,0 +1,789 @@
import "package:cake_wallet/core/amount_validator.dart";
import "package:cake_wallet/entities/qr_scanner.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
import "package:cake_wallet/new-ui/widgets/modern_button.dart";
import "package:cake_wallet/new-ui/widgets/send_page/fiat_amount_bar.dart";
import "package:cake_wallet/new-ui/widgets/send_page/send_memo_input.dart";
import "package:cake_wallet/new-ui/widgets/swap_page/refund_address_modal.dart";
import "package:cake_wallet/new-ui/widgets/swap_page/swap_address_selection_modal.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/utils/decimal_input_formatter.dart";
import "package:cake_wallet/utils/permission_handler.dart";
import "package:cake_wallet/view_model/exchange/exchange_view_model.dart";
import "package:cake_wallet/view_model/wallet_switcher_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currencies_with_memo.dart";
import "package:cw_core/currency.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:mobx/mobx.dart" as mobx;
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
import "package:permission_handler/permission_handler.dart";
class SwapAmountBox extends StatefulWidget {
SwapAmountBox({
required this.exchangeViewModel,
required this.isReceiverCard,
required this.walletSwitcherViewModel,
required this.currency,
required this.currencies,
required this.onCurrencySelected,
this.currencyValueValidator,
this.addressTextFieldValidator,
this.title = "",
this.hasRefundAddress = false,
this.hasAllAmount = false,
this.useBaseUnit = false,
this.allAmount,
this.onPushPasteButton,
this.onPushAddressBookButton,
this.filteredNetwork,
this.sourceSelectorMode = false,
this.walletName,
this.balanceByAsset,
this.useSingleNetworkLayout = false,
super.key,
});
final List<Currency> currencies;
final Function(Currency) onCurrencySelected;
final String title;
final bool isReceiverCard;
final Currency currency;
final bool useBaseUnit;
final bool hasRefundAddress;
final FormFieldValidator<String>? currencyValueValidator;
final FormFieldValidator<String>? addressTextFieldValidator;
final FormFieldValidator<String> allAmountValidator = AllAmountValidator();
final bool hasAllAmount;
final VoidCallback? allAmount;
final void Function(BuildContext context)? onPushPasteButton;
final void Function(BuildContext context)? onPushAddressBookButton;
final ExchangeViewModel exchangeViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final WalletType? filteredNetwork;
final bool sourceSelectorMode;
final String? walletName;
final Map<CryptoCurrency, CurrencyPickerBalance>? balanceByAsset;
final bool useSingleNetworkLayout;
@override
State<SwapAmountBox> createState() => SwapAmountBoxState();
}
class SwapAmountBoxState extends State<SwapAmountBox> {
final addressController = TextEditingController();
final amountController = TextEditingController();
final fiatAmountController = TextEditingController();
final amountFocusNode = FocusNode();
final memoController = TextEditingController();
mobx.ReactionDisposer? _memoReactionDisposer;
@override
void initState() {
if (widget.isReceiverCard) {
memoController.text = widget.exchangeViewModel.receiveAddressExtraId;
memoController.addListener(() {
if (widget.exchangeViewModel.receiveAddressExtraId != memoController.text) {
widget.exchangeViewModel.receiveAddressExtraId = memoController.text;
}
});
_memoReactionDisposer =
mobx.reaction((_) => widget.exchangeViewModel.receiveAddressExtraId, (String value) {
if (memoController.text != value) {
memoController.text = value;
}
});
}
super.initState();
}
@override
void dispose() {
_memoReactionDisposer?.call();
memoController.dispose();
addressController.dispose();
amountController.dispose();
fiatAmountController.dispose();
amountFocusNode.dispose();
super.dispose();
}
bool _fiatInputMode = false;
/// Whether the amount field is currently taking fiat input, the page's
/// best-rate reaction needs this to know which controller holds the amount.
bool get fiatInputMode => _fiatInputMode;
CurrencyPickerBalance? _sourceBalance() {
final selected = widget.currency;
if (selected is! CryptoCurrency) {
return null;
}
return balanceForAsset(widget.balanceByAsset, selected);
}
@override
Widget build(BuildContext context) {
final currencyToShow = (widget.currency is CryptoCurrency)
? widget.exchangeViewModel.amountParsingProxy
.getCryptoSymbol(widget.currency as CryptoCurrency)
: widget.currency.name.toUpperCase();
final chainIconPath = (widget.currency is CryptoCurrency)
? _getCurrencyChainIconPath(widget.currency as CryptoCurrency)
: null;
final colors = Theme.of(context).colorScheme;
if (widget.sourceSelectorMode) {
final available = _sourceBalance()?.amount.split(" ").first ?? "—";
return Column(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _presentCurrencyPicker,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
CakeImageWidget(
imageUrl: widget.currency.iconPath ?? "",
width: 24,
height: 24,
),
const SizedBox(width: 8),
Text(
currencyToShow,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(letterSpacing: -0.08),
),
if (chainIconPath != null && chainIconPath.isNotEmpty) ...[
const SizedBox(width: 4),
CakeImageWidget(
imageUrl: chainIconPath,
width: 12,
height: 12,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
],
const Spacer(),
CakeImageWidget(
imageUrl: "assets/new-ui/chooser.svg",
width: 12,
height: 12,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CakeImageWidget(
imageUrl: "assets/new-ui/wallet_filled.svg",
width: 16,
height: 16,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
const SizedBox(width: 4),
Flexible(
child: Text(
widget.walletName ?? "",
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
letterSpacing: -0.06,
color: colors.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(width: 8),
Text(
"${S.of(context).avl}: $available",
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
letterSpacing: -0.06,
color: colors.primary,
),
),
],
),
),
],
);
}
return Column(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.title.isNotEmpty)
Text(
widget.title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Container(
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: amountFocusNode.requestFocus,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 4,
children: [
Expanded(
child: Row(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: IntrinsicWidth(
child: Observer(
builder: (_) {
final showFetching = !widget.isReceiverCard &&
widget.exchangeViewModel.isFixedRateMode &&
widget.exchangeViewModel.receiveAmount.isNotEmpty &&
widget.exchangeViewModel.depositAmount.isEmpty;
return TextFormField(
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: !widget.useBaseUnit,
),
validator:
_fiatInputMode ? null : widget.currencyValueValidator,
controller:
_fiatInputMode ? fiatAmountController : amountController,
focusNode: amountFocusNode,
style: TextStyle(
fontSize: 28,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
isDense: true,
hintText: showFetching ? S.of(context).fetching : "0",
fillColor: Colors.transparent,
hoverColor: Colors.transparent,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
),
inputFormatters: <TextInputFormatter>[
DecimalInputFormatter(
maxDecimals:
widget.useBaseUnit ? 0 : widget.currency.decimals,
),
],
);
},
),
),
),
if (_fiatInputMode)
Center(
child: Text(
widget.exchangeViewModel.fiat.title,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
GestureDetector(
onTap: _presentCurrencyPicker,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(999999),
),
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 4, right: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CakeImageWidget(
imageUrl: widget.currency.iconPath ?? "",
width: 28,
height: 28,
),
const SizedBox(width: 10),
Text(
currencyToShow,
textAlign: TextAlign.center,
),
if (chainIconPath != null && chainIconPath.isNotEmpty) ...[
const SizedBox(width: 4),
CakeImageWidget(
imageUrl: chainIconPath,
width: 12,
height: 12,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
const SizedBox(width: 6),
] else
const SizedBox(width: 10),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999999999),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
child: Padding(
padding: const EdgeInsets.all(4),
child: RotatedBox(
quarterTurns: 2,
child: CakeImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg",
width: 4,
height: 4,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
),
),
),
const SizedBox(width: 4),
],
),
),
),
),
],
),
),
Observer(
builder: (_) {
final hasAllAmount = !(widget.isReceiverCard ||
widget.exchangeViewModel.isSendFromExternal ||
!widget.exchangeViewModel.hasAllAmount);
return FiatAmountBar(
foregroundElementColor: Theme.of(context).colorScheme.surfaceContainerHigh,
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
allAmountColor: hasAllAmount ? null : Colors.transparent,
allAmountTextColor:
hasAllAmount ? null : Theme.of(context).colorScheme.onSurfaceVariant,
fiatInputMode: _fiatInputMode,
allAmount: !hasAllAmount
? widget.isReceiverCard
? null
: widget.exchangeViewModel.balanceDisplay
: widget.exchangeViewModel.depositAvailableAmount,
onSwitchButtonPressed: () {
setState(() => _fiatInputMode = !_fiatInputMode);
if (_fiatInputMode) {
updateFiatAmount();
} else {
amountController.text = widget.isReceiverCard
? widget.exchangeViewModel.receiveAmount
: widget.exchangeViewModel.depositAmount;
}
},
onAllButtonPressed: () {
setState(() {
_fiatInputMode = false;
});
widget.allAmount?.call();
},
cryptoAmount: widget.isReceiverCard
? widget.exchangeViewModel.roundedReceiveAmount(6)
: widget.exchangeViewModel.roundedDepositAmount(6),
fiatAmount: widget.isReceiverCard
? widget.exchangeViewModel.roundedReceiveAmountFiat(6)
: widget.exchangeViewModel.roundedDepositAmountFiat(6),
cryptoCurrencySymbol: currencyToShow,
fiatCurrencySymbol: widget.exchangeViewModel.fiat.symbol,
);
},
),
Observer(
builder: (_) {
final addressEmpty = (widget.isReceiverCard &&
widget.exchangeViewModel.receiveAddress.isEmpty) ||
(!widget.isReceiverCard && widget.exchangeViewModel.depositAddress.isEmpty);
final addressPickerText = widget.isReceiverCard
? (addressEmpty ? S.of(context).select_receiver : S.of(context).to)
: S.of(context).from;
final addressDescription = widget.isReceiverCard
? widget.exchangeViewModel.receiveAddressDisplayName ??
_middleTruncate(widget.exchangeViewModel.receiveAddress, 8, 8)
: widget.exchangeViewModel.isSendFromExternal
? S.of(context).external
: widget.exchangeViewModel.wallet.name;
return Row(
spacing: 8,
children: [
Flexible(
child: GestureDetector(
onTap: _presentWalletPicker,
child: Container(
height: 36,
decoration: BoxDecoration(
color: (addressEmpty && widget.isReceiverCard)
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(9999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Observer(
builder: (_) => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 8,
mainAxisSize: MainAxisSize.max,
children: [
Text(
addressPickerText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: (addressEmpty && widget.isReceiverCard)
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
),
Text(
addressDescription,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
RotatedBox(
quarterTurns: 2,
child: CakeImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg",
colorFilter: ColorFilter.mode(
(addressEmpty && widget.isReceiverCard)
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
BlendMode.srcIn,
),
),
),
],
),
),
),
),
),
),
if (!widget.isReceiverCard &&
widget.exchangeViewModel.isSendFromExternal &&
widget.exchangeViewModel.depositAddress.isEmpty)
ModernButton.svg(
svgPath: "assets/new-ui/refund_address.svg",
onPressed: askForRefundAddress,
size: 36,
iconSize: 18,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
semanticLabel: S.of(context).refund_address,
),
if (widget.isReceiverCard &&
widget.exchangeViewModel.receiveAddress.isEmpty) ...[
ModernButton.svg(
svgPath: "assets/new-ui/paste.svg",
onPressed: () => widget.onPushPasteButton?.call(context),
size: 36,
iconSize: 20,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
semanticLabel: S.of(context).paste,
),
ModernButton.svg(
svgPath: "assets/new-ui/scan.svg",
onPressed: () => _presentQRScanner(context),
size: 36,
iconSize: 20,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
semanticLabel: S.of(context).scan,
),
],
],
);
},
),
if (widget.isReceiverCard)
Observer(
builder: (_) {
final selected = widget.exchangeViewModel.receiveCurrency;
final labelType = memoLabelTypeFor(selected);
if (labelType == null) {
return const SizedBox.shrink();
}
final isDestinationTag = labelType == MemoLabelType.destinationTag;
final hint = isDestinationTag
? S.of(context).destination_tag_optional
: S.of(context).memo_optional;
final disclaimer = isDestinationTag
? S.of(context).destination_tag_swap_disclaimer
: S.of(context).memo_swap_disclaimer;
return NewSendMemoInput(
memoController: memoController,
maxMemoLength: isDestinationTag ? 20 : 256,
memoLength: memoController.text.length,
hintText: hint,
disclaimerText: disclaimer,
);
},
),
],
),
),
),
],
);
}
void changeAddress({required String address}) {
setState(() => addressController.text = _normalizeAddressFormat(address));
}
void changeAmount({required String amount}) {
setState(() => amountController.text = amount);
}
String _normalizeAddressFormat(String address) {
if (address.startsWith("bitcoincash:")) {
address = address.substring(12);
}
return address;
}
void _presentCurrencyPicker() {
final rawCurrencies = widget.isReceiverCard
? widget.exchangeViewModel.receiveCurrencies
: widget.exchangeViewModel.depositCurrencies;
final currencies = rawCurrencies.whereType<CryptoCurrency>().toList();
final restrictToCurrentBalances = !widget.isReceiverCard &&
widget.balanceByAsset != null &&
widget.balanceByAsset!.isNotEmpty;
if (!restrictToCurrentBalances) {
appendEvmDefaultTokens(currencies);
}
if (widget.exchangeViewModel.wallet.type == WalletType.bitcoin) {
currencies.sort((a, b) {
if (a == CryptoCurrency.btcln) {
return -1;
}
if (b == CryptoCurrency.btcln) {
return 1;
}
return 0;
});
}
List<CryptoCurrency> items = currencies;
if (restrictToCurrentBalances) {
final allowed = {
for (final asset in widget.balanceByAsset!.keys) asset.title.toUpperCase(),
};
items = currencies.where((c) => allowed.contains(c.title.toUpperCase())).toList();
}
if (widget.isReceiverCard && widget.filteredNetwork != null) {
final network = widget.filteredNetwork!;
items = items.where((c) => cryptoCurrencyOrTokenToWalletType(c) == network).toList();
}
if (items.length <= 1) {
return;
}
final selected = widget.isReceiverCard
? widget.exchangeViewModel.receiveCurrency
: widget.exchangeViewModel.depositCurrency;
CurrencyPickerSheet.show(
context: context,
args: CurrencyPickerArgs(
items: items,
selected: selected,
filterByNetwork: widget.filteredNetwork,
balanceByAsset: widget.balanceByAsset,
useSingleNetworkLayout: widget.useSingleNetworkLayout,
recentsSource: RecentsSource.trades,
onSelected: widget.onCurrencySelected,
symbolResolver: widget.exchangeViewModel.amountParsingProxy.getCryptoSymbol,
),
);
}
Future<void> _presentQRScanner(BuildContext context) async {
final isCameraPermissionGranted =
await PermissionHandler.checkPermission(Permission.camera, context);
if (!isCameraPermissionGranted) {
return;
}
final code = await presentQRScanner(context);
if (code == null) {
return;
}
if (code.isEmpty) {
return;
}
try {
final uri = Uri.parse(code);
widget.exchangeViewModel.receiveAddress = uri.path;
} catch (_) {}
}
void _presentWalletPicker() async {
final res = await showMaterialModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: SwapAddressSelectionModal(
isSelectingReceiver: widget.isReceiverCard,
exchangeViewModel: widget.exchangeViewModel,
),
),
);
if (res != null && res is SwapAddressSelectionResult) {
if (widget.isReceiverCard) {
widget.exchangeViewModel.selectedAddressBookWallet = res.walletInfo;
widget.exchangeViewModel.receiveAddress = res.address!;
if (res.walletInfo?.name != null) {
if (res.accountName != null) {
widget.exchangeViewModel.receiveAddressDisplayName =
"${res.walletInfo!.name} → ${res.accountName}";
} else {
widget.exchangeViewModel.receiveAddressDisplayName = res.walletInfo!.name;
}
}
} else if (res.address == null || res.address!.isEmpty) {
widget.exchangeViewModel.isSendFromExternal = true;
askForRefundAddress();
} else {
widget.exchangeViewModel.isSendFromExternal = false;
switchToDepositWallet(res.walletInfo!.name);
}
}
}
void switchToDepositWallet(String walletName) async {
final walletType = cryptoCurrencyOrTokenToWalletType(widget.exchangeViewModel.depositCurrency);
if (walletType == null) {
return;
}
final wallet = await WalletInfo.get(walletName, walletType);
if (wallet == null) {
return;
}
widget.exchangeViewModel.depositAddress = wallet.address;
addressController.text = _normalizeAddressFormat(wallet.address);
widget.walletSwitcherViewModel.selectWallet(wallet);
await widget.walletSwitcherViewModel.switchToSelectedWallet();
}
void askForRefundAddress() async {
final refundAddress = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => RefundAddressModal(
selectedCurrency: widget.exchangeViewModel.depositCurrency,
isFromWalletSelection: true,
),
);
if (refundAddress != null && refundAddress is String) {
widget.exchangeViewModel.depositAddress = refundAddress;
} else {
widget.exchangeViewModel.depositAddress = "";
}
}
void updateFiatAmount() {
final newText = widget.isReceiverCard
? widget.exchangeViewModel.receiveAmountFiat
: widget.exchangeViewModel.depositAmountFiat;
if (double.tryParse(fiatAmountController.text) != double.tryParse(newText)) {
if (newText == "0.00") {
fiatAmountController.text = "";
} else {
fiatAmountController.text =
newText.replaceAll(RegExp(r"(?<=\.\d*)0+$"), "").replaceAll(RegExp(r"\.$"), "");
}
}
}
String _middleTruncate(String s, int head, int tail) {
if (s.length <= head + tail + 3) {
return s;
}
return s.substring(0, head) + "..." + s.substring(s.length - tail);
}
String? _getCurrencyChainIconPath(CryptoCurrency curr) {
try {
if (curr.chainIconPath != null) {
return curr.chainIconPath!;
}
if (curr.tag != null) {
final currencyFromTag = CryptoCurrency.fromString(curr.tag!);
if (currencyFromTag.chainIconPath != null) {
return currencyFromTag.chainIconPath!;
}
}
} catch (_) {}
return null;
}
}
@@ -0,0 +1,20 @@
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_type.dart";
class SwapFromSendArgs {
const SwapFromSendArgs({
required this.recipientAddress,
required this.receiveCurrency,
required this.targetWalletType,
this.depositBalanceByAsset,
this.receiveAmount,
});
final String recipientAddress;
final CryptoCurrency receiveCurrency;
final WalletType targetWalletType;
final Map<CryptoCurrency, CurrencyPickerBalance>? depositBalanceByAsset;
final Money? receiveAmount;
}
@@ -0,0 +1,49 @@
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class SwapSectionHeader extends StatelessWidget {
const SwapSectionHeader({
required this.label,
required this.networkName,
required this.networkIconPath,
});
final String label;
final String networkName;
final String networkIconPath;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
label,
style:
textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500, letterSpacing: -0.07),
),
const SizedBox(width: 8),
CakeImageWidget(
imageUrl: networkIconPath,
width: 16,
height: 16,
color: isMonochromeSymbolIcon(networkIconPath) ? colors.primary : null,
),
const SizedBox(width: 4),
Text(
networkName,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
letterSpacing: -0.07,
color: colors.primary,
),
),
],
),
);
}
}
+11
View File
@@ -21,6 +21,17 @@ String walletConnectCompatibleChainsLabel() {
return '$head, and ${names.last}';
}
String networkDisplayName(WalletType type, int? chainId) {
if (evm != null && isEVMCompatibleChain(type)) {
final id = chainId ?? evm!.getChainIdByWalletType(type);
final info = evm!.getChainInfoByChainId(id);
if (info != null) {
return info.name;
}
}
return walletTypeToString(type);
}
bool isEVMCompatibleChain(WalletType walletType) {
switch (walletType) {
case WalletType.polygon:
@@ -215,6 +215,10 @@ class WalletRestorePage extends BasePage {
walletRestoreFromKeysFormKey.currentState!.privateKeyController.text;
credentials['name'] =
walletRestoreFromKeysFormKey.currentState!.nameTextEditingController.text;
if (walletRestoreViewModel.hasBlockchainHeightSelector) {
credentials['height'] =
walletRestoreFromKeysFormKey.currentState!.blockchainHeightKey.currentState?.height;
}
} else {
credentials['name'] =
walletRestoreFromKeysFormKey.currentState!.nameTextEditingController.text;
@@ -80,6 +80,7 @@ class WCConnectionRequestSheet extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConfirmSwiper(
swiperText: S.of(context).wc_swipe_to_approve,
accessibleNavigationModeButtonText: S.of(context).wc_action_approve,
onConfirmed: () {
if (Navigator.canPop(context)) {
Navigator.of(context).pop(WCBottomSheetResult.one);
@@ -82,6 +82,7 @@ class WCSigningRequestSheet extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConfirmSwiper(
swiperText: swipeLabel,
accessibleNavigationModeButtonText: S.of(context).confirm,
onConfirmed: () {
if (Navigator.canPop(context)) {
Navigator.of(context).pop(WCBottomSheetResult.one);
@@ -78,7 +78,7 @@ class _WalletKeysPageBodyState extends State<WalletKeysPageBody>
showKeyTab = widget.walletKeysViewModel.items.isNotEmpty;
showSilentPaymentsTab =
widget.walletKeysViewModel.isBitcoin && widget.walletKeysViewModel.items.length > 4;
widget.walletKeysViewModel.isBitcoin && widget.walletKeysViewModel.silentPaymentItems.isNotEmpty;
showLegacySeedTab = widget.walletKeysViewModel.legacySeedSplit.isNotEmpty;
isLegacySeedOnly = widget.walletKeysViewModel.isLegacySeedOnly;
@@ -157,16 +157,12 @@ class _WalletKeysPageBodyState extends State<WalletKeysPageBody>
if (showKeyTab)
Padding(
padding: const EdgeInsets.only(left: 22, right: 22),
child: _buildKeysTab(
context,
showSilentPaymentsTab
? widget.walletKeysViewModel.items.sublist(0, 4)
: widget.walletKeysViewModel.items),
child: _buildKeysTab(context, widget.walletKeysViewModel.items),
),
if (showSilentPaymentsTab)
Padding(
padding: const EdgeInsets.only(left: 22, right: 22),
child: _buildKeysTab(context, widget.walletKeysViewModel.items.sublist(4)),
child: _buildKeysTab(context, widget.walletKeysViewModel.silentPaymentItems),
),
if (showLegacySeedTab)
Padding(
@@ -65,7 +65,8 @@ class BlockchainHeightState extends State<BlockchainHeightWidget> {
try {
final int _height;
if (restoreHeightController.text.isNotEmpty) {
_height = int.tryParse(restoreHeightController.text) ?? 0;
final digits = restoreHeightController.text.replaceAll(RegExp(r'[^0-9]'), '');
_height = int.tryParse(digits) ?? 0;
} else {
_height = 0;
}
@@ -381,7 +381,7 @@ class _TokenSelectionContentState extends State<_TokenSelectionContent> {
detectedWalletType: WalletType.solana,
detectedCurrency: selectedToken!,
isValid: true,
amount: widget.paymentRequest.amount,
amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
note: widget.paymentRequest.note,
scheme: widget.paymentRequest.scheme,
pjUri: widget.paymentRequest.pjUri,
@@ -398,7 +398,7 @@ class _TokenSelectionContentState extends State<_TokenSelectionContent> {
detectedWalletType: WalletType.tron,
detectedCurrency: selectedToken!,
isValid: true,
amount: widget.paymentRequest.amount,
amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
note: widget.paymentRequest.note,
scheme: widget.paymentRequest.scheme,
pjUri: widget.paymentRequest.pjUri,
@@ -415,7 +415,7 @@ class _TokenSelectionContentState extends State<_TokenSelectionContent> {
detectedWalletType: selectedNetwork!,
detectedCurrency: selectedToken!,
isValid: true,
amount: widget.paymentRequest.amount,
amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
note: widget.paymentRequest.note,
scheme: widget.paymentRequest.scheme,
pjUri: widget.paymentRequest.pjUri,
@@ -93,16 +93,28 @@ class ListItemRegularRowWidget extends StatelessWidget {
children: [
leadingIcon!,
Positioned(
right: -3,
bottom: -3,
child: CakeImageWidget(
imageUrl: badgeIconPath!,
width: badgeIconSize ?? 10,
height: badgeIconSize ?? 10,
errorWidget: leadingIconErrorWidget,
colorFilter: iconColor == null
? null
: ColorFilter.mode(iconColor!, BlendMode.srcIn),
right: -2,
bottom: -2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: theme.colorScheme.surfaceContainerHigh,
width: 2,
),
color: theme.colorScheme.onSurface,
),
padding: const EdgeInsets.all(2),
child: CakeImageWidget(
imageUrl: badgeIconPath!,
width: badgeIconSize ?? 12,
height: badgeIconSize ?? 12,
fit: BoxFit.cover,
color: theme.colorScheme.surface,
errorWidget: leadingIconErrorWidget,
),
),
),
],
+5
View File
@@ -11,4 +11,9 @@ class Debounce {
_timer?.cancel();
_timer = Timer(duration, action);
}
void cancel() {
_timer?.cancel();
_timer = null;
}
}
+80 -27
View File
@@ -1,25 +1,41 @@
import 'package:cw_core/amount/amount_sanitizer.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/payment_uris.dart';
import 'package:cake_wallet/nano/nano.dart';
import "package:cake_wallet/nano/nano.dart";
import "package:cw_core/amount/amount_sanitizer.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/format_fixed.dart";
import "package:cw_core/lnurl.dart";
import "package:cw_core/payment_uris.dart";
import "package:cw_core/utils/print_verbose.dart";
class PaymentRequest {
PaymentRequest(this.address, this.amount, this.note, this.scheme, this.pjUri,
{this.callbackUrl, this.callbackMessage, this.contractAddress});
PaymentRequest(
this.address,
this.amount,
this.note,
this.scheme,
this.pjUri, {
this.callbackUrl,
this.callbackMessage,
this.contractAddress,
this.chainId,
this.rawTokenAmount,
});
factory PaymentRequest.fromString(String input) {
try {
return PaymentRequest.fromBolt11(input);
} catch (_) {
return PaymentRequest.fromUri(Uri.parse(input));
try {
return PaymentRequest.fromUri(Uri.parse(input));
} catch (_) {
return PaymentRequest(input, "", "", "", null);
}
}
}
factory PaymentRequest.fromBolt11(String invoice) {
final amount = getBolt11Amount(invoice) ?? Money.zero(CryptoCurrency.btcln);
return PaymentRequest(invoice, amount.toString(), '', 'lightning', null);
return PaymentRequest(invoice, amount.toString(), "", "lightning", null);
}
factory PaymentRequest.fromUri(Uri? uri) {
@@ -32,13 +48,15 @@ class PaymentRequest {
String? callbackMessage;
String? pjUri;
String? contractAddress;
int? chainId;
String? rawTokenAmount;
if (uri != null) {
if (uri.queryParameters['pj'] != null) {
if (uri.queryParameters["pj"] != null) {
pjUri = uri.toString();
}
address = uri.queryParameters['address'] ?? uri.path;
address = uri.queryParameters["address"] ?? uri.path;
try {
final lnAmount = getBolt11Amount(uri.path) ?? Money.zero(CryptoCurrency.btcln);
@@ -47,27 +65,30 @@ class PaymentRequest {
}
} catch (_) {}
if (amount.isEmpty) {
amount = uri.queryParameters['tx_amount'] ?? uri.queryParameters['amount'] ?? "";
amount = uri.queryParameters["tx_amount"] ?? uri.queryParameters["amount"] ?? "";
}
note = uri.queryParameters['tx_description'] ?? uri.queryParameters['message'] ?? "";
note = uri.queryParameters["tx_description"] ?? uri.queryParameters["message"] ?? "";
scheme = uri.scheme;
callbackUrl = uri.queryParameters['callback'];
callbackMessage = uri.queryParameters['callbackMessage'];
walletType = uri.queryParameters['type'];
callbackUrl = uri.queryParameters["callback"];
callbackMessage = uri.queryParameters["callbackMessage"];
walletType = uri.queryParameters["type"];
if (scheme == "ethereum") {
final paymentUri = ERC681URI.fromUri(uri);
final hasExplicitChainId = uri.path.contains("@");
address = paymentUri.address;
amount = paymentUri.amount;
contractAddress = paymentUri.contractAddress;
chainId = hasExplicitChainId ? paymentUri.chainId : null;
rawTokenAmount = paymentUri.rawTokenAmount;
} else if (scheme == "tron") {
final token = uri.queryParameters['token'];
final token = uri.queryParameters["token"];
if (token != null && token.isNotEmpty) {
contractAddress = token;
}
} else if (scheme == "solana") {
final splToken = uri.queryParameters['spl-token'];
final splToken = uri.queryParameters["spl-token"];
if (splToken != null && splToken.isNotEmpty) {
contractAddress = splToken;
}
@@ -99,6 +120,8 @@ class PaymentRequest {
callbackUrl: callbackUrl,
callbackMessage: callbackMessage,
contractAddress: contractAddress,
chainId: chainId,
rawTokenAmount: rawTokenAmount,
);
}
@@ -110,23 +133,53 @@ class PaymentRequest {
final String? callbackUrl;
final String? callbackMessage;
final String? contractAddress;
final int? chainId;
final String? rawTokenAmount;
String? resolveTokenAmount(CryptoCurrency token) {
if (amount.isNotEmpty) {
return amount;
}
if (rawTokenAmount == null || rawTokenAmount!.isEmpty) {
return null;
}
final raw = BigInt.tryParse(rawTokenAmount!);
if (raw == null) {
return null;
}
// 1e9 whole tokens is beyond any real payment, so a larger result means the QR
// came from an old app version that encoded with 18 decimals regardless of token.
if (token.decimals != 18 && raw > BigInt.from(10).pow(token.decimals + 9)) {
printV("resolveTokenAmount: reading $rawTokenAmount with legacy 18 decimals");
return formatFixed(raw, 18);
}
return formatFixed(raw, token.decimals);
}
/// Checks if the amount string is already in a usable format (e.g., "123.45") and doesn't need to be converted from raw format.
///
/// This was causing an error for us when we scan Nano QRs with amounts in them, the amounts are already in usable format so when the parsing was done, it returns 0 wrongly.
static bool _isAlreadyUsableAmount(String amount) {
if (amount.isEmpty) return false;
if (amount.isEmpty) {
return false;
}
final parsed = double.tryParse(amount.sanitized());
if (parsed == null) return false;
// Check if the amount contains a decimal point and is a reasonable number,
// it's likely already in usable format rather than raw format
// Raw amounts are typically very large integers without decimal points
if (amount.contains('.') && parsed > 0 && parsed < 1000000000) return true;
if (parsed == null) {
return false;
}
if (amount.contains(".") && parsed > 0 && parsed < 1000000000) {
return true;
}
// If it's a small integer (less than 1 billion), it's likely already usable
if (parsed == parsed.toInt() && parsed < 1000000000) return true;
if (parsed == parsed.toInt() && parsed < 1000000000) {
return true;
}
return false;
}
+44 -7
View File
@@ -222,6 +222,24 @@ class TokenUtilities {
return null;
}
static String? findSolanaTokenMint(CryptoCurrency currency) {
if (currency is SPLToken) return currency.mintAddress;
for (final token in loadDefaultSolTokensForSwap()) {
if (_matchesCurrency(token, currency)) return token.mintAddress;
}
return null;
}
static String? findTronTokenContract(CryptoCurrency currency) {
if (currency is TronToken) return currency.contractAddress;
for (final token in loadDefaultTronTokensForSwap()) {
if (_matchesCurrency(token, currency)) return token.contractAddress;
}
return null;
}
static bool isNativeToken(CryptoCurrency currency) {
final title = currency.title.toLowerCase();
final tag = currency.tag?.toLowerCase();
@@ -289,9 +307,7 @@ class TokenUtilities {
final allTokens = <CryptoCurrency>[baseCurrency];
final addedAddresses = <String>{};
// Handle EVM networks
if (isEVMCompatibleChain(network)) {
// First, collect all user tokens
final userTokens = await _getUserTokensForNetwork(baseCurrency);
for (final token in userTokens) {
if (token is Erc20Token) {
@@ -302,7 +318,17 @@ class TokenUtilities {
}
}
// Then add tokens from CryptoCurrency.all that don't duplicate user tokens
if (evm != null) {
final chainId = evm!.getChainIdByWalletType(network);
for (final token in evm!.getDefaultTokensByChainId(chainId)) {
final address = token.contractAddress.toLowerCase();
if (addedAddresses.add(address)) {
allTokens.add(token);
}
}
}
// Add tokens from CryptoCurrency.all that don't duplicate user tokens
for (final currency in CryptoCurrency.all) {
// Match by tag for POL/BASE, match by title==tag for ETH
final matches = (baseCurrency.tag == null && baseCurrency.title == currency.tag) ||
@@ -320,10 +346,7 @@ class TokenUtilities {
}
}
}
}
// Handle Solana network
else if (network == WalletType.solana) {
} else if (network == WalletType.solana) {
final userSolTokens = await loadAllUniqueSolTokens();
for (final token in userSolTokens) {
final mintAddress = token.mintAddress.toLowerCase();
@@ -332,6 +355,13 @@ class TokenUtilities {
}
}
for (final token in loadDefaultSolTokensForSwap()) {
final mintAddress = token.mintAddress.toLowerCase();
if (addedAddresses.add(mintAddress)) {
allTokens.add(token);
}
}
for (final currency in CryptoCurrency.all) {
if (currency.tag?.toLowerCase() == 'sol') {
if (currency is SPLToken) {
@@ -356,6 +386,13 @@ class TokenUtilities {
}
}
for (final token in loadDefaultTronTokensForSwap()) {
final contractAddress = token.contractAddress.toLowerCase();
if (addedAddresses.add(contractAddress)) {
allTokens.add(token);
}
}
for (final currency in CryptoCurrency.all) {
if (currency.tag?.toLowerCase() == 'trx') {
if (currency is TronToken) {
@@ -448,9 +448,17 @@ abstract class ExchangeTradeViewModelBase with Store {
case WalletType.bsc:
return _createERC681URI(fromCurrency, inputAddress, amount);
case WalletType.solana:
return SolanaURI(amount: amount, address: inputAddress);
return SolanaURI(
amount: amount,
address: inputAddress,
contractAddress: TokenUtilities.findSolanaTokenMint(fromCurrency),
);
case WalletType.tron:
return TronURI(amount: amount, address: inputAddress);
return TronURI(
amount: amount,
address: inputAddress,
contractAddress: TokenUtilities.findTronTokenContract(fromCurrency),
);
case WalletType.monero:
return MoneroURI(address: inputAddress, amount: amount);
case WalletType.wownero:
@@ -494,6 +502,7 @@ abstract class ExchangeTradeViewModelBase with Store {
address: address,
amount: amount,
contractAddress: erc20Token.contractAddress,
tokenDecimals: erc20Token.decimal,
);
}
return null;
@@ -1185,6 +1185,19 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
}
}
if (receiveCurrency == CryptoCurrency.zec &&
selectedAddressBookWallet?.type == WalletType.zcash) {
if (wallet.type == WalletType.zcash && wallet.name == selectedAddressBookWallet!.name) {
receiveAddress = wallet.walletAddresses.addressForExchange;
} else {
receiveAddress = (await selectedAddressBookWallet!.getAddresses())
.entries
.firstWhereOrNull((e) => e.value == 'transparent')
?.key ??
receiveAddress;
}
}
// parse Lightning address to bolt11 invoice
if (receiveAddress.contains("@")) {
receiveAddress = await getBolt11FromLightingAddress(receiveAddress) ?? receiveAddress;
+11 -3
View File
@@ -146,6 +146,13 @@ abstract class PaymentViewModelBase with Store {
await evm!.selectChain(appStore.wallet!, detectedChainId!, node: node);
}
void applyManualEvmSelection(AddressDetectionResult detection) {
runInAction(() {
_lastDetectionResult = detection;
detectedWalletType = detection.detectedWalletType;
});
}
Future<List<WalletInfo>> getWalletsByType(WalletType walletType) async {
return (await WalletInfo.getAll()).where((wallet) => wallet.type == walletType).toList();
}
@@ -245,7 +252,8 @@ class PaymentFlowResult {
if (chainId == null && addressDetectionResult.detectedCurrency != null) {
chainId = getChainIdByCryptoCurrency(addressDetectionResult.detectedCurrency!);
}
if (chainId == null) {
if (chainId == null && isEVMCompatibleChain(wallet.type)) {
chainId = evm?.getChainIdByWalletType(wallet.type);
}
@@ -267,7 +275,7 @@ class PaymentFlowResult {
if (chainId == null && addressDetectionResult.detectedCurrency != null) {
chainId = getChainIdByCryptoCurrency(addressDetectionResult.detectedCurrency!);
}
if (chainId == null) {
if (chainId == null && isEVMCompatibleChain(wallets.first.type)) {
chainId = evm?.getChainIdByWalletType(wallets.first.type);
}
@@ -289,7 +297,7 @@ class PaymentFlowResult {
if (chainId == null && addressDetectionResult.detectedCurrency != null) {
chainId = getChainIdByCryptoCurrency(addressDetectionResult.detectedCurrency!);
}
if (chainId == null) {
if (chainId == null && isEVMCompatibleChain(walletType)) {
chainId = evm?.getChainIdByWalletType(walletType);
}
+1
View File
@@ -263,6 +263,7 @@ abstract class OutputBase with Store {
void updateWallet(
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> newWallet) {
_wallet = newWallet;
estimatedFee = Money.zero(cryptoCurrencyHandler());
}
@action
+4 -1
View File
@@ -138,9 +138,12 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
return null;
}, (_) async {
// When chain changes, update currencies and selected currency
final selectionAtChainChange = selectedCryptoCurrency;
await Future.delayed(const Duration(milliseconds: 100));
currencies = wallet.balance.keys.toList();
selectedCryptoCurrency = wallet.currency;
if (selectedCryptoCurrency == selectionAtChainChange) {
selectedCryptoCurrency = wallet.currency;
}
updateSendingBalance();
});
}
@@ -218,11 +218,13 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo
@computed
PaymentURI get uri {
if (tokenCurrency != null && isEVMCompatibleChain(wallet.type)) {
final erc20Token = tokenCurrency! as Erc20Token;
return ERC681URI(
chainId: wallet.chainId ?? 1,
address: wallet.walletAddresses.address,
amount: _amount,
contractAddress: (tokenCurrency! as Erc20Token).contractAddress,
contractAddress: erc20Token.contractAddress,
tokenDecimals: erc20Token.decimal,
);
}
if (tokenCurrency is TronToken && wallet.type == WalletType.tron) {
@@ -614,7 +616,8 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo
@computed
bool get isLightning =>
wallet.type == WalletType.bitcoin && selectedCurrency == CryptoCurrency.btcln;
wallet.type == WalletType.bitcoin &&
(selectedCurrency == CryptoCurrency.btcln || bitcoin!.hasSelectedLightning(wallet));
@computed
bool get isZCashTransparent {
+10 -1
View File
@@ -30,6 +30,7 @@ abstract class WalletKeysViewModelBase with Store {
_restoreHeight = _appStore.wallet!.walletInfo.restoreHeight,
_restoreHeightByTransactions = 0,
items = ObservableList<StandartListItem>(),
silentPaymentItems = ObservableList<StandartListItem>(),
_title = _getInitialTitle(_appStore.wallet!) {
_populateKeysItems();
@@ -87,6 +88,7 @@ abstract class WalletKeysViewModelBase with Store {
// this is incomplete, needs legacy seed toggle for XMR
bool get shouldShowHeightBox => [WalletType.bitcoin, WalletType.zcash].contains(_wallet.type);
final ObservableList<StandartListItem> items;
final ObservableList<StandartListItem> silentPaymentItems;
@observable
String _title;
@@ -150,6 +152,7 @@ abstract class WalletKeysViewModelBase with Store {
void _populateKeysItems() {
items.clear();
silentPaymentItems.clear();
Map<String, String>? keys;
@@ -217,6 +220,11 @@ abstract class WalletKeysViewModelBase with Store {
final electrumKeys = bitcoin!.getWalletKeys(_appStore.wallet!);
items.addAll([
if ((electrumKeys['masterFingerprint'] ?? '').isNotEmpty)
StandartListItem(
title: "Master fingerprint",
value: electrumKeys['masterFingerprint']!,
),
if ((electrumKeys['wif'] ?? '').isNotEmpty)
StandartListItem(title: "WIF", value: electrumKeys['wif']!),
if ((electrumKeys['privateKey'] ?? '').isNotEmpty)
@@ -233,7 +241,8 @@ abstract class WalletKeysViewModelBase with Store {
}
if (keys != null) {
items.addAll([
final keysList = _wallet.type == WalletType.bitcoin ? silentPaymentItems : items;
keysList.addAll([
if ((keys['primaryAddress'] ?? '').isNotEmpty)
StandartListItem(
key: ValueKey('${_walletName}_wallet_primary_address_item_key'),
+8
View File
@@ -176,6 +176,14 @@ class CWZcash extends Zcash {
return getSelectedAddressType(wallet) == ZcashReceivePageOption.transparentRotated;
}
/// The disposable transparent type is the only Zcash address type that
/// rotates. The public transparent address, both shielded types and the
/// unified address are all derived from the account and stay the same.
@override
bool isRotatingAddressOption(ReceivePageOption option) {
return option == ZcashReceivePageOption.transparentRotated;
}
@override
dynamic getZcashAddressType(ReceivePageOption option) {
return (option as ZcashReceivePageOption).toType();