Skip to content
Merged
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
2 changes: 2 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions lib/app_config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 65 additions & 22 deletions lib/bloc/dex_repository.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,9 +58,12 @@ class DexRepository {
swapMethod: swapMethod,
max: max,
);
final ApiResponse<TradePreimageRequest, TradePreimageResponseResult,
Map<String, dynamic>> response =
await _mm2Api.getTradePreimage(request);
final ApiResponse<
TradePreimageRequest,
TradePreimageResponseResult,
Map<String, dynamic>
>
response = await _mm2Api.getTradePreimage(request);

final Map<String, dynamic>? error = response.error;
final TradePreimageResponseResult? result = response.result;
Expand Down Expand Up @@ -90,8 +95,9 @@ class DexRepository {
}

Future<Rational?> 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;
}
Expand All @@ -100,8 +106,9 @@ class DexRepository {
}

Future<Rational?> 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;
}
Expand All @@ -110,8 +117,9 @@ class DexRepository {
}

Future<Rational?> 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;
}
Expand All @@ -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: <String, List<BestOrder>>{});
}

// 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<String, dynamic>? response;
try {
response = await _mm2Api.getBestOrders(request);
Expand All @@ -142,30 +160,54 @@ class DexRepository {
path: 'api => getBestOrders',
isError: true,
).ignore();
return BestOrders(result: <String, List<BestOrder>>{});
return BestOrders(error: TextError.fromString(e.toString()));
}

final isErrorResponse =
(response?['error'] as String?)?.isNotEmpty ?? false;
final hasResult =
(response?['result'] as Map<String, dynamic>?)?.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: <String, List<BestOrder>>{});
}

if (isErrorResponse) {
log(
'best_orders returned error: ${response!['error']}',
'best_orders returned error: $errorText',
path: 'api => getBestOrders',
isError: true,
).ignore();
return BestOrders(result: <String, List<BestOrder>>{});
return BestOrders(error: TextError(error: errorText));
}

if (!hasResult) {
// Treat empty or missing result as no orders available
final Map<String, dynamic>? result =
response['result'] as Map<String, dynamic>?;
if (result == null || result.isEmpty) {
// No error and no result → no liquidity available
return BestOrders(result: <String, List<BestOrder>>{});
}

try {
return BestOrders.fromJson(response!);
return BestOrders.fromJson(response);
} catch (e, s) {
log('Error parsing best_orders response: $e', trace: s, isError: true);

Expand All @@ -178,8 +220,9 @@ class DexRepository {
}

Future<Swap> 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']);
Expand Down
2 changes: 2 additions & 0 deletions lib/generated/codegen_loader.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
28 changes: 24 additions & 4 deletions lib/views/bridge/view/table/bridge_nothing_found.dart
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -61,7 +62,9 @@ class OrdersTableContent extends StatelessWidget {
.testCoinsEnabled,
);

if (orders.isEmpty) return const NothingFound();
if (orders.isEmpty) {
return const _NoOrdersCta();
}

return GroupedListView<BestOrder>(
items: orders,
Expand Down Expand Up @@ -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';
},
),
],
),
);
}
}
Loading