CW-1273: Implement USDT0 Bridge (#2855)

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* Add transaction history and status polling for USDT0 bridging

* Add  Transaction history and status polling for USDT0 Bridging

* USDT0 Bridging Implementation

* Update Arbitrum USDT token symbol

* fix: Merge conflicts

* fix: Merge conflicts

* chore: Rever formatting

* feat: Implement new flow

* feat: Implement new ui flow

* Add currency to configure

* feat: new ui flow

* Update USDT0 implementation
- Switch to sqlite for storage instead of hive
- Switch bridge history and details to modals
- Switch bridge transfer details page to new tx details ui

* refactor: rearrange modal action buttons

* enhance usdt0 bridge flows

* Update lib/entities/wallet_manager.dart [skip ci]

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2026-04-15 05:06:05 +02:00
committed by GitHub
co-authored by Omar Hatem
parent 162e0a5300
commit 586a040e4a
45 changed files with 3958 additions and 43 deletions
+318
View File
@@ -0,0 +1,318 @@
import 'dart:convert';
import 'package:cw_core/utils/proxy_wrapper.dart';
class LayerZeroScanService {
static const String _baseUrl = 'https://scan.layerzero-api.com/v1';
static Future<LayerZeroMessageStatus?> getMessageStatus(
String sourceTxHash,
) async {
try {
final uri = Uri.parse('$_baseUrl/messages/tx/$sourceTxHash');
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode != 200) return null;
final decoded = json.decode(response.body);
Map<String, dynamic>? data;
if (decoded is List && decoded.isNotEmpty) {
data = decoded.first as Map<String, dynamic>?;
} else if (decoded is Map<String, dynamic>) {
if (decoded.containsKey('data') && decoded['data'] is List) {
final dataList = decoded['data'] as List;
if (dataList.isNotEmpty) {
data = dataList.first as Map<String, dynamic>?;
}
} else {
data = decoded;
}
}
if (data == null) return null;
return LayerZeroMessageStatus.fromJson(data);
} catch (_) {
return null;
}
}
}
class LayerZeroMessageStatus {
final String? guid;
final LayerZeroStatus? status;
final LayerZeroSource? source;
final LayerZeroDestination? destination;
final LayerZeroVerification? verification;
LayerZeroMessageStatus({
this.guid,
this.status,
this.source,
this.destination,
this.verification,
});
factory LayerZeroMessageStatus.fromJson(Map<String, dynamic> json) {
return LayerZeroMessageStatus(
guid: json['guid'] as String?,
status: json['status'] != null
? LayerZeroStatus.fromJson(
json['status'] as Map<String, dynamic>,
)
: null,
source: json['source'] != null
? LayerZeroSource.fromJson(
json['source'] as Map<String, dynamic>,
)
: null,
destination: json['destination'] != null
? LayerZeroDestination.fromJson(
json['destination'] as Map<String, dynamic>,
)
: null,
verification: json['verification'] != null
? LayerZeroVerification.fromJson(
json['verification'] as Map<String, dynamic>,
)
: null,
);
}
bool get isDelivered =>
status?.name == 'DELIVERED' ||
destination?.status == 'DELIVERED' ||
destination?.status == 'SUCCEEDED';
bool get isFailed => status?.name == 'FAILED' || destination?.status == 'FAILED';
bool get isInflight =>
status?.name == 'INFLIGHT' ||
status?.name == 'CONFIRMING' ||
destination?.status == 'WAITING' ||
destination?.status == 'VALIDATING_TX' ||
(destination?.status != 'SUCCEEDED' &&
destination?.status != 'DELIVERED' &&
destination?.status != 'FAILED' &&
destination?.tx == null);
}
class LayerZeroStatus {
final String? name;
final String? message;
LayerZeroStatus({this.name, this.message});
factory LayerZeroStatus.fromJson(Map<String, dynamic> json) {
return LayerZeroStatus(
name: json['name'] as String?,
message: json['message'] as String?,
);
}
}
class LayerZeroSource {
final String? status;
final LayerZeroTransaction? tx;
LayerZeroSource({
this.status,
this.tx,
});
factory LayerZeroSource.fromJson(Map<String, dynamic> json) {
return LayerZeroSource(
status: json['status'] as String?,
tx: json['tx'] != null
? LayerZeroTransaction.fromJson(
json['tx'] as Map<String, dynamic>,
)
: null,
);
}
}
class LayerZeroDestination {
final String? status;
final LayerZeroTransaction? tx;
final LayerZeroNativeDrop? nativeDrop;
final LayerZeroLzCompose? lzCompose;
LayerZeroDestination({
this.status,
this.tx,
this.nativeDrop,
this.lzCompose,
});
factory LayerZeroDestination.fromJson(Map<String, dynamic> json) {
return LayerZeroDestination(
status: json['status'] as String?,
tx: json['tx'] != null
? LayerZeroTransaction.fromJson(
json['tx'] as Map<String, dynamic>,
)
: null,
nativeDrop: json['nativeDrop'] != null
? LayerZeroNativeDrop.fromJson(
json['nativeDrop'] as Map<String, dynamic>,
)
: null,
lzCompose: json['lzCompose'] != null
? LayerZeroLzCompose.fromJson(
json['lzCompose'] as Map<String, dynamic>,
)
: null,
);
}
}
class LayerZeroNativeDrop {
final String? status;
LayerZeroNativeDrop({this.status});
factory LayerZeroNativeDrop.fromJson(Map<String, dynamic> json) {
return LayerZeroNativeDrop(
status: json['status'] as String?,
);
}
}
class LayerZeroLzCompose {
final String? status;
LayerZeroLzCompose({this.status});
factory LayerZeroLzCompose.fromJson(Map<String, dynamic> json) {
return LayerZeroLzCompose(
status: json['status'] as String?,
);
}
}
class LayerZeroTransaction {
final String? txHash;
final String? blockHash;
final String? blockNumber;
final int? blockTimestamp;
final String? from;
LayerZeroTransaction({
this.txHash,
this.blockHash,
this.blockNumber,
this.blockTimestamp,
this.from,
});
factory LayerZeroTransaction.fromJson(Map<String, dynamic> json) {
final blockNumberValue = json['blockNumber'];
final blockNumberStr = blockNumberValue != null
? (blockNumberValue is int ? blockNumberValue.toString() : blockNumberValue as String?)
: null;
return LayerZeroTransaction(
txHash: json['txHash'] as String?,
blockHash: json['blockHash'] as String?,
blockNumber: blockNumberStr,
blockTimestamp: json['blockTimestamp'] as int?,
from: json['from'] as String?,
);
}
}
class LayerZeroVerification {
final LayerZeroDvn? dvn;
final LayerZeroSealer? sealer;
LayerZeroVerification({
this.dvn,
this.sealer,
});
factory LayerZeroVerification.fromJson(Map<String, dynamic> json) {
return LayerZeroVerification(
dvn: json['dvn'] != null
? LayerZeroDvn.fromJson(
json['dvn'] as Map<String, dynamic>,
)
: null,
sealer: json['sealer'] != null
? LayerZeroSealer.fromJson(
json['sealer'] as Map<String, dynamic>,
)
: null,
);
}
}
class LayerZeroDvn {
final Map<String, LayerZeroDvnStatus>? dvns;
final String? status;
LayerZeroDvn({
this.dvns,
this.status,
});
factory LayerZeroDvn.fromJson(Map<String, dynamic> json) {
final dvnsMap = json['dvns'] as Map<String, dynamic>?;
final parsedDvns = dvnsMap?.map(
(key, value) => MapEntry(
key,
LayerZeroDvnStatus.fromJson(value as Map<String, dynamic>),
),
);
return LayerZeroDvn(
dvns: parsedDvns,
status: json['status'] as String?,
);
}
}
class LayerZeroDvnStatus {
final String? status;
final String? txHash;
final String? blockHash;
final int? blockNumber;
final int? blockTimestamp;
LayerZeroDvnStatus({
this.status,
this.txHash,
this.blockHash,
this.blockNumber,
this.blockTimestamp,
});
factory LayerZeroDvnStatus.fromJson(Map<String, dynamic> json) {
return LayerZeroDvnStatus(
status: json['status'] as String?,
txHash: json['txHash'] as String?,
blockHash: json['blockHash'] as String?,
blockNumber: json['blockNumber'] as int?,
blockTimestamp: json['blockTimestamp'] as int?,
);
}
}
class LayerZeroSealer {
final String? status;
final LayerZeroTransaction? tx;
LayerZeroSealer({
this.status,
this.tx,
});
factory LayerZeroSealer.fromJson(Map<String, dynamic> json) {
return LayerZeroSealer(
status: json['status'] as String?,
tx: json['tx'] != null
? LayerZeroTransaction.fromJson(
json['tx'] as Map<String, dynamic>,
)
: null,
);
}
}
+45 -1
View File
@@ -32,6 +32,7 @@ import 'package:cake_wallet/core/wallet_loading_service.dart';
import 'package:cake_wallet/core/yat_service.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:cake_wallet/entities/biometric_auth.dart';
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/exchange_api_mode.dart';
@@ -52,6 +53,9 @@ import 'package:cake_wallet/nano/nano.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
import 'package:cake_wallet/new-ui/pages/account_customizer.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
import 'package:cake_wallet/new-ui/pages/home_page.dart';
@@ -163,6 +167,8 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_
import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart';
import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart';
import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart';
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart';
@@ -183,6 +189,7 @@ import 'package:cake_wallet/store/dashboard/order_filter_store.dart';
import 'package:cake_wallet/store/dashboard/orders_store.dart';
import 'package:cake_wallet/store/dashboard/payjoin_transactions_store.dart';
import 'package:cake_wallet/store/dashboard/trade_filter_store.dart';
import 'package:cake_wallet/store/bridge_transfers_store.dart';
import 'package:cake_wallet/store/dashboard/trades_store.dart';
import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart';
import 'package:cake_wallet/store/node_list_store.dart';
@@ -202,6 +209,8 @@ import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart';
import 'package:cake_wallet/view_model/anonpay_details_view_model.dart';
import 'package:cake_wallet/view_model/auth_view_model.dart';
import 'package:cake_wallet/view_model/backup_view_model.dart';
import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_amount_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_view_model.dart';
@@ -269,6 +278,7 @@ import 'package:cake_wallet/view_model/transaction_details_view_model.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_model.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
@@ -327,7 +337,6 @@ late Box<Order> _ordersSource;
late Box<UnspentCoinsInfo> _unspentCoinsInfoSource;
late Box<PayjoinSession> _payjoinSessionSource;
late Box<AnonpayInvoiceInfo> _anonpayInvoiceInfoSource;
Future<void> setup({
required Box<Node> nodeSource,
required Box<Node> powNodeSource,
@@ -396,6 +405,7 @@ Future<void> setup({
TradesStore(tradesSource: _tradesSource, appStore: getIt.get<AppStore>()));
getIt.registerSingleton<OrdersStore>(
OrdersStore(ordersSource: _ordersSource, settingsStore: getIt.get<SettingsStore>()));
getIt.registerSingleton<BridgeTransfersStore>(BridgeTransfersStore());
getIt.registerFactory(() =>
PayjoinTransactionsStore(payjoinSessionSource: _payjoinSessionSource));
getIt.registerSingleton<TradeFilterStore>(TradeFilterStore());
@@ -1761,11 +1771,45 @@ Future<void> setup({
getIt.registerFactory(() => DEuroSavingsPage(getIt<DEuroViewModel>()));
getIt.registerFactory(() => BridgeViewModel(
appStore: getIt.get<AppStore>(),
bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
walletManager: getIt.get<WalletManager>(),
fiatConversionStore: getIt.get<FiatConversionStore>(),
settingsStore: getIt.get<SettingsStore>(),
));
getIt.registerFactory(() => BridgeHistoryViewModel(
bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
appStore: getIt.get<AppStore>(),
));
getIt.registerFactoryParam<BridgeDetailsViewModel, BridgeTransfer, void>(
(BridgeTransfer transfer, _) {
final appStore = getIt.get<AppStore>();
return BridgeDetailsViewModel(
transferForDetails: transfer,
bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
walletId: appStore.wallet?.name ?? transfer.walletId,
);
});
getIt.registerFactoryParam<BridgeDetailPage, BridgeTransfer, void>(
(BridgeTransfer transfer, _) => BridgeDetailPage(
viewModel: getIt.get<BridgeDetailsViewModel>(param1: transfer),
));
getIt.registerLazySingleton(() => NodeSwitchingService(
appStore: getIt.get<AppStore>(),
settingsStore: getIt.get<SettingsStore>(),
nodeSource: _nodeSource,
));
getIt.registerFactoryParam<BridgeAmountPage, CryptoCurrency, void>(
(CryptoCurrency initialToken, _) => BridgeAmountPage(
bridgeViewModel: getIt.get<BridgeViewModel>(),
bridgeHistoryViewModel: getIt.get<BridgeHistoryViewModel>(),
initialToken: initialToken,
),
);
_isSetupFinished = true;
}
+127
View File
@@ -0,0 +1,127 @@
import 'package:cw_core/db/sqlite.dart';
class BridgeTransfer {
BridgeTransfer({
required this.id,
required this.walletId,
required this.sourceChainId,
required this.destinationChainId,
required this.tokenSymbol,
required this.tokenContract,
required this.amount,
required this.recipientAddress,
required this.sourceTxHash,
required this.status,
required this.createdAt,
this.updatedAt,
this.confirmedAt,
this.amountRaw,
this.errorMessage,
this.statusMessage,
});
static const tableName = 'BridgeTransfer';
static Future<List<BridgeTransfer>> selectAll() async {
final database = db;
if (database == null) return [];
final rows = await database.query(
tableName,
orderBy: 'created_at DESC',
);
return rows.map(fromRow).toList();
}
static Future<void> insert(BridgeTransfer transfer) async {
final database = db;
if (database == null) return;
await database.insert(tableName, transfer.toRow());
}
static Future<void> update(BridgeTransfer transfer) async {
final database = db;
if (database == null) return;
await database.update(
tableName,
transfer.toRow(),
where: 'id = ?',
whereArgs: [transfer.id],
);
}
String id;
String walletId;
int sourceChainId;
int destinationChainId;
String tokenSymbol;
String tokenContract;
String amount;
String recipientAddress;
String sourceTxHash;
String status;
DateTime createdAt;
DateTime? updatedAt;
DateTime? confirmedAt;
String? amountRaw;
String? errorMessage;
String? statusMessage;
bool get isActive => status == 'submitted' || status == 'confirming' || status == 'initiated';
Map<String, Object?> toRow() {
return {
'id': id,
'wallet_id': walletId,
'source_chain_id': sourceChainId,
'destination_chain_id': destinationChainId,
'token_symbol': tokenSymbol,
'token_contract': tokenContract,
'amount': amount,
'recipient_address': recipientAddress,
'source_tx_hash': sourceTxHash,
'status': status,
'created_at': createdAt.millisecondsSinceEpoch,
'updated_at': updatedAt?.millisecondsSinceEpoch,
'confirmed_at': confirmedAt?.millisecondsSinceEpoch,
'amount_raw': amountRaw,
'error_message': errorMessage,
'status_message': statusMessage,
};
}
static int? _nullableInt(Object? v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
static int _parseInt(Object? v) => _nullableInt(v) ?? 0;
static DateTime _parseDateTime(Object? v) => DateTime.fromMillisecondsSinceEpoch(_parseInt(v));
static BridgeTransfer fromRow(Map<String, Object?> m) {
String? asStr(Object? v) => v as String?;
return BridgeTransfer(
id: m['id'] as String,
walletId: m['wallet_id'] as String,
sourceChainId: _parseInt(m['source_chain_id']),
destinationChainId: _parseInt(m['destination_chain_id']),
tokenSymbol: m['token_symbol'] as String,
tokenContract: m['token_contract'] as String,
amount: m['amount'] as String,
recipientAddress: m['recipient_address'] as String,
sourceTxHash: m['source_tx_hash'] as String,
status: m['status'] as String,
createdAt: _parseDateTime(m['created_at']),
updatedAt: _parseDateTime(m['updated_at']),
confirmedAt: _parseDateTime(m['confirmed_at']),
amountRaw: asStr(m['amount_raw']),
errorMessage: asStr(m['error_message']),
statusMessage: asStr(m['status_message']),
);
}
}
+10 -1
View File
@@ -4,7 +4,6 @@ import 'package:cake_wallet/entities/hash_wallet_identifier.dart';
import 'package:cake_wallet/entities/wallet_group.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WalletManager {
@@ -163,4 +162,14 @@ class WalletManager {
await _sharedPreferences.remove(oldNameKey);
}
}
String? getGroupName(WalletInfo walletInfo) {
try {
final groupKey = _resolveGroupKey(walletInfo);
final group = walletGroups.firstWhere((g) => g.groupKey == groupKey);
return group.groupName;
} catch (_) {
return null;
}
}
}
+110
View File
@@ -226,6 +226,22 @@ class CWEVM extends EVM {
@override
Web3Client? getWeb3Client(WalletBase wallet) => (wallet as EVMChainWallet).getWeb3Client();
@override
Future<bool?> getTransactionReceipt(WalletBase wallet, String txHash) async {
final client = getWeb3Client(wallet);
if (client == null) return null;
try {
final receipt = await client.getTransactionReceipt(txHash);
if (receipt == null) return null;
return receipt.status;
} catch (_) {
return null;
}
}
@override
String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress;
@@ -492,10 +508,24 @@ class CWEVM extends EVM {
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
currency: config.nativeCurrency,
))
.toList();
}
@override
ChainInfo? getChainInfoByChainId(int chainId) {
final config = _registry.getChainConfig(chainId);
if (config == null) return null;
return ChainInfo(
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
currency: config.nativeCurrency,
);
}
@override
ChainInfo? getCurrentChain(WalletBase wallet) {
if (wallet is EVMChainWallet) {
@@ -505,6 +535,7 @@ class CWEVM extends EVM {
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
currency: config.nativeCurrency,
);
}
return null;
@@ -542,6 +573,85 @@ class CWEVM extends EVM {
bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId);
@override
bool isUSDT0Token(WalletBase wallet, CryptoCurrency token) {
if (token is! Erc20Token) return false;
final chainId = getSelectedChainId(wallet);
if (chainId == null) return false;
return USDT0Config.isUSDT0Token(token, chainId);
}
@override
List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet) {
final currentChainId = getSelectedChainId(wallet);
if (currentChainId == null) return [];
final result = <ChainInfo>[];
for (final config in _registry.getAllChains()) {
if (USDT0Config.isChainSupported(config.chainId) && config.chainId != currentChainId) {
result.add(ChainInfo(
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
currency: config.nativeCurrency,
));
}
}
return result;
}
@override
Future<USDT0Quote> quoteUSDT0Transfer({
required WalletBase wallet,
required int sourceChainId,
required int destinationChainId,
required BigInt amount,
required String recipientAddress,
}) {
final evmWallet = wallet as EVMChainWallet;
final client = evmWallet.getWeb3Client();
if (client == null) {
throw StateError('Wallet not connected');
}
return USDT0Service.quoteCrossChainTransfer(
client: client,
sourceChainId: sourceChainId,
destinationChainId: destinationChainId,
amount: amount,
recipientAddress: recipientAddress,
);
}
@override
Future<PendingTransaction> executeUSDT0Transfer({
required WalletBase wallet,
required CryptoCurrency token,
required int sourceChainId,
required int destinationChainId,
required BigInt amount,
required String recipientAddress,
required USDT0Quote quote,
required TransactionPriority priority,
bool useBlinkProtection = true,
}) {
final evmWallet = wallet as EVMChainWallet;
final tokenErc20 = token as Erc20Token;
return USDT0Service.executeCrossChainTransfer(
wallet: evmWallet,
sourceChainId: sourceChainId,
destinationChainId: destinationChainId,
amount: amount,
recipientAddress: recipientAddress,
quote: quote,
token: tokenErc20,
priority: priority as EVMChainTransactionPriority,
useBlinkProtection: useBlinkProtection,
);
}
Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
WalletBase wallet,
TransactionPriority priority,
+1 -1
View File
@@ -362,10 +362,10 @@ Future<void> initialSetup({
powNodeSource: powNodes,
contactSource: contactSource,
tradesSource: tradesSource,
ordersSource: ordersSource,
templates: templates,
exchangeTemplates: exchangeTemplates,
transactionDescriptionBox: transactionDescriptions,
ordersSource: ordersSource,
anonpayInvoiceInfoSource: anonpayInvoiceInfo,
unspentCoinsInfoSource: unspentCoinsInfoSource,
payjoinSessionSource: payjoinSessionSource,
@@ -0,0 +1,305 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/utils/request_review_handler.dart';
import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class BridgeAmountPage extends StatefulWidget {
const BridgeAmountPage({
super.key,
required this.bridgeViewModel,
required this.bridgeHistoryViewModel,
required this.initialToken,
});
final BridgeViewModel bridgeViewModel;
final BridgeHistoryViewModel bridgeHistoryViewModel;
final CryptoCurrency initialToken;
@override
State<BridgeAmountPage> createState() => _BridgeAmountPageState();
}
class _BridgeAmountPageState extends State<BridgeAmountPage> {
late final TextEditingController _amountController;
BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
@override
void initState() {
super.initState();
bridgeViewModel.applyInitialBridgeToken(widget.initialToken);
bridgeViewModel.onBridgeSuccess = _showBridgeSuccessBottomSheet;
_amountController = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
bridgeViewModel.ensureFiatPriceForSelectedToken();
});
}
void _showBridgeSuccessBottomSheet() {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
final ctx = context;
if (!ctx.mounted) return;
await showModalBottomSheet<void>(
context: ctx,
isScrollControlled: true,
builder: (BuildContext bottomSheetContext) {
return InfoBottomSheet(
footerType: FooterType.doubleActionButton,
titleText: 'Bridge initiated!',
contentImage: 'assets/images/birthday_cake.png',
content: 'The bridging will take between 30 seconds and 3 '
'minutes to complete.',
doubleActionLeftButtonText: S.of(bottomSheetContext).close,
doubleActionRightButtonText: 'View history',
onLeftActionButtonPressed: () {
bridgeViewModel.clearBridgeSuccess();
Navigator.of(context, rootNavigator: true).pop();
RequestReviewHandler.requestReview();
},
onRightActionButtonPressed: () {
bridgeViewModel.clearBridgeSuccess();
Navigator.of(context).popUntil((route) => route.isFirst);
showMaterialModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => BridgeHistoryPage(widget.bridgeHistoryViewModel),
);
},
);
},
);
});
}
@override
void dispose() {
bridgeViewModel.onBridgeSuccess = null;
bridgeViewModel.setAmount('');
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return KeyboardHideOverlay(
unfocusOnTap: true,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
ModalTopBar(
title: 'Enter Amount',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context, rootNavigator: true).pop(),
trailingIcon: const Icon(Icons.calendar_month, size: 18),
onTrailingPressed: () {
Navigator.pushNamed(context, Routes.bridgeHistoryPage, arguments: widget.bridgeHistoryViewModel);
},
),
Expanded(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Observer(
builder: (_) {
final canNext = bridgeViewModel.canProceedToDestinationNetwork;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(flex: 2),
LayoutBuilder(
builder: (context, constraints) {
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: (constraints.maxWidth - 200)
.clamp(40.0, constraints.maxWidth),
minWidth: 40,
),
child: TextFormField(
controller: _amountController,
maxLines: 1,
onChanged: bridgeViewModel.setAmount,
autovalidateMode: AutovalidateMode.always,
validator: bridgeViewModel.decimalAmountValidator,
keyboardType: TextInputType.numberWithOptions(
signed: false,
decimal: true,
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp(r'^\d*[.,]?\d*$'),
),
],
textAlign: TextAlign.center,
decoration: InputDecoration(
isDense: true,
hintText: '0.00',
hintStyle: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
color: theme.colorScheme.onSurfaceVariant,
),
),
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 45,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: 8),
Text(
bridgeViewModel.selectedToken?.title ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 45,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
},
),
const SizedBox(height: 12),
Center(
child: Text(
bridgeViewModel.fiatAmountFormatted.isEmpty
? ''
: '~${bridgeViewModel.fiatAmountFormatted} ${bridgeViewModel.fiatCurrencyTitle}',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 22,
letterSpacing: -0.11,
),
),
),
if (bridgeViewModel.amountError != null) ...[
const SizedBox(height: 10),
Text(
bridgeViewModel.amountError!,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const Spacer(flex: 3),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: 'Available ',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
fontSize: 16,
letterSpacing: -0.08,
),
),
TextSpan(
text: '${bridgeViewModel.tokenBalanceFormatted} '
'${bridgeViewModel.selectedToken?.title ?? ''}',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w400,
fontSize: 16,
letterSpacing: -0.08,
),
),
],
),
),
),
const SizedBox(width: 10),
TextButton(
style: TextButton.styleFrom(
textStyle: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14,
letterSpacing: -0.07,
),
foregroundColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(80),
),
),
onPressed: () {
bridgeViewModel.setMaxAmount();
_amountController.text = bridgeViewModel.amount;
_amountController.selection = TextSelection.collapsed(
offset: _amountController.text.length,
);
},
child: Text(S.of(context).max),
),
const SizedBox(width: 10),
IgnorePointer(
ignoring: !canNext,
child: ModernButton(
size: 48,
backgroundColor: theme.colorScheme.primary,
iconColor: theme.colorScheme.onPrimary,
icon: Icon(Icons.arrow_forward, size: 25),
onPressed: () {
_amountController.clear();
Navigator.pushNamed(
context, Routes.bridgeDestinationNetworkPage, arguments: bridgeViewModel);
},
),
),
],
),
],
);
},
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,227 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/bridge/confirm_details_card.dart';
import 'package:cake_wallet/new-ui/widgets/bridge/network_path_pill.dart';
import 'package:cake_wallet/new-ui/widgets/confirm_swiper.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/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class BridgeConfirmSheet extends StatefulWidget {
const BridgeConfirmSheet(this.bridgeViewModel);
final BridgeViewModel bridgeViewModel;
@override
State<BridgeConfirmSheet> createState() => _BridgeConfirmSheetState();
}
class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
late final ReactionDisposer _successDisposer;
BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
@override
void initState() {
super.initState();
_successDisposer = reaction(
(_) => bridgeViewModel.bridgeSuccess,
(bool success) {
if (success && mounted) {
Navigator.of(context).maybePop();
}
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
bridgeViewModel.loadQuote();
bridgeViewModel.ensureFiatPriceForSelectedToken();
bridgeViewModel.ensureFiatPriceForNativeCurrency();
});
}
@override
void dispose() {
_successDisposer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Observer(
builder: (_) {
return ModalTopBar(
title: '',
leadingWidget: Row(
spacing: 8,
children: [
CakeImageWidget(
imageUrl: bridgeViewModel.selectedToken?.iconPath ?? '',
width: 36,
height: 36,
),
Text(
'Bridge',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 20,
letterSpacing: -0.1,
color: scheme.onSurface,
),
),
],
),
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
);
},
),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Observer(
builder: (_) {
if (bridgeViewModel.isQuoteLoading && bridgeViewModel.quote == null) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(
child: CircularProgressIndicator(),
),
);
}
if (bridgeViewModel.quoteError != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
children: [
Text(
bridgeViewModel.quoteError!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14,
letterSpacing: -0.07,
color: scheme.error,
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => bridgeViewModel.loadQuote(),
child: Text(
S.of(context).try_again,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16,
letterSpacing: -0.08,
color: scheme.onPrimary,
),
),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 24,
children: [
Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
Text(
bridgeViewModel.amountDisplayFormatted,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w400,
color: scheme.onSurface,
),
),
Text(
bridgeViewModel.selectedToken?.title ?? '',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w400,
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
Text(
'${bridgeViewModel.fiatCurrencyTitle} ${bridgeViewModel.fiatAmountFormatted}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
letterSpacing: -0.1,
),
),
],
),
NetworkPathPill(
sourceChainName: bridgeViewModel.wallet.type.name,
destChainName: bridgeViewModel.destinationChainInfo?.name ?? '',
),
ConfirmDetailsCard(bridgeViewModel: bridgeViewModel),
if (bridgeViewModel.executeError != null) ...[
Text(
bridgeViewModel.executeError!,
textAlign: TextAlign.center,
style: TextStyle(color: scheme.error),
),
],
const SizedBox(height: 8),
],
);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Observer(
builder: (_) {
if (bridgeViewModel.isExecuting) {
return const LoadingBottomWidget(
text: 'Bridging...',
);
}
final canSwipe = bridgeViewModel.quote != null && !bridgeViewModel.isQuoteLoading;
return IgnorePointer(
ignoring: !canSwipe,
child: Opacity(
opacity: canSwipe ? 1 : 0.45,
child: ConfirmSwiper(
onConfirmed: () {
bridgeViewModel.executeBridge();
},
swiperText: 'Swipe to bridge',
),
),
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,160 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
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/screens/trade_details/track_trade_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
import 'package:cw_core/generate_name.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BridgeDetailPage extends StatelessWidget {
BridgeDetailPage({super.key, required this.viewModel});
final BridgeDetailsViewModel viewModel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
minChildSize: 0.25,
maxChildSize: 0.9,
snap: true,
snapSizes: const [0.9],
builder: (context, controller) {
return SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
ModalTopBar(
title: "Bridge Transfer Details",
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
NewListSections(
sections: {
"": viewModel.items
.where((item) => item is! TrackTradeListItem)
.map((item) {
return ListItemRegularRow(
onTap: () {
Clipboard.setData(ClipboardData(text: item.value));
showBar<void>(context, S.of(context).copied_to_clipboard);
},
showArrow: false,
keyValue: item.title,
label: item.title,
trailingWidget: _buildTrailingWidget(item, context),
bottomWidget: _buildBottomWidget(item, context),
);
}).toList(),
},
),
SizedBox(height: 16),
NewListSections(sections: {
"": viewModel.items
.where((item) => item is TrackTradeListItem)
.map((item) {
return ListItemRegularRow(
keyValue: "view tx on",
label: item.title,
onTap: () => (item as TrackTradeListItem).onTap(),
foregroundColor: Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8,
);
}).toList()
}),
],
),
),
),
SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
],
),
),
),
),
);
},
);
}
Widget _buildBottomWidget(TransactionDetailsListItem item, BuildContext context) {
if (item is AddressListItem) {
return AddressFormatter.buildSegmentedAddress(
address: item.value,
evenTextStyle: TextStyle(
fontSize: 12,
fontFamily: "IBM Plex Mono",
color: Theme.of(context).colorScheme.onSurface));
}
final isCompleted = item.value.contains("Completed");
final isInitiated = item.value.contains("initiated");
if (isCompleted || isInitiated) {
return Row(
children: [
Container(
height: 8,
width: 8,
decoration: BoxDecoration(
color: isCompleted ? CustomThemeColors.syncGreen : CustomThemeColors.syncYellow,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 1.5,
),
),
),
SizedBox(width: 8),
Text(
item.value,
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
);
}
return SizedBox.shrink();
}
Widget _buildTrailingWidget(TransactionDetailsListItem item, BuildContext context) {
final isCompleted = item.value.contains("Completed");
final isInitiated = item.value.contains("initiated");
if (item is AddressListItem || isCompleted || isInitiated) {
return SizedBox.shrink();
}
return Text(
item.value.capitalized(),
style: TextStyle(
fontWeight: FontWeight.w400,
fontFamily: 'Wix Madefor Text',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
@@ -0,0 +1,117 @@
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/new-ui/widgets/bridge/transfer_history_row.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class BridgeHistoryPage extends StatelessWidget {
BridgeHistoryPage(this.viewModel);
final BridgeHistoryViewModel viewModel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
ModalTopBar(
title: "Bridge history",
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Observer(
builder: (_) {
if (viewModel.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
"No bridge transfers yet.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
final active = viewModel.activeTransfers;
final past = viewModel.pastTransfers;
final items = <Widget>[];
if (active.isNotEmpty) {
items.add(
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"In progress",
style: Theme.of(context).textTheme.titleSmall,
),
),
);
for (final transfer in active) {
items.add(
TransferHistoryRow(
transfer: transfer,
onTap: () {
final page = getIt.get<BridgeDetailPage>(param1: transfer);
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => page,
);
},
),
);
}
}
if (past.isNotEmpty) {
items.add(
Padding(
padding: const EdgeInsets.only(bottom: 8.0, top: 16),
child: Text(
"Past",
style: Theme.of(context).textTheme.titleSmall,
),
),
);
for (final transfer in past) {
items.add(
TransferHistoryRow(
transfer: transfer,
onTap: () {
final page = getIt.get<BridgeDetailPage>(param1: transfer);
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => page,
);
},
),
);
}
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
children: items,
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,98 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/widgets/new_list_row/list_item_regular_row_widget.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cw_core/generate_name.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class BridgeNetworkPage extends StatelessWidget {
const BridgeNetworkPage(this.bridgeViewModel);
final BridgeViewModel bridgeViewModel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return KeyboardHideOverlay(
unfocusOnTap: true,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
ModalTopBar(
title: 'Destination Network',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context).pop(),
),
SizedBox(height: 48),
Padding(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 16),
child: Text(
'Select what Network to transfer your assets to',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(height: 24),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
children: [
Observer(
builder: (_) {
return NewListSections(
sections: {
'': bridgeViewModel.availableDestinationChains.map(
(chain) {
final chainName = chain.name;
return ListItemRegularRow(
iconPath: 'assets/images/crypto/${chainName.toLowerCase()}.webp',
keyValue: chain.chainId.toString(),
label: chainName,
onTap: () {
bridgeViewModel.setDestinationChain(chain.chainId);
Navigator.pushNamed(context, Routes.bridgeReceivingWalletPage, arguments: bridgeViewModel);
},
);
},
).toList(),
},
);
},
),
SizedBox(height: 24),
Observer(
builder: (_) {
final src = bridgeViewModel.wallet.type.name;
return ListItemRegularRowWidget(
isFirstInSection: true,
isLastInSection: true,
keyValue: 'sending_from',
label: 'Sending from',
trailingIconPath: 'assets/new-ui/chain_badges/${src.toLowerCase()}.svg',
trailingText: src.capitalized(),
);
},
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,160 @@
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class BridgeReceiveAddressInputPage extends StatefulWidget {
const BridgeReceiveAddressInputPage({super.key, required this.bridgeViewModel});
final BridgeViewModel bridgeViewModel;
@override
State<BridgeReceiveAddressInputPage> createState() => _BridgeReceiveAddressInputPageState();
}
class _BridgeReceiveAddressInputPageState extends State<BridgeReceiveAddressInputPage> {
BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
late final TextEditingController _controller;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController();
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return KeyboardHideOverlay(
unfocusOnTap: true,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
ModalTopBar(
title: 'Input Receive Address',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context).pop(),
),
SizedBox(height: 48),
Expanded(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Observer(
builder: (_) {
final chainName = bridgeViewModel.destinationChainInfo?.name;
return Column(
children: [
Text(
'Make sure it is compatible with:',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: -0.07,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CakeImageWidget(
imageUrl:
'assets/new-ui/chain_badges/${chainName?.toLowerCase()}.svg',
width: 24,
height: 24,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
chainName ?? '',
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
letterSpacing: -0.07,
),
),
],
),
],
);
},
),
const SizedBox(height: 24),
NewSendAddressInput(
hintText: 'Enter Address',
addressController: _controller,
focusNode: _focusNode,
selectedCurrency: bridgeViewModel.wallet.currency,
onEditingComplete: () {},
validator: AddressValidator(type: bridgeViewModel.destinationChainInfo!.currency),
),
const Spacer(),
Observer(
builder: (_) {
return FilledButton(
onPressed: () {
final overlayCtx = Navigator.of(context).overlay?.context;
bridgeViewModel.setRecipientAddress(_controller.text.trim());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (overlayCtx != null && overlayCtx.mounted) {
showMaterialModalBottomSheet<void>(
context: overlayCtx,
backgroundColor: Colors.transparent,
builder: (ctx) =>
BridgeConfirmSheet(bridgeViewModel),
);
}
});
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(S.of(context).continue_text),
);
},
),
const SizedBox(height: 12),
],
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,264 @@
import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_receive_address_input_page.dart';
import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.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:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class BridgeReceivingWalletPage extends StatefulWidget {
const BridgeReceivingWalletPage(this.bridgeViewModel);
final BridgeViewModel bridgeViewModel;
@override
State<BridgeReceivingWalletPage> createState() => _BridgeReceivingWalletPageState();
}
class _BridgeReceivingWalletPageState extends State<BridgeReceivingWalletPage> {
BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
@override
void initState() {
super.initState();
bridgeViewModel.loadReceivingWalletOptions();
}
@override
Widget build(BuildContext context) {
return KeyboardHideOverlay(
unfocusOnTap: true,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
ModalTopBar(
title: 'Receiving Wallet',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
onLeadingPressed: () => Navigator.of(context).pop(),
),
const SizedBox(height: 48),
Expanded(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Observer(
builder: (_) {
final chain = bridgeViewModel.destinationChainInfo;
final chainName = chain?.name ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Center(
child: CakeImageWidget(
borderRadius: 8,
imageUrl: chainName.isNotEmpty
? 'assets/images/crypto/${chainName.toLowerCase()}.webp'
: null,
width: 72,
height: 72,
),
),
const SizedBox(height: 20),
Text(
'Select a $chainName wallet or address to send '
'the bridged USDT0 to.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14,
letterSpacing: -0.07,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
if (bridgeViewModel.isBridgeReceivingWalletListLoading)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: _receivingListRows(context),
),
),
],
);
},
),
),
),
),
],
),
),
);
}
List<Widget> _receivingListRows(BuildContext context) {
final options = bridgeViewModel.bridgeReceivingWalletOptions;
final rows = <Widget>[];
for (int i = 0; i < options.length; i++) {
final option = options[i];
if (i > 0) rows.add(const SizedBox(height: 12));
rows.add(
_BridgeReceivingWalletRow(
option: option,
onTap: () {
bridgeViewModel.setRecipientAddress(option.address, destWalletName: option.name);
showMaterialModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => BridgeConfirmSheet(bridgeViewModel),
);
},
),
);
}
if (options.isNotEmpty) rows.add(const SizedBox(height: 12));
rows.add(
ListItemStyleWrapper(
isFirstInSection: true,
isLastInSection: true,
height: 48,
contentPadding: const EdgeInsets.all(12),
onTap: () {
showMaterialModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => BridgeReceiveAddressInputPage(bridgeViewModel: bridgeViewModel),
);
},
builder: (ctx, textStyle, _) {
final scheme = Theme.of(ctx).colorScheme;
return Row(
children: [
Icon(
Icons.edit_outlined,
size: 24,
color: scheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Input an Address',
style: textStyle.copyWith(
color: scheme.primary,
fontSize: 15,
fontWeight: FontWeight.w500,
letterSpacing: -0.3,
),
),
),
],
);
},
),
);
return rows;
}
}
class _BridgeReceivingWalletRow extends StatelessWidget {
const _BridgeReceivingWalletRow({
required this.option,
required this.onTap,
});
final BridgeReceivingWalletOption option;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final hasGroupRow = option.groupLabel != null;
return ListItemStyleWrapper(
onTap: onTap,
isFirstInSection: true,
isLastInSection: true,
height: hasGroupRow ? 62.0 : 48.0,
builder: (ctx, textStyle, labelStyle) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.name,
style: textStyle.copyWith(letterSpacing: -0.07),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasGroupRow) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.account_balance_wallet_outlined,
size: 16,
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
option.groupLabel!,
style: labelStyle.copyWith(
fontSize: 12,
letterSpacing: -0.06,
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (option.isCurrent) ...[
const SizedBox(width: 8),
Text(
'Current',
style: textStyle.copyWith(
fontSize: 12,
color: Theme.of(ctx).colorScheme.primary,
letterSpacing: -0.06,
),
),
],
],
),
],
],
),
),
CakeImageWidget(
imageUrl: 'assets/new-ui/arrow_forward.svg',
height: 16,
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
],
);
},
);
}
}
@@ -0,0 +1,62 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/bridge/expandable_details_card.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class ConfirmDetailsCard extends StatelessWidget {
const ConfirmDetailsCard({required this.bridgeViewModel});
final BridgeViewModel bridgeViewModel;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Observer(
builder: (_) {
final feeParts = <String>[
bridgeViewModel.quoteNativeFeeFormattedForDisplay,
bridgeViewModel.quoteNativeFiatFeeFormattedForDisplay,
];
final feeLine = feeParts.join(' ');
return Container(
decoration: BoxDecoration(
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
ExpandableBridgeDetailRow(
label: S.of(context).from,
title: bridgeViewModel.wallet.name,
address: bridgeViewModel.sourceAddress,
showChevron: true,
),
Divider(
height: 1,
color: scheme.surfaceContainerHigh,
),
ExpandableBridgeDetailRow(
label: S.of(context).to,
title: bridgeViewModel.destinationWalletName ?? bridgeViewModel.recipientAddress.trim(),
address: bridgeViewModel.recipientAddress.trim(),
showChevron: bridgeViewModel.destinationWalletName != null,
),
Divider(
height: 1,
color: scheme.surfaceContainerHigh,
),
ExpandableBridgeDetailRow(
label: S.of(context).fee,
title: feeLine,
showChevron: false,
),
],
),
);
},
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
class ExpandableBridgeDetailRow extends StatefulWidget {
const ExpandableBridgeDetailRow({
required this.label,
required this.title,
this.address,
this.showChevron = false,
});
final String label;
final String title;
final String? address;
final bool showChevron;
@override
State<ExpandableBridgeDetailRow> createState() => ExpandableBridgeDetailRowState();
}
class ExpandableBridgeDetailRowState extends State<ExpandableBridgeDetailRow> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final showChevron = widget.showChevron;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: showChevron ? () => setState(() => _expanded = !_expanded) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w400,
fontSize: 14,
letterSpacing: -0.07,
),
),
const SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
widget.title,
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14,
letterSpacing: -0.07,
color: scheme.onSurfaceVariant,
),
),
),
if (showChevron) ...[
const SizedBox(width: 2),
AnimatedRotation(
turns: _expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: Icon(
Icons.keyboard_arrow_down,
size: 22,
color: scheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: showChevron && _expanded
? Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Align(
alignment: Alignment.centerRight,
child: SelectableText(
widget.address ?? '',
textAlign: TextAlign.end,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
),
)
: const SizedBox.shrink(),
),
],
);
}
}
@@ -0,0 +1,89 @@
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cw_core/generate_name.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class NetworkPathPill extends StatelessWidget {
const NetworkPathPill({
required this.sourceChainName,
required this.destChainName,
this.showBackground = true,
});
final String sourceChainName;
final String destChainName;
final bool showBackground;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final body = Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
CakeImageWidget(
imageUrl: 'assets/new-ui/chain_badges/${sourceChainName.toLowerCase()}.svg',
width: 24,
height: 24,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Flexible(
child: Text(
sourceChainName.capitalized(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w400,
fontSize: 16,
letterSpacing: -0.08,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.arrow_forward,
size: 16,
color: scheme.primary,
),
),
CakeImageWidget(
imageUrl: 'assets/new-ui/chain_badges/${destChainName.toLowerCase()}.svg',
width: 24,
height: 24,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Flexible(
child: Text(
destChainName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w400,
fontSize: 16,
letterSpacing: -0.08,
),
),
),
],
);
return Observer(
builder: (_) {
return showBackground
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(80),
),
child: body,
)
: body;
},
);
}
}
@@ -0,0 +1,180 @@
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class TransferHistoryRow extends StatelessWidget {
const TransferHistoryRow({
required this.transfer,
required this.onTap,
super.key,
});
final BridgeTransfer transfer;
final VoidCallback onTap;
String _statusLabel(String status) {
switch (status) {
case 'submitted':
return "Submitted";
case 'confirming':
return "Confirming on source";
case 'initiated':
return "Bridge initiated";
case 'completed':
return "Completed";
case 'failed':
return "Failed";
default:
return status;
}
}
Color _statusColor(BuildContext context, String status) {
switch (status) {
case 'completed':
return CustomThemeColors.syncGreen;
case 'failed':
return Theme.of(context).colorScheme.error;
case 'submitted':
case 'confirming':
case 'initiated':
default:
return CustomThemeColors.syncYellow;
}
}
@override
Widget build(BuildContext context) {
final sourceChainName = evm?.getChainInfoByChainId(transfer.sourceChainId)?.name;
final destChainName = evm?.getChainInfoByChainId(transfer.destinationChainId)?.name;
final formattedDate = DateFormat('HH:mm').format(transfer.createdAt);
final statusText = _statusLabel(transfer.status);
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
margin: const EdgeInsets.only(bottom: 16),
width: double.infinity,
height: 72,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surfaceContainerHigh,
Theme.of(context).colorScheme.surfaceContainer,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.all(Radius.circular(20)),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 42,
width: 42,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
CakeImageWidget(
imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp',
width: 28,
height: 28,
),
Positioned(
top: 14,
left: 12,
child: CakeImageWidget(
imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp',
width: 28,
height: 28,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'$sourceChainName → $destChainName',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
'${transfer.amount} ${transfer.tokenSymbol}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: [
Container(
height: 8,
width: 8,
decoration: BoxDecoration(
color: _statusColor(context, transfer.status),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 1.5,
),
),
),
SizedBox(width: 8),
Text(
statusText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
Text(
formattedDate,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/modal_navigator.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
import 'package:cake_wallet/new-ui/pages/receive_page.dart';
import 'package:cake_wallet/new-ui/pages/send_page.dart';
import 'package:cake_wallet/new-ui/pages/swap_page.dart';
@@ -31,6 +32,7 @@ class AssetDetailsModal extends StatelessWidget {
required this.mode,
required this.wallet,
required this.showSwap,
required this.showBridgeButton,
this.asset});
final String title;
@@ -44,6 +46,7 @@ class AssetDetailsModal extends StatelessWidget {
final String chainIconPath;
final WalletBase wallet;
final bool showSwap;
final bool showBridgeButton;
final AssetDetailsModalModes mode;
@override
@@ -59,7 +62,7 @@ class AssetDetailsModal extends StatelessWidget {
title: "",
trailingIcon: Icon(Icons.close),
onTrailingPressed: Navigator.of(context).pop,
padding: EdgeInsets.only(top:12,right:18),
padding: EdgeInsets.only(top: 12, right: 18),
),
SafeArea(
child: Column(
@@ -125,21 +128,20 @@ class AssetDetailsModal extends StatelessWidget {
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
if(asset != null)
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999999)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
child: Text(
asset?.title??"",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
if (asset != null)
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999999)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
child: Text(
asset?.title ?? "",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
),
)
)
],
),
if (subtitle.isNotEmpty)
@@ -147,11 +149,15 @@ class AssetDetailsModal extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
if(chainIconPath.isNotEmpty)
CakeImageWidget(
imageUrl: chainIconPath,
width:16,height:16,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn),
),
if (chainIconPath.isNotEmpty)
CakeImageWidget(
imageUrl: chainIconPath,
width: 16,
height: 16,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn),
),
Text(
subtitle,
style: TextStyle(
@@ -227,6 +233,18 @@ class AssetDetailsModal extends StatelessWidget {
),
),
),
if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
AssetDetailsModalBottomButton(
iconPath: "assets/new-ui/exchange.svg",
title: S.of(context).swap,
onPressed: () => openPage<NewSwapPage>(context, param2: asset),
),
if (showBridgeButton)
AssetDetailsModalBottomButton(
iconPath: "assets/new-ui/bridge.svg",
title: "Bridge",
onPressed: () => openPage<BridgeAmountPage>(context, param1: asset),
),
AssetDetailsModalBottomButton(
iconPath: "assets/new-ui/receive.svg",
title: S.of(context).receive,
@@ -240,12 +258,6 @@ class AssetDetailsModal extends StatelessWidget {
openPage<NewReceivePage>(context, param2: asset);
},
),
if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
AssetDetailsModalBottomButton(
iconPath: "assets/new-ui/exchange.svg",
title: S.of(context).swap,
onPressed: () => openPage<NewSwapPage>(context, param2: asset),
),
],
),
SizedBox()
@@ -14,6 +14,7 @@ class AssetTile extends StatelessWidget {
required this.balance,
required this.chainIconPath,
this.showSecondary = false,
this.showBridgeButton = false,
this.title,
this.trailingText,
this.modalMode = AssetDetailsModalModes.normal,
@@ -22,6 +23,7 @@ class AssetTile extends StatelessWidget {
final BalanceRecord balance;
final bool showSecondary;
final bool showSwap;
final bool showBridgeButton;
final String chainIconPath;
final String? title;
final String? trailingText;
@@ -42,6 +44,7 @@ class AssetTile extends StatelessWidget {
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
@@ -59,6 +59,7 @@ class AssetsSection extends StatelessWidget {
final balance = dashboardViewModel.balanceViewModel.formattedBalances.elementAt(index);
return AssetTile(
showSwap: dashboardViewModel.isEnabledSwapAction,
showBridgeButton: dashboardViewModel.showBridge(balance.asset),
balance: balance,
wallet: dashboardViewModel.wallet,
isFirst: index == 0,
@@ -23,6 +23,7 @@ class NewSendAddressInput extends StatefulWidget {
this.validator,
this.focusNode,
this.displayName,
this.hintText,
});
final TextEditingController addressController;
@@ -36,7 +37,8 @@ class NewSendAddressInput extends StatefulWidget {
final bool bottomPadding;
final FormFieldValidator<String>? validator;
final FocusNode? focusNode;
final String? hintText;
@override
State<NewSendAddressInput> createState() => _NewSendAddressInputState();
}
@@ -96,7 +98,7 @@ class _NewSendAddressInputState extends State<NewSendAddressInput> {
},
controller: widget.addressController,
decoration: InputDecoration(
hintText: S.of(context).search_or_enter,
hintText: widget.hintText ?? S.of(context).search_or_enter,
errorMaxLines: 3,
),
),
+21 -1
View File
@@ -4,6 +4,10 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart';
import 'package:cake_wallet/core/new_wallet_arguments.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
import 'package:cake_wallet/new-ui/pages/lightning_username_page.dart';
@@ -89,7 +93,6 @@ import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart';
import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart';
import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart';
import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart';
import 'package:cake_wallet/src/screens/send/send_page.dart';
import 'package:cake_wallet/src/screens/send/send_template_page.dart';
import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart';
import 'package:cake_wallet/src/screens/settings/background_sync_page.dart';
@@ -136,6 +139,8 @@ import 'package:cake_wallet/src/screens/welcome/welcome_page.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/utils/payment_request.dart';
import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
@@ -1035,6 +1040,21 @@ Route<dynamic> createRoute(RouteSettings settings) {
builder: (_) => getIt.get<DEuroSavingsPage>(),
);
case Routes.bridgeHistoryPage:
return handleRouteWithPlatformAwareness(
(context) => BridgeHistoryPage(settings.arguments as BridgeHistoryViewModel),
);
case Routes.bridgeDestinationNetworkPage:
return handleRouteWithPlatformAwareness(
(context) => BridgeNetworkPage(settings.arguments as BridgeViewModel),
);
case Routes.bridgeReceivingWalletPage:
return handleRouteWithPlatformAwareness(
(context) => BridgeReceivingWalletPage(settings.arguments as BridgeViewModel),
);
default:
return MaterialPageRoute<void>(
builder: (_) => Scaffold(
+4
View File
@@ -144,4 +144,8 @@ class Routes {
static const lightningUsernamePage = "/lightning_username_page";
static const aboutPage = "/about_page";
static const bridgeHistoryPage = '/bridge_history_page';
static const bridgeDestinationNetworkPage = '/bridge_destination_network_page';
static const bridgeReceivingWalletPage = '/bridge_receiving_wallet_page';
}
@@ -9,6 +9,7 @@ class ListItemStyleWrapper extends StatelessWidget {
this.backgroundColor,
this.onTap,
this.iconPath,
this.contentPadding,
this.height,
});
@@ -19,10 +20,10 @@ class ListItemStyleWrapper extends StatelessWidget {
final VoidCallback? onTap;
final Color? backgroundColor;
final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder;
final EdgeInsets? contentPadding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textStyle = TextStyle(
@@ -70,15 +71,16 @@ class ListItemStyleWrapper extends StatelessWidget {
child: Container(height: 1, color: theme.colorScheme.outlineVariant),
),
)
else if(!isLastInSection) Container(
else if (!isLastInSection)
Container(
color: theme.colorScheme.surfaceContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(height: 1, color: theme.colorScheme.outlineVariant),
),
)
],
),
);
],
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:mobx/mobx.dart';
part 'bridge_transfers_store.g.dart';
class BridgeTransfersStore = BridgeTransfersStoreBase with _$BridgeTransfersStore;
abstract class BridgeTransfersStoreBase with Store {
BridgeTransfersStoreBase() : bridgeTransfers = [] {
updateList();
}
@observable
List<BridgeTransfer> bridgeTransfers;
@action
Future<void> updateList() async {
bridgeTransfers = await BridgeTransfer.selectAll();
}
@action
Future<void> addTransfer(BridgeTransfer transfer) async {
try {
await BridgeTransfer.insert(transfer);
await updateList();
} catch (_) {}
}
@action
Future<void> updateTransfer(BridgeTransfer transfer) async {
try {
await BridgeTransfer.update(transfer);
await updateList();
} catch (_) {}
}
@computed
List<BridgeTransfer> get activeTransfers =>
bridgeTransfers.where((b) => b.isActive).toList(growable: false);
@computed
List<BridgeTransfer> get pastTransfers =>
bridgeTransfers.where((b) => !b.isActive).toList(growable: false);
void dispose() {}
}
@@ -0,0 +1,17 @@
import 'package:cw_core/wallet_info.dart';
class BridgeReceivingWalletOption {
const BridgeReceivingWalletOption({
required this.walletInfo,
required this.isCurrent,
this.groupLabel,
});
final WalletInfo walletInfo;
final bool isCurrent;
final String? groupLabel;
String get name => walletInfo.name;
String get address => walletInfo.address;
}
@@ -0,0 +1,719 @@
import 'dart:async';
import 'package:cake_wallet/core/amount_parsing_proxy.dart';
import 'package:cake_wallet/core/amount_validator.dart';
import 'package:cake_wallet/core/fiat_conversion_service.dart';
import 'package:cake_wallet/core/utilities.dart';
import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
import 'package:cake_wallet/entities/fiat_api_mode.dart';
import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/entities/wallet_manager.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import 'package:cake_wallet/core/layerzero_scan_service.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/bridge_transfers_store.dart';
import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:mobx/mobx.dart';
part 'bridge_view_model.g.dart';
class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel;
abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store {
BridgeViewModelBase({
required AppStore appStore,
required this.bridgeTransfersStore,
required this.walletManager,
required this.fiatConversionStore,
required this.settingsStore,
}) : _appStore = appStore,
super(appStore: appStore);
final AppStore _appStore;
AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
void Function()? onBridgeSuccess;
final Map<String, Completer<void>> _pollingCancellers = {};
final BridgeTransfersStore bridgeTransfersStore;
final WalletManager walletManager;
final FiatConversionStore fiatConversionStore;
final SettingsStore settingsStore;
@observable
ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
ObservableList<BridgeReceivingWalletOption>();
@observable
bool isBridgeReceivingWalletListLoading = false;
@observable
CryptoCurrency? selectedToken;
@observable
int? destinationChainId;
@observable
String amount = '';
@observable
String recipientAddress = '';
@observable
String? destinationWalletName;
@observable
USDT0Quote? quote;
@observable
bool isQuoteLoading = false;
@observable
String? quoteError;
@observable
bool isExecuting = false;
@observable
String? executeError;
@observable
bool bridgeSuccess = false;
@observable
BridgeTransfer? lastCreatedBridgeTransfer;
@computed
int? get sourceChainId => evm!.getSelectedChainId(wallet);
@computed
String get sourceAddress => wallet.walletAddresses.address;
@computed
List<ChainInfo> get availableDestinationChains {
if (!isEVMCompatibleChain(wallet.type)) return [];
return evm!.getUSDT0DestinationChains(wallet);
}
@computed
List<Erc20Token> get availableUSDT0Tokens {
if (!isEVMCompatibleChain(wallet.type)) return [];
final tokens = wallet.balance.keys.whereType<Erc20Token>();
return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false);
}
@computed
ChainInfo? get destinationChainInfo {
if (destinationChainId == null) return null;
return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId);
}
@computed
String get tokenBalanceFormatted {
final token = selectedToken;
if (token is! Erc20Token) return '0.00';
return amountParsingProxy.getDisplayCryptoStringFromBigInt(
selectedTokenBalance,
token,
);
}
@computed
String get amountDisplayFormatted {
if (amount.isEmpty) return '';
final token = selectedToken;
if (token is! Erc20Token) return amount.replaceAll(',', '.');
return amountParsingProxy.getDisplayCryptoAmount(
amount.replaceAll(',', '.'),
token,
);
}
DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
currency: selectedToken!,
isAutovalidate: true,
);
@computed
String get fiatAmountFormatted {
if (amount.isEmpty) return '';
final token = selectedToken;
if (token is! Erc20Token) return '';
final price = fiatConversionStore.prices[token];
if (price == null) return '';
final forFiat = amountParsingProxy.getDisplayCryptoAmount(
amount.replaceAll(',', '.'),
token,
);
return calculateFiatAmount(
price: price,
cryptoAmount: forFiat,
);
}
@computed
String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
@computed
String get quoteNativeFee {
if (quote == null) return '—';
final cur = wallet.currency;
return amountParsingProxy.getDisplayCryptoStringFromBigInt(
quote!.nativeFee,
cur,
);
}
@computed
String get quoteNativeFeeFormattedForDisplay {
if (quoteNativeFee.isEmpty) return '';
return '${quoteNativeFee} ${wallet.currency.title}';
}
@computed
String get quoteNativeFiatFeeFormattedForDisplay {
if (quote == null || quoteNativeFee.isEmpty) return '';
final price = fiatConversionStore.prices[wallet.currency];
if (price == null) return '';
final fiatFeeFormatted = calculateFiatAmount(
price: price,
cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
quoteNativeFee.replaceAll(',', '.'),
wallet.currency,
),
);
return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)';
}
@computed
bool get canProceedToDestinationNetwork {
if (amount.isEmpty) return false;
if (selectedToken == null || selectedToken is! Erc20Token) return false;
if (amountError != null) return false;
final token = selectedToken as Erc20Token;
final validAmount = amountParsingProxy.tryParseCryptoString(
amount.replaceAll(',', '.'),
token,
);
return validAmount != null && validAmount > BigInt.zero;
}
@action
void applyInitialBridgeToken(CryptoCurrency asset) {
for (final t in availableUSDT0Tokens) {
if (t == asset) {
setSelectedToken(t);
break;
}
}
}
@action
void setDestinationChain(int chainId) {
destinationChainId = chainId;
_clearQuoteState();
}
@action
void setSelectedToken(CryptoCurrency token) {
selectedToken = token;
_clearQuoteState();
}
@action
void setAmount(String value) {
amount = value;
_clearQuoteState();
}
@action
void setMaxAmount() {
final token = selectedToken;
if (token is! Erc20Token) return;
if (selectedTokenBalance == BigInt.zero) {
setAmount('');
return;
}
setAmount(
amountParsingProxy.getDisplayCryptoStringFromBigInt(
selectedTokenBalance,
token,
),
);
}
@action
void setRecipientAddress(String value, {String? destWalletName}) {
recipientAddress = value;
destinationWalletName = destWalletName;
_clearQuoteState();
}
void _clearQuoteState() {
quote = null;
quoteError = null;
executeError = null;
}
Future<void> _ensureFiatPriceFor(CryptoCurrency crypto) async {
if (fiatConversionStore.prices[crypto] != null) return;
final p = await FiatConversionService.fetchPrice(
crypto: crypto,
fiat: settingsStore.fiatCurrency,
torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
);
runInAction(() {
fiatConversionStore.prices[crypto] = p;
});
}
@action
Future<void> ensureFiatPriceForSelectedToken() async {
await _ensureFiatPriceFor(selectedToken!);
}
@action
Future<void> ensureFiatPriceForNativeCurrency() async {
await _ensureFiatPriceFor(wallet.currency);
}
@computed
BigInt get selectedTokenBalance {
final bal = wallet.balance[selectedToken];
if (bal is EVMChainERC20Balance) return bal.balance;
return BigInt.zero;
}
@computed
String? get amountError {
if (selectedToken == null || amount.isEmpty) return null;
if (selectedToken is! Erc20Token) return null;
final token = selectedToken as Erc20Token;
final amountBigInt = amountParsingProxy.tryParseCryptoString(
amount.replaceAll(',', '.'),
token,
);
if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
if (amountBigInt > selectedTokenBalance) {
return 'Insufficient balance for ${token.title} token.';
}
return null;
}
@action
Future<void> loadReceivingWalletOptions() async {
if (!isEVMCompatibleChain(wallet.type)) return;
final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
isBridgeReceivingWalletListLoading = true;
try {
await walletManager.updateWalletGroups();
final all = await WalletInfo.getAll();
if (destWalletType == null) {
bridgeReceivingWalletOptions.clear();
return;
}
final filtered =
all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList();
final options = <BridgeReceivingWalletOption>[];
for (final wi in filtered) {
final isCurrent = wi.name == wallet.name;
options.add(
BridgeReceivingWalletOption(
walletInfo: wi,
isCurrent: isCurrent,
groupLabel: walletManager.getGroupName(wi),
),
);
}
bridgeReceivingWalletOptions
..clear()
..addAll(options);
} finally {
isBridgeReceivingWalletListLoading = false;
}
}
String? missingBridgeFieldsMessage() {
final src = sourceChainId;
final dst = destinationChainId;
final token = selectedToken;
if (src == null ||
dst == null ||
token == null ||
amount.isEmpty ||
recipientAddress.trim().isEmpty) {
return 'Fill all fields';
}
return null;
}
({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) {
final amountBigInt = amountParsingProxy.tryParseCryptoString(
amount.replaceAll(',', '.'),
token,
);
if (amountBigInt == null || amountBigInt == BigInt.zero) {
return (error: 'Invalid amount', parsedAmount: null);
}
if (amountBigInt > selectedTokenBalance) {
return (
error: 'Insufficient balance for ${token.title} token.',
parsedAmount: null,
);
}
return (error: null, parsedAmount: amountBigInt);
}
@action
Future<void> loadQuote() async {
final missing = missingBridgeFieldsMessage();
if (missing != null) {
quoteError = missing;
return;
}
final token = selectedToken!;
if (token is! Erc20Token) return;
final check = _parseAndValidateAmount(token);
if (check.error != null) {
quoteError = check.error;
return;
}
final src = sourceChainId!;
final dst = destinationChainId!;
final amountBigInt = check.parsedAmount!;
isQuoteLoading = true;
quoteError = null;
quote = null;
executeError = null;
try {
quote = await evm!.quoteUSDT0Transfer(
wallet: wallet,
sourceChainId: src,
destinationChainId: dst,
amount: amountBigInt,
recipientAddress: recipientAddress.trim(),
);
} catch (e) {
quoteError = e.toString();
} finally {
isQuoteLoading = false;
}
}
@action
Future<void> executeBridge() async {
if (quote == null) {
executeError = 'Get a quote first';
return;
}
final missing = missingBridgeFieldsMessage();
if (missing != null) {
executeError = missing;
return;
}
final token = selectedToken!;
if (token is! Erc20Token) return;
final check = _parseAndValidateAmount(token);
if (check.error != null) {
executeError = check.error;
return;
}
final src = sourceChainId!;
final dst = destinationChainId!;
final amountBigInt = check.parsedAmount!;
isExecuting = true;
executeError = null;
try {
final priority = EVMChainTransactionPriority.medium;
final pending = await evm!.executeUSDT0Transfer(
wallet: wallet,
token: token,
sourceChainId: src,
destinationChainId: dst,
amount: amountBigInt,
recipientAddress: recipientAddress.trim(),
quote: quote!,
priority: priority as TransactionPriority,
useBlinkProtection: canSupportBlinkProtection(src),
);
final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id;
await pending.commit();
final record = BridgeTransfer(
id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}',
walletId: wallet.name,
sourceChainId: src,
destinationChainId: dst,
tokenSymbol: token.title,
tokenContract: token.contractAddress,
amount: amount,
recipientAddress: recipientAddress.trim(),
sourceTxHash: sourceTxHash,
status: 'submitted',
createdAt: DateTime.now(),
);
await bridgeTransfersStore.addTransfer(record);
runInAction(() {
quote = null;
bridgeSuccess = true;
lastCreatedBridgeTransfer = record;
});
onBridgeSuccess?.call();
_pollForSourceConfirmation(record, wallet);
} catch (e) {
executeError = e.toString();
} finally {
isExecuting = false;
}
}
static const _pollInterval = Duration(seconds: 2);
static const _pollTimeout = Duration(minutes: 3);
static const _destinationPollInterval = Duration(seconds: 5);
static const _destinationPollTimeout = Duration(minutes: 10);
@override
void onWalletChange(WalletBase wallet) {
_cancelAllPolling();
_resumePollingForActiveTransfers(wallet);
}
void _cancelAllPolling() {
for (final canceller in _pollingCancellers.values) {
if (!canceller.isCompleted) {
canceller.complete();
}
}
_pollingCancellers.clear();
}
void _resumePollingForActiveTransfers(WalletBase wallet) {
if (!isEVMCompatibleChain(wallet.type)) return;
final activeTransfers = bridgeTransfersStore.bridgeTransfers
.where((t) => t.walletId == wallet.name && t.isActive)
.toList();
for (final transfer in activeTransfers) {
if (transfer.status == 'submitted' || transfer.status == 'confirming') {
_pollForSourceConfirmation(transfer, wallet);
} else if (transfer.status == 'initiated') {
_pollForDestinationCompletion(transfer, wallet);
}
}
}
bool _isValidWalletContext(String expectedWalletId) {
return wallet.name == expectedWalletId &&
isEVMCompatibleChain(wallet.type) &&
!_pollingCancellers.values.any((c) => c.isCompleted);
}
Future<void> _updateTransferStatus(
BridgeTransfer record,
String status, {
String? errorMessage,
String? statusMessage,
DateTime? confirmedAt,
}) async {
if (!_isValidWalletContext(record.walletId)) return;
runInAction(() {
record.updatedAt = DateTime.now();
record.status = status;
if (errorMessage != null) record.errorMessage = errorMessage;
if (statusMessage != null) record.statusMessage = statusMessage;
if (confirmedAt != null) record.confirmedAt = confirmedAt;
});
await bridgeTransfersStore.updateTransfer(record);
}
Future<void> _pollForSourceConfirmation(
BridgeTransfer record,
WalletBase wallet,
) async {
final canceller = Completer<void>();
_pollingCancellers[record.id] = canceller;
final walletId = wallet.name;
final deadline = DateTime.now().add(_pollTimeout);
try {
while (DateTime.now().isBefore(deadline)) {
await Future.any([
Future.delayed(_pollInterval),
canceller.future,
]);
if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
bool? receipt;
try {
receipt = await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
} catch (e) {
printV('USDT0 bridge: Error fetching receipt: $e');
continue;
}
if (receipt == null) continue;
if (receipt == true) {
await _updateTransferStatus(
record,
'confirming',
confirmedAt: DateTime.now(),
);
await Future.delayed(const Duration(seconds: 1));
if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
await _updateTransferStatus(record, 'initiated');
_pollForDestinationCompletion(record, wallet);
return;
} else if (receipt == false) {
await _updateTransferStatus(
record,
'failed',
errorMessage: 'Transaction reverted',
);
return;
}
}
if (!_isValidWalletContext(walletId)) return;
await _updateTransferStatus(record, 'initiated');
_pollForDestinationCompletion(record, wallet);
} finally {
_pollingCancellers.remove(record.id);
}
}
Future<void> _pollForDestinationCompletion(
BridgeTransfer record,
WalletBase wallet,
) async {
final canceller = Completer<void>();
_pollingCancellers['${record.id}_dest'] = canceller;
final walletId = wallet.name;
final deadline = DateTime.now().add(_destinationPollTimeout);
try {
while (DateTime.now().isBefore(deadline)) {
await Future.any([
Future.delayed(_destinationPollInterval),
canceller.future,
]);
if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
LayerZeroMessageStatus? status;
try {
status = await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
} catch (e) {
printV('USDT0 bridge: Error fetching LayerZero status: $e');
continue;
}
if (status == null) continue;
if (status.isDelivered) {
await _updateTransferStatus(
record,
'completed',
statusMessage: status.status?.message,
);
return;
}
if (status.isFailed) {
await _updateTransferStatus(
record,
'failed',
errorMessage: status.status?.message ?? 'Bridge message failed',
statusMessage: status.status?.message,
);
return;
}
await _updateTransferStatus(
record,
record.status,
statusMessage: status.status?.message,
);
}
} finally {
_pollingCancellers.remove('${record.id}_dest');
}
}
@action
void clearBridgeSuccess() {
amount = '';
recipientAddress = '';
destinationWalletName = null;
destinationChainId = null;
bridgeSuccess = false;
lastCreatedBridgeTransfer = null;
_clearQuoteState();
}
void dispose() {
_cancelAllPolling();
}
}
@@ -0,0 +1,181 @@
import 'dart:async';
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
import 'package:cake_wallet/store/bridge_transfers_store.dart';
import 'package:mobx/mobx.dart';
import 'package:url_launcher/url_launcher.dart';
part 'bridge_details_view_model.g.dart';
class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel;
abstract class BridgeDetailsViewModelBase with Store {
BridgeDetailsViewModelBase({
required BridgeTransfer transferForDetails,
required this.bridgeTransfersStore,
required this.walletId,
}) : items = ObservableList<TransactionDetailsListItem>(),
transfer = _findTransferInStore(
bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ??
transferForDetails {
_updateItems();
_setupReaction();
}
static BridgeTransfer? _findTransferInStore(
List<BridgeTransfer> transfers,
String transferId,
String walletId,
) {
try {
return transfers.firstWhere(
(t) => t.id == transferId && t.walletId == walletId,
);
} catch (_) {
return null;
}
}
final BridgeTransfersStore bridgeTransfersStore;
final String walletId;
ReactionDisposer? _reactionDisposer;
@observable
BridgeTransfer transfer;
@observable
ObservableList<TransactionDetailsListItem> items;
Timer? timer;
void _setupReaction() {
_reactionDisposer = reaction(
(_) => bridgeTransfersStore.bridgeTransfers,
(_) => updateTransfer(),
);
updateTransfer();
}
@action
void updateTransfer() {
final updatedTransfer = _findTransferInStore(
bridgeTransfersStore.bridgeTransfers,
transfer.id,
walletId,
);
if (updatedTransfer != null) {
transfer = updatedTransfer;
_updateItems();
}
}
void dispose() {
_reactionDisposer?.call();
timer?.cancel();
}
void _updateItems() {
items.clear();
final statusText = transfer.statusMessage?.isNotEmpty == true
? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}'
: _statusLabel(transfer.status);
items.add(
DetailsListStatusItem(
title: "Status",
value: statusText,
status: transfer.status,
),
);
final sourceName =
evm?.getChainNameByChainId(transfer.sourceChainId) ?? '${transfer.sourceChainId}';
final destName =
evm?.getChainNameByChainId(transfer.destinationChainId) ?? '${transfer.destinationChainId}';
items.add(
StandartListItem(
title: "Source chain",
value: sourceName,
),
);
items.add(
StandartListItem(
title: "Destination chain",
value: destName,
),
);
items.add(
StandartListItem(
title: "Amount",
value: '${transfer.amount} ${transfer.tokenSymbol}',
),
);
items.add(
AddressListItem(
title: "Recipient",
value: transfer.recipientAddress,
),
);
final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId);
final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty
? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}'
: null;
if (sourceTxUrl != null) {
final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host;
items.add(
TrackTradeListItem(
title: explorerDescription,
value: sourceTxUrl,
onTap: () => _launchUrl(sourceTxUrl),
),
);
}
if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) {
items.add(
StandartListItem(
title: "Error",
value: transfer.errorMessage!,
),
);
}
}
String _statusLabel(String status) {
switch (status) {
case 'submitted':
return "Submitted";
case 'confirming':
return "Confirming on source";
case 'initiated':
return "Bridge initiated";
case 'completed':
return "Completed";
case 'failed':
return "Failed";
default:
return status;
}
}
void _launchUrl(String url) {
final uri = Uri.parse(url);
try {
launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
}
}
@@ -0,0 +1,37 @@
import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/bridge_transfers_store.dart';
import 'package:mobx/mobx.dart';
part 'bridge_history_view_model.g.dart';
class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel;
abstract class BridgeHistoryViewModelBase with Store {
BridgeHistoryViewModelBase({
required this.bridgeTransfersStore,
required this.appStore,
});
final BridgeTransfersStore bridgeTransfersStore;
final AppStore appStore;
@computed
List<BridgeTransfer> get walletTransfers {
final wallet = appStore.wallet;
if (wallet == null) return [];
return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList();
}
@computed
List<BridgeTransfer> get activeTransfers =>
walletTransfers.where((b) => b.isActive).toList(growable: false);
@computed
List<BridgeTransfer> get pastTransfers =>
walletTransfers.where((b) => !b.isActive).toList(growable: false);
@computed
bool get isEmpty => walletTransfers.isEmpty;
}
@@ -393,6 +393,14 @@ abstract class DashboardViewModelBase with Store {
return false;
}
bool showBridge(CryptoCurrency currency) {
if(!isEVMCompatibleChain(wallet.type)) return false;
if(evm!.isUSDT0Token(wallet, currency)) return true;
return false;
}
@action
Future<void> loadCardDesigns() async {
final accountStyleSettings =