CW-959: Swap Status on Transaction Screen (#2247)

* feat(swap-status-monitor): add real-time swap status monitoring and UI updates - Introduce SwapManager for automatic tracking of active-wallet swaps. - Automatically queues new or updated trades from the Hive box. - Periodically fetch and persist swap statuses via the corresponding trade provider. - Implement start(wallet, providers), stop(), and dispose() for lifecycle control. - Apply user's ExchangeApiMode(disabled, tor-only, enabled) when fetching updates. - Remove swaps from the watchlist on any final state (completed, expired, failed). - Dispose SwapManager in AppState.dispose() to cancel polling and the Hive subscription. * refactor(swap-status): replace SwapManager with TradeMonitor for improved trade monitoring. This change improves the flow by simplifying the trade monitoring logic. - Removes SwapManager class and replace with TradeMonitor implementation - Update di and Appstate to register and dispose TradeMonitor - Modify DashboardViewModel to use TradeMonitor instead of SwapManager * fix: Modify trade monitoring logic to ensure trade timers are properly disposed when wallet switching occurs * fix(swap-status): Fix receive amount for exchanges showing as .00 because of null values

David Adegoke committed May 26, 2025 at 22:52 UTC eede8fa6c733010711176b6480e5408c738dd329
6 files changed +322 -55
lib/core/trade_monitor.dart new
+211
@@ -0,0 +1,211 @@
1 +import 'dart:async';
2 +import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
3 +import 'package:cake_wallet/exchange/trade.dart';
4 +import 'package:cake_wallet/exchange/trade_state.dart';
5 +import 'package:cake_wallet/store/dashboard/trades_store.dart';
6 +import 'package:cake_wallet/store/settings_store.dart';
7 +import 'package:cake_wallet/entities/exchange_api_mode.dart';
8 +import 'package:cake_wallet/exchange/exchange_provider_description.dart';
9 +import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
10 +import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
11 +import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
12 +import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
13 +import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
14 +import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
15 +import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart';
16 +import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
17 +import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
18 +import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
19 +import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart';
20 +import 'package:cw_core/utils/print_verbose.dart';
21 +import 'package:hive/hive.dart';
22 +
23 +class TradeMonitor {
24 + static const int _tradeCheckIntervalMinutes = 1;
25 + static const int _maxTradeAgeHours = 24;
26 +
27 + TradeMonitor({
28 + required this.tradesStore,
29 + required this.settingsStore,
30 + required this.trades,
31 + });
32 +
33 + final TradesStore tradesStore;
34 + final Box<Trade> trades;
35 + final SettingsStore settingsStore;
36 + final Map<String, Timer> _tradeTimers = {};
37 +
38 + ExchangeProvider? _getProviderByDescription(ExchangeProviderDescription description) {
39 + switch (description) {
40 + case ExchangeProviderDescription.changeNow:
41 + return ChangeNowExchangeProvider(settingsStore: settingsStore);
42 + case ExchangeProviderDescription.sideShift:
43 + return SideShiftExchangeProvider();
44 + case ExchangeProviderDescription.simpleSwap:
45 + return SimpleSwapExchangeProvider();
46 + case ExchangeProviderDescription.trocador:
47 + return TrocadorExchangeProvider();
48 + case ExchangeProviderDescription.exolix:
49 + return ExolixExchangeProvider();
50 + case ExchangeProviderDescription.thorChain:
51 + return ThorChainExchangeProvider(tradesStore: trades);
52 + case ExchangeProviderDescription.swapTrade:
53 + return SwapTradeExchangeProvider();
54 + case ExchangeProviderDescription.letsExchange:
55 + return LetsExchangeExchangeProvider();
56 + case ExchangeProviderDescription.stealthEx:
57 + return StealthExExchangeProvider();
58 + case ExchangeProviderDescription.chainflip:
59 + return ChainflipExchangeProvider(tradesStore: trades);
60 + case ExchangeProviderDescription.xoSwap:
61 + return XOSwapExchangeProvider();
62 + }
63 + return null;
64 + }
65 +
66 + void monitorActiveTrades(String walletId) {
67 + final now = DateTime.now();
68 + final trades = tradesStore.trades;
69 + final tradesToCancel = <String>[];
70 +
71 + for (final item in trades) {
72 + final trade = item.trade;
73 +
74 + if (_shouldCancelTradeTimer(trade, walletId, now)) {
75 + if (_tradeTimers.containsKey(trade.id)) {
76 + tradesToCancel.add(trade.id);
77 + }
78 + continue;
79 + }
80 +
81 + if (!_tradeTimers.containsKey(trade.id)) {
82 + printV('Starting trade monitoring for ${trade.id}');
83 + _startTradeMonitoring(trade);
84 + }
85 + }
86 +
87 + _cancelTradeTimers(tradesToCancel);
88 + }
89 +
90 + bool _shouldCancelTradeTimer(Trade trade, String walletId, DateTime now) {
91 + if (trade.walletId != walletId) return true;
92 +
93 + final createdAt = trade.createdAt;
94 + if (createdAt == null) return true;
95 +
96 + if (now.difference(createdAt).inHours > _maxTradeAgeHours) return true;
97 +
98 + return _isFinalState(trade.state);
99 + }
100 +
101 + void _startTradeMonitoring(Trade trade) {
102 + if (_tradeTimers.containsKey(trade.id)) return;
103 +
104 + _checkTradeStatus(trade);
105 +
106 + final timer = Timer.periodic(
107 + Duration(minutes: _tradeCheckIntervalMinutes),
108 + (_) => _checkTradeStatus(trade)
109 + );
110 +
111 + _tradeTimers[trade.id] = timer;
112 + }
113 +
114 + Future<void> _checkTradeStatus(Trade trade) async {
115 + printV('Checking trade status for ${trade.id}');
116 +
117 + if (_isTradeOld(trade)) {
118 + printV('The trade ${trade.id} is older than $_maxTradeAgeHours hours, we will cancel the timer');
119 + _cancelTradeTimer(trade.id);
120 + return;
121 + }
122 +
123 + final provider = _getProviderByDescription(trade.provider);
124 + if (provider == null) {
125 + printV('No provider found for trade ${trade.id}');
126 + return;
127 + }
128 +
129 + if (!_isExchangeModeEnabled(provider)) {
130 + return;
131 + }
132 +
133 + try {
134 + await _updateTradeStatus(trade, provider);
135 + } catch (e) {
136 + printV('Error fetching status for ${trade.id}: $e');
137 + }
138 + }
139 +
140 + bool _isTradeOld(Trade trade) {
141 + final now = DateTime.now();
142 + final createdAt = trade.createdAt;
143 + return createdAt != null && now.difference(createdAt).inHours > _maxTradeAgeHours;
144 + }
145 +
146 + bool _isExchangeModeEnabled(ExchangeProvider provider) {
147 + final exchangeApiMode = settingsStore.exchangeStatus;
148 +
149 + if (exchangeApiMode == ExchangeApiMode.disabled) {
150 + printV('Exchange API mode is disabled');
151 + return false;
152 + }
153 +
154 + if (exchangeApiMode == ExchangeApiMode.torOnly && !provider.supportsOnionAddress) {
155 + printV('Skipping ${provider.description}, no TOR support');
156 + return false;
157 + }
158 +
159 + return true;
160 + }
161 +
162 + Future<void> _updateTradeStatus(Trade trade, ExchangeProvider provider) async {
163 + final updated = await provider.findTradeById(id: trade.id);
164 + trade
165 + ..stateRaw = updated.state.raw
166 + ..receiveAmount = updated.receiveAmount ?? trade.receiveAmount
167 + ..outputTransaction = updated.outputTransaction ?? trade.outputTransaction;
168 + printV('Trade ${trade.id} updated: ${trade.state}');
169 + await trade.save();
170 +
171 + if (_isFinalState(updated.state)) {
172 + printV('Trade ${trade.id} is in final state');
173 + _cancelTradeTimer(trade.id);
174 + }
175 + }
176 +
177 + void _cancelTradeTimer(String tradeId) {
178 + _tradeTimers[tradeId]?.cancel();
179 + _tradeTimers.remove(tradeId);
180 + }
181 +
182 + void _cancelTradeTimers(List<String> tradeIds) {
183 + for (final tradeId in tradeIds) {
184 + _cancelTradeTimer(tradeId);
185 + }
186 + }
187 +
188 + void cancelAllTradeTimers() {
189 + for (final timer in _tradeTimers.values) {
190 + timer.cancel();
191 + }
192 + _tradeTimers.clear();
193 + }
194 +
195 + bool _isFinalState(TradeState state) {
196 + return {
197 + TradeState.completed.raw,
198 + TradeState.success.raw,
199 + TradeState.confirmed.raw,
200 + TradeState.settled.raw,
201 + TradeState.finished.raw,
202 + TradeState.expired.raw,
203 + TradeState.failed.raw,
204 + TradeState.notFound.raw,
205 + }.contains(state.raw);
206 + }
207 +
208 + void dispose() {
209 + cancelAllTradeTimers();
210 + }
211 +}
lib/di.dart
+35 -25
@@ -275,6 +275,7 @@ import 'src/screens/buy/buy_sell_page.dart';
275 import 'cake_pay/cake_pay_payment_credantials.dart';
276 import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart';
277 import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart';
278 +import 'package:cake_wallet/core/trade_monitor.dart';
279
280 final getIt = GetIt.instance;
281
@@ -507,19 +508,41 @@ Future<void> setup({
508 settingsStore: getIt.get<SettingsStore>(),
509 fiatConvertationStore: getIt.get<FiatConversionStore>()));
510
510 - getIt.registerFactory(() => DashboardViewModel(
511 - balanceViewModel: getIt.get<BalanceViewModel>(),
512 - appStore: getIt.get<AppStore>(),
511 + getIt.registerFactory(
512 + () => ExchangeViewModel(
513 + getIt.get<AppStore>(),
514 + _tradesSource,
515 + getIt.get<ExchangeTemplateStore>(),
516 + getIt.get<TradesStore>(),
517 + getIt.get<AppStore>().settingsStore,
518 + getIt.get<SharedPreferences>(),
519 + getIt.get<ContactListViewModel>(),
520 + getIt.get<FeesViewModel>(),
521 + ),
522 + );
523 +
524 + getIt.registerSingleton(
525 + TradeMonitor(
526 tradesStore: getIt.get<TradesStore>(),
514 - tradeFilterStore: getIt.get<TradeFilterStore>(),
515 - transactionFilterStore: getIt.get<TransactionFilterStore>(),
516 - settingsStore: settingsStore,
517 - yatStore: getIt.get<YatStore>(),
518 - ordersStore: getIt.get<OrdersStore>(),
519 - anonpayTransactionsStore: getIt.get<AnonpayTransactionsStore>(),
520 - payjoinTransactionsStore: getIt.get<PayjoinTransactionsStore>(),
521 - sharedPreferences: getIt.get<SharedPreferences>(),
522 - keyService: getIt.get<KeyService>()));
527 + settingsStore: getIt.get<SettingsStore>(),
528 + trades: _tradesSource
529 + ),
530 + );
531 +
532 + getIt.registerFactory(() => DashboardViewModel(
533 + tradeMonitor: getIt.get<TradeMonitor>(),
534 + balanceViewModel: getIt.get<BalanceViewModel>(),
535 + appStore: getIt.get<AppStore>(),
536 + tradesStore: getIt.get<TradesStore>(),
537 + tradeFilterStore: getIt.get<TradeFilterStore>(),
538 + transactionFilterStore: getIt.get<TransactionFilterStore>(),
539 + settingsStore: settingsStore,
540 + yatStore: getIt.get<YatStore>(),
541 + ordersStore: getIt.get<OrdersStore>(),
542 + anonpayTransactionsStore: getIt.get<AnonpayTransactionsStore>(),
543 + payjoinTransactionsStore: getIt.get<PayjoinTransactionsStore>(),
544 + sharedPreferences: getIt.get<SharedPreferences>(),
545 + keyService: getIt.get<KeyService>()));
546
547 getIt.registerFactory<AuthService>(
548 () => AuthService(
@@ -1051,19 +1074,6 @@ Future<void> setup({
1074
1075 getIt.registerFactoryParam<WebViewPage, String, Uri>((title, uri) => WebViewPage(title, uri));
1076
1054 - getIt.registerFactory(
1055 - () => ExchangeViewModel(
1056 - getIt.get<AppStore>(),
1057 - _tradesSource,
1058 - getIt.get<ExchangeTemplateStore>(),
1059 - getIt.get<TradesStore>(),
1060 - getIt.get<SettingsStore>(),
1061 - getIt.get<SharedPreferences>(),
1062 - getIt.get<ContactListViewModel>(),
1063 - getIt.get<FeesViewModel>(),
1064 - ),
1065 - );
1066 -
1077 getIt.registerFactory<FeesViewModel>(
1078 () => FeesViewModel(
1079 getIt.get<AppStore>(),
lib/main.dart
+1
@@ -51,6 +51,7 @@ import 'package:cw_core/root_dir.dart';
51 import 'package:shared_preferences/shared_preferences.dart';
52 import 'package:cw_core/window_size.dart';
53 import 'package:logging/logging.dart';
54 +import 'package:cake_wallet/core/trade_monitor.dart';
55
56 final navigatorKey = GlobalKey<NavigatorState>();
57 final rootKey = GlobalKey<RootState>();
lib/src/screens/dashboard/pages/transactions_page.dart
+12 -10
@@ -166,17 +166,19 @@ class TransactionsPage extends StatelessWidget {
166
167 return Observer(
168 builder: (_) => TradeRow(
169 - key: item.key,
170 - onTap: () => Navigator.of(context)
171 - .pushNamed(Routes.tradeDetails, arguments: trade),
169 + key: item.key,
170 + onTap: () => Navigator.of(context)
171 + .pushNamed(Routes.tradeDetails, arguments: trade),
172 + swapState: trade.state,
173 provider: trade.provider,
173 - from: trade.from,
174 - to: trade.to,
175 - createdAtFormattedDate: trade.createdAt != null
176 - ? DateFormat('HH:mm').format(trade.createdAt!)
177 - : null,
178 - formattedAmount: item.tradeFormattedAmount,
179 - formattedReceiveAmount: item.tradeFormattedReceiveAmount),
174 + from: trade.from,
175 + to: trade.to,
176 + createdAtFormattedDate: trade.createdAt != null
177 + ? DateFormat('HH:mm').format(trade.createdAt!)
178 + : null,
179 + formattedAmount: item.tradeFormattedAmount,
180 + formattedReceiveAmount: item.tradeFormattedReceiveAmount
181 + ),
182 );
183 }
184 if (item is OrderListItem) {
lib/src/screens/dashboard/widgets/trade_row.dart
+54 -19
@@ -1,7 +1,9 @@
1 +import 'package:cake_wallet/exchange/trade_state.dart';
2 +import 'package:cake_wallet/palette.dart';
3 import 'package:cake_wallet/utils/image_utill.dart';
4 import 'package:flutter/material.dart';
3 -import 'package:cw_core/crypto_currency.dart';
5 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
6 +import 'package:cw_core/crypto_currency.dart';
7
8 class TradeRow extends StatelessWidget {
9 TradeRow({
@@ -12,6 +14,7 @@ class TradeRow extends StatelessWidget {
14 this.onTap,
15 this.formattedAmount,
16 this.formattedReceiveAmount,
17 + required this.swapState,
18 super.key,
19 });
20
@@ -22,6 +25,7 @@ class TradeRow extends StatelessWidget {
25 final String? createdAtFormattedDate;
26 final String? formattedAmount;
27 final String? formattedReceiveAmount;
28 + final TradeState swapState;
29
30 @override
31 Widget build(BuildContext context) {
@@ -29,25 +33,39 @@ class TradeRow extends StatelessWidget {
33 final receiveAmountCrypto = to.toString();
34
35 return InkWell(
32 - onTap: onTap,
33 - child: Container(
34 - padding: EdgeInsets.fromLTRB(24, 8, 24, 8),
35 - color: Colors.transparent,
36 - child: Row(
37 - mainAxisSize: MainAxisSize.max,
38 - crossAxisAlignment: CrossAxisAlignment.center,
39 - children: [
40 - ClipRRect(
41 - borderRadius: BorderRadius.circular(50),
42 - child: ImageUtil.getImageFromPath(
43 - imagePath: provider.image,
44 - height: 36,
45 - width: 36,
36 + onTap: onTap,
37 + child: Container(
38 + padding: EdgeInsets.fromLTRB(24, 8, 24, 8),
39 + color: Colors.transparent,
40 + child: Row(
41 + mainAxisSize: MainAxisSize.max,
42 + crossAxisAlignment: CrossAxisAlignment.center,
43 + children: [
44 + Stack(
45 + clipBehavior: Clip.none,
46 + children: [
47 + ClipRRect(
48 + borderRadius: BorderRadius.circular(50),
49 + child: ImageUtil.getImageFromPath(
50 + imagePath: provider.image, height: 36, width: 36),),
51 + Positioned(
52 + right: 0,
53 + bottom: 2,
54 + child: Container(
55 + height: 8,
56 + width: 8,
57 + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
58 + decoration: BoxDecoration(
59 + color: _statusColor(context, swapState),
60 + borderRadius: BorderRadius.circular(12),
61 + ),
62 + ),
63 + ),
64 + ],
65 ),
47 - ),
48 - SizedBox(width: 12),
49 - Expanded(
50 - child: Column(
66 + SizedBox(width: 12),
67 + Expanded(
68 + child: Column(
69 mainAxisSize: MainAxisSize.min,
70 children: [
71 Row(
@@ -104,4 +122,21 @@ class TradeRow extends StatelessWidget {
122 ),
123 );
124 }
125 +
126 + Color _statusColor(BuildContext context, TradeState status) {
127 + switch (status) {
128 + case TradeState.complete:
129 + case TradeState.completed:
130 + case TradeState.finished:
131 + case TradeState.success:
132 + case TradeState.settled:
133 + return PaletteDark.brightGreen;
134 + case TradeState.failed:
135 + case TradeState.expired:
136 + case TradeState.notFound:
137 + return Palette.darkRed;
138 + default:
139 + return const Color(0xffff6600);
140 + }
141 + }
142 }
lib/view_model/dashboard/dashboard_view_model.dart
+9 -1
@@ -23,7 +23,6 @@ import 'package:cake_wallet/store/dashboard/trades_store.dart';
23 import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart';
24 import 'package:cake_wallet/store/settings_store.dart';
25 import 'package:cake_wallet/store/yat/yat_store.dart';
26 -import 'package:cake_wallet/themes/core/material_base_theme.dart';
26 import 'package:cake_wallet/view_model/dashboard/action_list_item.dart';
27 import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
28 import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart';
@@ -56,6 +55,8 @@ import 'package:mobx/mobx.dart';
55 import 'package:permission_handler/permission_handler.dart';
56 import 'package:shared_preferences/shared_preferences.dart';
57
58 +import 'package:cake_wallet/core/trade_monitor.dart';
59 +
60 part 'dashboard_view_model.g.dart';
61
62 class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel;
@@ -63,6 +64,7 @@ class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel;
64 abstract class DashboardViewModelBase with Store {
65 DashboardViewModelBase(
66 {required this.balanceViewModel,
67 + required this.tradeMonitor,
68 required this.appStore,
69 required this.tradesStore,
70 required this.tradeFilterStore,
@@ -298,6 +300,10 @@ abstract class DashboardViewModelBase with Store {
300
301 _checkMweb();
302 reaction((_) => settingsStore.mwebAlwaysScan, (bool value) => _checkMweb());
303 +
304 + reaction((_) => tradesStore.trades, (_) => tradeMonitor.monitorActiveTrades(wallet.id));
305 +
306 + tradeMonitor.monitorActiveTrades(wallet.id);
307 }
308
309 bool _isTransactionDisposerCallbackRunning = false;
@@ -773,6 +779,8 @@ abstract class DashboardViewModelBase with Store {
779
780 BalanceViewModel balanceViewModel;
781
782 + TradeMonitor tradeMonitor;
783 +
784 AppStore appStore;
785
786 SettingsStore settingsStore;