diff --git a/assets/translations/en.json b/assets/translations/en.json index 2237c7d7d1..31e9b2a67a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -507,6 +507,8 @@ "orderBookNoAsks": "No asks found", "orderBookNoBids": "No bids found", "orderBookEmpty": "Orderbook is empty", + "dexNoSwapOffers": "No swap offers available for the selected asset.", + "bridgeNoCrossNetworkRoutes": "No cross-network routes found for this asset.", "freshAddress": "Fresh address", "userActionRequired": "User action required", "unknown": "Unknown", diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index 08d98de146..bfafab3fb2 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -44,6 +44,11 @@ const Duration kPerformanceLogInterval = Duration(minutes: 1); /// - Balance and price update polling const bool kDebugElectrumLogs = true; +/// Temporary failure simulation toggles for testing UI/flows. +/// Guarded by kDebugMode in calling sites. +const bool kSimulateBestOrdersFailure = false; +const double kSimulatedBestOrdersFailureRate = 0.5; // 50% + // This information is here because it is not contextual and is branded. // Names of their own are not localized. Also, the application is initialized before // the localization package is initialized. diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart index 894dcab392..354dee5825 100644 --- a/lib/bloc/bridge_form/bridge_bloc.dart +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -238,6 +238,17 @@ class BridgeBloc extends Bloc { ), ); + // Before login, show 0.00 instead of spinner + if (!_isLoggedIn) { + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => null, + ), + ); + return; + } + _autoActivateCoin(event.coin.abbr); _subscribeMaxSellAmount(); @@ -392,22 +403,27 @@ class BridgeBloc extends Bloc { return; } - if (state.availableBalanceState == AvailableBalanceState.initial || - event.setLoadingStatus) { + // If not logged in, show 0.00 (unavailable) and skip spinner + if (!_isLoggedIn) { emit( state.copyWith( - availableBalanceState: () => AvailableBalanceState.loading, + availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => null, ), ); + return; } - if (!_isLoggedIn) { + if (state.availableBalanceState == AvailableBalanceState.initial || + event.setLoadingStatus) { emit( state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, + availableBalanceState: () => AvailableBalanceState.loading, ), ); - } else { + } + + { Rational? maxSellAmount = await _dexRepository.getMaxTakerVolume( state.sellCoin!.abbr, ); diff --git a/lib/bloc/dex_repository.dart b/lib/bloc/dex_repository.dart index 8e1384c05c..afceae9f20 100644 --- a/lib/bloc/dex_repository.dart +++ b/lib/bloc/dex_repository.dart @@ -1,3 +1,5 @@ +import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:rational/rational.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; @@ -19,6 +21,8 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.da import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/model/main_menu_value.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/services/mappers/trade_preimage_mappers.dart'; @@ -54,9 +58,12 @@ class DexRepository { swapMethod: swapMethod, max: max, ); - final ApiResponse> response = - await _mm2Api.getTradePreimage(request); + final ApiResponse< + TradePreimageRequest, + TradePreimageResponseResult, + Map + > + response = await _mm2Api.getTradePreimage(request); final Map? error = response.error; final TradePreimageResponseResult? result = response.result; @@ -88,8 +95,9 @@ class DexRepository { } Future getMaxTakerVolume(String coinAbbr) async { - final MaxTakerVolResponse? response = - await _mm2Api.getMaxTakerVolume(MaxTakerVolRequest(coin: coinAbbr)); + final MaxTakerVolResponse? response = await _mm2Api.getMaxTakerVolume( + MaxTakerVolRequest(coin: coinAbbr), + ); if (response == null) { return null; } @@ -98,8 +106,9 @@ class DexRepository { } Future getMaxMakerVolume(String coinAbbr) async { - final MaxMakerVolResponse? response = - await _mm2Api.getMaxMakerVolume(MaxMakerVolRequest(coin: coinAbbr)); + final MaxMakerVolResponse? response = await _mm2Api.getMaxMakerVolume( + MaxMakerVolRequest(coin: coinAbbr), + ); if (response == null) { return null; } @@ -108,8 +117,9 @@ class DexRepository { } Future getMinTradingVolume(String coinAbbr) async { - final MinTradingVolResponse? response = - await _mm2Api.getMinTradingVol(MinTradingVolRequest(coin: coinAbbr)); + final MinTradingVolResponse? response = await _mm2Api.getMinTradingVol( + MinTradingVolRequest(coin: coinAbbr), + ); if (response == null) { return null; } @@ -122,28 +132,82 @@ class DexRepository { } Future getBestOrders(BestOrdersRequest request) async { + // Only allow best_orders when user is on Swap (DEX) or Bridge pages + final MainMenuValue current = routingState.selectedMenu; + final bool isTradingPage = + current == MainMenuValue.dex || current == MainMenuValue.bridge; + if (!isTradingPage) { + // Not an error – we intentionally suppress best_orders away from trading pages + return BestOrders(result: >{}); + } + + // Testing aid: opt-in random failure in debug mode + if (kDebugMode && + kSimulateBestOrdersFailure && + Random().nextDouble() < kSimulatedBestOrdersFailureRate) { + return BestOrders( + error: TextError(error: 'Simulated best_orders failure (debug)'), + ); + } + Map? response; try { response = await _mm2Api.getBestOrders(request); - } catch (e) { + } catch (e, s) { + log( + 'best_orders request failed: $e', + trace: s, + path: 'api => getBestOrders', + isError: true, + ).ignore(); return BestOrders(error: TextError.fromString(e.toString())); } - final isErrorResponse = - (response?['error'] as String?)?.isNotEmpty ?? false; - final hasResult = - (response?['result'] as Map?)?.isNotEmpty ?? false; + if (response == null) { + return BestOrders( + error: TextError(error: 'best_orders returned null response'), + ); + } + + final String? errorText = response['error'] as String?; + if (errorText != null && errorText.isNotEmpty) { + // Map known "no orders" network condition to empty result so UI shows a + // graceful "Nothing found" instead of an error panel. + final String? errorType = response['error_type'] as String?; + final String? errorPath = response['error_path'] as String?; + final bool isNoOrdersNetworkCondition = + errorPath == 'best_orders' && + errorType == 'P2PError' && + errorText.contains('No response from any peer'); - if (isErrorResponse) { - return BestOrders(error: TextError(error: response!['error']!)); + // Mm2Api.getBestOrders may wrap MM2 errors in an Exception() during + // retry handling, yielding text like: "Exception: No response from any peer" + // (without error_type/error_path). Treat these as "no orders" as well. + final bool isWrappedNoOrdersText = errorText.toLowerCase().contains( + 'no response from any peer', + ); + + if (isNoOrdersNetworkCondition || isWrappedNoOrdersText) { + return BestOrders(result: >{}); + } + + log( + 'best_orders returned error: $errorText', + path: 'api => getBestOrders', + isError: true, + ).ignore(); + return BestOrders(error: TextError(error: errorText)); } - if (!hasResult) { - return BestOrders(error: TextError(error: 'Orders not found!')); + final Map? result = + response['result'] as Map?; + if (result == null || result.isEmpty) { + // No error and no result → no liquidity available + return BestOrders(result: >{}); } try { - return BestOrders.fromJson(response!); + return BestOrders.fromJson(response); } catch (e, s) { log('Error parsing best_orders response: $e', trace: s, isError: true); @@ -156,8 +220,9 @@ class DexRepository { } Future getSwapStatus(String swapUuid) async { - final response = - await _mm2Api.getSwapStatus(MySwapStatusReq(uuid: swapUuid)); + final response = await _mm2Api.getSwapStatus( + MySwapStatusReq(uuid: swapUuid), + ); if (response['error'] != null) { throw TextError(error: response['error']); diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index dc2b982f74..44e2c3e911 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -318,6 +318,17 @@ class TakerBloc extends Bloc { add(TakerUpdateBestOrders(autoSelectOrderAbbr: event.autoSelectOrderAbbr)); + // Before login, show 0.00 instead of spinner + if (!_isLoggedIn) { + emit( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => null, + ), + ); + return; + } + await _autoActivateCoin(state.sellCoin?.abbr); _subscribeMaxSellAmount(); add(TakerGetMinSellAmount()); @@ -440,6 +451,17 @@ class TakerBloc extends Bloc { _maxSellAmountTimer?.cancel(); return; } + // If not logged in, show 0.00 (unavailable) and skip spinner + if (!_isLoggedIn) { + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => null, + ), + ); + return; + } + if (state.availableBalanceState == AvailableBalanceState.initial || event.setLoadingStatus) { emitter( @@ -469,34 +491,26 @@ class TakerBloc extends Bloc { return; } - if (!_isLoggedIn) { + Rational? maxSellAmount = await _dexRepo.getMaxTakerVolume( + state.sellCoin!.abbr, + ); + if (maxSellAmount != null) { emitter( state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, ), ); } else { - Rational? maxSellAmount = await _dexRepo.getMaxTakerVolume( - state.sellCoin!.abbr, + maxSellAmount = await _frequentlyGetMaxTakerVolume(); + emitter( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + ), ); - if (maxSellAmount != null) { - emitter( - state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: () => AvailableBalanceState.success, - ), - ); - } else { - maxSellAmount = await _frequentlyGetMaxTakerVolume(); - emitter( - state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: maxSellAmount == null - ? () => AvailableBalanceState.failure - : () => AvailableBalanceState.success, - ), - ); - } } } catch (e, s) { _log.severe('Failed to update max sell amount', e, s); diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart index 792f58f6d6..fe7443bd26 100644 --- a/lib/blocs/maker_form_bloc.dart +++ b/lib/blocs/maker_form_bloc.dart @@ -246,7 +246,10 @@ class MakerFormBloc implements BlocBase { Future _updateMaxSellAmountListener() async { _maxSellAmountTimer?.cancel(); maxSellAmount = null; - availableBalanceState = AvailableBalanceState.loading; + // Only show loading spinner when signed in + final bool isSignedIn = await kdfSdk.auth.isSignedIn(); + availableBalanceState = + isSignedIn ? AvailableBalanceState.loading : AvailableBalanceState.unavailable; isMaxActive = false; await _updateMaxSellAmount(); @@ -259,10 +262,6 @@ class MakerFormBloc implements BlocBase { Future _updateMaxSellAmount() async { final Coin? coin = sellCoin; - if (availableBalanceState == AvailableBalanceState.initial) { - availableBalanceState = AvailableBalanceState.loading; - } - final bool isSignedIn = await kdfSdk.auth.isSignedIn(); if (!isSignedIn) { maxSellAmount = null; @@ -270,6 +269,10 @@ class MakerFormBloc implements BlocBase { return; } + if (availableBalanceState == AvailableBalanceState.initial) { + availableBalanceState = AvailableBalanceState.loading; + } + if (coin == null) { maxSellAmount = null; availableBalanceState = AvailableBalanceState.unavailable; diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 22a4045ab6..9bd6b5edfa 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -503,6 +503,8 @@ abstract class LocaleKeys { static const orderBookNoAsks = 'orderBookNoAsks'; static const orderBookNoBids = 'orderBookNoBids'; static const orderBookEmpty = 'orderBookEmpty'; + static const dexNoSwapOffers = 'dexNoSwapOffers'; + static const bridgeNoCrossNetworkRoutes = 'bridgeNoCrossNetworkRoutes'; static const freshAddress = 'freshAddress'; static const userActionRequired = 'userActionRequired'; static const unknown = 'unknown'; diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index c5f2114940..a514fba5c2 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -241,7 +241,27 @@ class Mm2Api { Future?> getBestOrders(BestOrdersRequest request) async { try { - return await _mm2.call(request) as Map?; + final Map? response = await retry( + () async { + final Map? resp = + await _mm2.call(request) as Map?; + if (resp == null) { + throw Exception('null response'); + } + if (resp['error'] != null) { + // Throw to allow a quick retry during transient auth/session races + throw Exception(resp['error'].toString()); + } + return resp; + }, + maxAttempts: 4, + backoffStrategy: const LinearBackoff( + initialDelay: Duration(milliseconds: 500), + increment: Duration(milliseconds: 250), + maxDelay: Duration(seconds: 3), + ), + ); + return response; } catch (e, s) { log( 'Error getting best orders ${request.coin}: $e', diff --git a/lib/views/bridge/view/table/bridge_nothing_found.dart b/lib/views/bridge/view/table/bridge_nothing_found.dart index 55b88259df..4ddab3854e 100644 --- a/lib/views/bridge/view/table/bridge_nothing_found.dart +++ b/lib/views/bridge/view/table/bridge_nothing_found.dart @@ -1,18 +1,38 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/model/main_menu_value.dart'; class BridgeNothingFound extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(0, 30, 0, 20), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - LocaleKeys.nothingFound.tr(), - style: Theme.of(context).textTheme.bodySmall, + Text( + LocaleKeys.bridgeNoCrossNetworkRoutes.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 8), + UiSimpleButton( + onPressed: () { + routingState.selectedMenu = MainMenuValue.dex; + routingState.dexState.orderType = 'maker'; + }, + child: Text( + LocaleKeys.makerOrder.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), ), ], ), diff --git a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart index f8a9b812ea..cce1dcc260 100644 --- a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart @@ -11,10 +11,11 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; import 'package:web_dex/model/authorize_mode.dart'; -import 'package:web_dex/views/dex/simple/form/tables/nothing_found.dart'; import 'package:web_dex/views/dex/simple/form/tables/orders_table/grouped_list_view.dart'; import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/model/main_menu_value.dart'; class OrdersTableContent extends StatelessWidget { const OrdersTableContent({ @@ -61,7 +62,9 @@ class OrdersTableContent extends StatelessWidget { .testCoinsEnabled, ); - if (orders.isEmpty) return const NothingFound(); + if (orders.isEmpty) { + return const _NoOrdersCta(); + } return GroupedListView( items: orders, @@ -116,3 +119,37 @@ class _ErrorMessage extends StatelessWidget { ); } } + +class _NoOrdersCta extends StatelessWidget { + const _NoOrdersCta(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 30, 12, 20), + alignment: const Alignment(0, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.dexNoSwapOffers.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + UiSimpleButton( + child: Text( + // Reuse existing localization for Maker order label + LocaleKeys.makerOrder.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + onPressed: () { + routingState.selectedMenu = MainMenuValue.dex; + routingState.dexState.orderType = 'maker'; + }, + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form.dart b/lib/views/dex/simple/form/taker/taker_form.dart index 9beb06e259..44c50f88b0 100644 --- a/lib/views/dex/simple/form/taker/taker_form.dart +++ b/lib/views/dex/simple/form/taker/taker_form.dart @@ -29,6 +29,16 @@ class _TakerFormState extends State { takerBloc.add(TakerSetDefaults()); takerBloc.add(TakerSetWalletIsReady(authBlocState.isSignedIn)); routingState.dexState.addListener(_consumeRouteParameters); + // If entering the swap page while already authenticated, ensure the + // available balance initializes without waiting for further user action. + if (authBlocState.isSignedIn) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final currentSellCoin = takerBloc.state.sellCoin; + if (currentSellCoin != null) { + takerBloc.add(TakerSetSellCoin(currentSellCoin)); + } + }); + } super.initState(); } @@ -87,6 +97,21 @@ class _TakerFormState extends State { listener: (context, state) { final takerBloc = context.read(); takerBloc.add(TakerSetWalletIsReady(state.isSignedIn)); + + // When the user becomes authenticated while on the swap page, + // refresh the available balance/max sell amount immediately so it + // doesn't remain at 0.00 until the user re-selects the sell coin. + if (state.isSignedIn) { + final currentSellCoin = takerBloc.state.sellCoin; + if (currentSellCoin != null) { + // Re-dispatching the same sell coin sets up periodic polling + // and triggers max-sell/min-sell refresh without user action. + takerBloc.add(TakerSetSellCoin(currentSellCoin)); + } else { + // Ensure defaults are set so the form initializes properly. + takerBloc.add(TakerSetDefaults()); + } + } }, child: const TakerFormLayout(), );