diff --git a/assets/translations/en.json b/assets/translations/en.json index 54764c231b..39588e5e2d 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/dex_repository.dart b/lib/bloc/dex_repository.dart index 68a0f7f11c..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'; @@ -56,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; @@ -90,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; } @@ -100,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; } @@ -110,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; } @@ -129,9 +137,19 @@ class DexRepository { 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); @@ -142,30 +160,54 @@ class DexRepository { path: 'api => getBestOrders', isError: true, ).ignore(); - return BestOrders(result: >{}); + 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'); + + // 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: >{}); + } - if (isErrorResponse) { log( - 'best_orders returned error: ${response!['error']}', + 'best_orders returned error: $errorText', path: 'api => getBestOrders', isError: true, ).ignore(); - return BestOrders(result: >{}); + return BestOrders(error: TextError(error: errorText)); } - if (!hasResult) { - // Treat empty or missing result as no orders available + 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); @@ -178,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/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/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'; + }, + ), + ], + ), + ); + } +}