Skip to content

CW-959: Swap Status on Transaction Screen #2247

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions lib/di.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/exchange_api_mode.dart';
import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart';
import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/entities/swap_manager.dart';
import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:cake_wallet/haven/cw_haven.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
Expand Down Expand Up @@ -497,8 +498,30 @@ Future<void> setup({
settingsStore: getIt.get<SettingsStore>(),
fiatConvertationStore: getIt.get<FiatConversionStore>()));

getIt.registerFactory(
() => ExchangeViewModel(
getIt.get<AppStore>(),
_tradesSource,
getIt.get<ExchangeTemplateStore>(),
getIt.get<TradesStore>(),
getIt.get<AppStore>().settingsStore,
getIt.get<SharedPreferences>(),
getIt.get<ContactListViewModel>(),
getIt.get<FeesViewModel>(),
),
);

getIt.registerSingleton(
SwapManager(
tradesStore: getIt.get<TradesStore>(),
settingsStore: getIt.get<SettingsStore>(),
),
);

getIt.registerFactory(() => DashboardViewModel(
balanceViewModel: getIt.get<BalanceViewModel>(),
exchangeViewModel: getIt.get<ExchangeViewModel>(),
swapManager: getIt.get<SwapManager>(),
appStore: getIt.get<AppStore>(),
tradesStore: getIt.get<TradesStore>(),
tradeFilterStore: getIt.get<TradeFilterStore>(),
Expand Down Expand Up @@ -1040,19 +1063,6 @@ Future<void> setup({

getIt.registerFactoryParam<WebViewPage, String, Uri>((title, uri) => WebViewPage(title, uri));

getIt.registerFactory(
() => ExchangeViewModel(
getIt.get<AppStore>(),
_tradesSource,
getIt.get<ExchangeTemplateStore>(),
getIt.get<TradesStore>(),
getIt.get<AppStore>().settingsStore,
getIt.get<SharedPreferences>(),
getIt.get<ContactListViewModel>(),
getIt.get<FeesViewModel>(),
),
);

getIt.registerFactory<FeesViewModel>(
() => FeesViewModel(
getIt.get<AppStore>(),
Expand Down
158 changes: 158 additions & 0 deletions lib/entities/swap_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import 'dart:async';
import 'package:cake_wallet/entities/exchange_api_mode.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:hive/hive.dart';
import 'package:cake_wallet/exchange/exchange_provider_description.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/trade.dart';
import 'package:cake_wallet/exchange/trade_state.dart';
import 'package:cake_wallet/store/dashboard/trades_store.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';

class SwapManager {
SwapManager({
required this.tradesStore,
required this.settingsStore,
Duration? pollingInterval,
}) : _pollingInterval = pollingInterval ?? const Duration(minutes: 1) {
_boxSub = tradesStore.tradesSource.watch().listen(_onBoxEvent);
}

WalletBase? _currentWallet;
final TradesStore tradesStore;
final Duration _pollingInterval;
final SettingsStore settingsStore;
Map<ExchangeProviderDescription, ExchangeProvider>? _providers;

// Timer for periodic polling.
Timer? _timer;

// The map of trades we’re monitoring, keyed by trade ID.
final Map<String, Trade> _activeSwaps = {};

// Subscription to Hive box events (for new or updated Trade entries).
late final StreamSubscription<BoxEvent> _boxSub;

/// Set of trade states where polling is no longer needed.
static final Set<TradeState> _finalStates = {
// Completed or successful trades
TradeState.completed,
TradeState.success,
TradeState.confirmed,
TradeState.finished,

// Expired or failed trades
TradeState.expired,
TradeState.failed,
TradeState.notFound,
};

// Called on every Hive write; adds new non-final trades for polling.
void _onBoxEvent(BoxEvent event) {
if (event.deleted) return;
final value = event.value;
if (value is! Trade) return;
final trade = value;

// We only care about currently active trades for the current wallet
if (_currentWallet == null || trade.walletId != _currentWallet!.id || _isFinal(trade.state)) {
return;
}

final isNew = !_activeSwaps.containsKey(trade.id);

_activeSwaps[trade.id] = trade;

if (isNew) _ensureTimerRunning(immediate: false);
}

void start(WalletBase wallet, Map<ExchangeProviderDescription, ExchangeProvider> providers) {
if (_currentWallet == wallet && _timer?.isActive == true) return;

// Clear any previous state
_timer?.cancel();
_activeSwaps.clear();
_currentWallet = wallet;
_providers ??= providers;

// We fetch any existing pending swaps from Hive.
for (final item in tradesStore.trades) {
final trade = item.trade;
if (trade.walletId == wallet.id && !_isFinal(trade.state)) {
_activeSwaps[trade.id] = trade;
}
}

_ensureTimerRunning(immediate: true);
}

// Ensures the timer is running if there are swaps to poll.
void _ensureTimerRunning({required bool immediate}) {
if (_activeSwaps.isEmpty) return;

if (_timer?.isActive != true) {
if (immediate) _fetchPendingSwapsStatuses();

_timer = Timer.periodic(_pollingInterval, (_) => _fetchPendingSwapsStatuses());
}
}

// Polls each pending swap status and writes updates back to Hive.
Future<void> _fetchPendingSwapsStatuses() async {
if (_activeSwaps.isEmpty) {
stop();
return;
}

final exchangeApiMode = settingsStore.exchangeStatus;
if (exchangeApiMode == ExchangeApiMode.disabled) return;

for (final entry in _activeSwaps.entries.toList()) {
final trade = entry.value;
final provider = _providers?[trade.provider];

if (provider == null) {
printV('No provider found for ${trade.provider}');
continue;
}

if (exchangeApiMode == ExchangeApiMode.torOnly && !provider.supportsOnionAddress) {
printV('Skipping ${trade.provider}, no TOR support');
continue;
}

try {
final updated = await provider.findTradeById(id: trade.id);
trade
..stateRaw = updated.state.raw
..receiveAmount = updated.receiveAmount
..outputTransaction = updated.outputTransaction;
await trade.save();

if (_isFinal(updated.state)) {
_activeSwaps.remove(trade.id);
}
} catch (e) {
printV('Error fetching status for ${trade.id}: $e');
}
}
}

// Returns true for any state where we no longer need to poll.
bool _isFinal(TradeState state) => _finalStates.contains(state);

// Stops polling and clears tracked swaps.
void stop() {
_timer?.cancel();
_timer = null;
_activeSwaps.clear();
_currentWallet = null;
}

// Cleans up resources when the app is closed.
void dispose() {
stop();
_boxSub.cancel();
}
}
8 changes: 8 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:cake_wallet/entities/get_encryption_key.dart';
import 'package:cake_wallet/core/secure_storage.dart';
import 'package:cake_wallet/entities/haven_seed_store.dart';
import 'package:cake_wallet/entities/language_service.dart';
import 'package:cake_wallet/entities/swap_manager.dart';
import 'package:cake_wallet/entities/template.dart';
import 'package:cake_wallet/entities/transaction_description.dart';
import 'package:cake_wallet/exchange/exchange_template.dart';
Expand Down Expand Up @@ -281,6 +282,13 @@ class App extends StatefulWidget {
}

class AppState extends State<App> with SingleTickerProviderStateMixin {

@override
void dispose() {
getIt.get<SwapManager>().dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Observer(builder: (BuildContext context) {
Expand Down
3 changes: 2 additions & 1 deletion lib/src/screens/dashboard/pages/transactions_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ class TransactionsPage extends StatelessWidget {
key: item.key,
onTap: () => Navigator.of(context)
.pushNamed(Routes.tradeDetails, arguments: trade),
provider: trade.provider,
swapState: trade.state,
provider: trade.provider,
from: trade.from,
to: trade.to,
createdAtFormattedDate: trade.createdAt != null
Expand Down
82 changes: 61 additions & 21 deletions lib/src/screens/dashboard/widgets/trade_row.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/exchange/trade_state.dart';
import 'package:cake_wallet/palette.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:flutter/material.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cake_wallet/exchange/exchange_provider_description.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart';
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';

class TradeRow extends StatelessWidget {
TradeRow({
Expand All @@ -14,6 +16,7 @@ class TradeRow extends StatelessWidget {
this.onTap,
this.formattedAmount,
this.formattedReceiveAmount,
required this.swapState,
super.key,
});

Expand All @@ -24,6 +27,7 @@ class TradeRow extends StatelessWidget {
final String? createdAtFormattedDate;
final String? formattedAmount;
final String? formattedReceiveAmount;
final TradeState swapState;

@override
Widget build(BuildContext context) {
Expand All @@ -39,10 +43,28 @@ class TradeRow extends StatelessWidget {
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(50),
child: ImageUtil.getImageFromPath(
imagePath: provider.image, height: 36, width: 36)),
Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(50),
child: ImageUtil.getImageFromPath(
imagePath: provider.image, height: 36, width: 36)),
Positioned(
right: 0,
bottom: 2,
child: Container(
height: 8,
width: 8,
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColor(context, swapState),
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
SizedBox(width: 12),
Expanded(
child: Column(
Expand All @@ -64,26 +86,44 @@ class TradeRow extends StatelessWidget {
: Container()
]),
SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
createdAtFormattedDate != null
? Text(createdAtFormattedDate!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).extension<CakeTextTheme>()!.dateSectionRowColor))
: Container(),
formattedReceiveAmount != null
? Text(formattedReceiveAmount! + ' ' + receiveAmountCrypto,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).extension<CakeTextTheme>()!.dateSectionRowColor))
: Container(),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[
createdAtFormattedDate != null
? Text(createdAtFormattedDate!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.extension<CakeTextTheme>()!
.dateSectionRowColor))
: Container(),
formattedReceiveAmount != null
? Text(formattedReceiveAmount! + ' ' + receiveAmountCrypto,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.extension<CakeTextTheme>()!
.dateSectionRowColor))
: Container(),
])
],
))
],
),
));
}

Color _statusColor(BuildContext context, TradeState status) {
switch (status) {
case TradeState.complete:
case TradeState.completed:
case TradeState.finished:
case TradeState.success:
return PaletteDark.brightGreen;
case TradeState.failed:
case TradeState.expired:
case TradeState.notFound:
return Palette.darkRed;
default:
return const Color(0xffff6600);
}
}
}
Loading
Loading