diff --git a/lib/models/exchange/response_objects/trade.dart b/lib/models/exchange/response_objects/trade.dart index a00d531e38..1e7cfc46bd 100644 --- a/lib/models/exchange/response_objects/trade.dart +++ b/lib/models/exchange/response_objects/trade.dart @@ -290,6 +290,26 @@ class Trade { ); } + static const Set terminalStatuses = { + "finished", + "completed", + "success", + "failed", + "error", + "refunded", + "overdue", + "expired", + "closed", + "cancelled", + "canceled", + "not found", + }; + + static bool isTerminalStatusValue(String status) => + terminalStatuses.contains(status.trim().toLowerCase()); + + bool get isTerminalStatus => isTerminalStatusValue(status); + @override String toString() { return toMap().toString(); diff --git a/lib/pages/exchange_view/delete_trade_confirmation_dialog.dart b/lib/pages/exchange_view/delete_trade_confirmation_dialog.dart new file mode 100644 index 0000000000..3a55f0f21c --- /dev/null +++ b/lib/pages/exchange_view/delete_trade_confirmation_dialog.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; + +Future showDeleteTradeConfirmationDialog({ + required BuildContext context, + required bool isTerminalStatus, +}) async { + return await showDialog( + context: context, + useSafeArea: true, + builder: (_) => + DeleteTradeConfirmationDialog(isTerminalStatus: isTerminalStatus), + ) ?? + false; +} + +class DeleteTradeConfirmationDialog extends StatelessWidget { + const DeleteTradeConfirmationDialog({ + super.key, + required this.isTerminalStatus, + }); + + final bool isTerminalStatus; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return SDialog( + padding: EdgeInsets.all(isDesktop ? 32 : 20), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 386), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + isTerminalStatus + ? "Delete this trade?" + : "Delete an active trade?", + style: isDesktop + ? STextStyles.desktopH3(context) + : STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 16), + Text( + isTerminalStatus + ? "This trade will be permanently deleted." + : "This trade is still active. Deleting it will remove it " + "from this device, so you will no longer be able to " + "track its status here.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SecondaryButton( + key: const Key("cancelDeleteTradeButton"), + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + key: const Key("confirmDeleteTradeButton"), + label: "Delete", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index b5799e039a..f3e269fc2d 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -21,6 +21,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../app_config.dart'; import '../../models/exchange/change_now/cn_exchange_transaction_status.dart'; +import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/models/blockchain_data/transaction.dart'; import '../../models/isar/stack_theme.dart'; import '../../notifications/show_flush_bar.dart'; @@ -44,6 +45,7 @@ import '../../utilities/assets.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; import '../../utilities/format.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -59,8 +61,10 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../wallet_view/transaction_views/edit_note_view.dart'; import '../wallet_view/transaction_views/transaction_details_view.dart' as tdv; +import 'delete_trade_confirmation_dialog.dart'; import 'edit_trade_note_view.dart'; import 'send_from_view.dart'; +import 'trade_operation_guard.dart'; class TradeDetailsView extends ConsumerStatefulWidget { const TradeDetailsView({ @@ -89,34 +93,88 @@ class _TradeDetailsViewState extends ConsumerState { late final ClipboardInterface clipboard; late final Transaction? transactionIfSentFromStack; late final String? walletId; + final _operationGuard = TradeOperationGuard(); @override - initState() { + void initState() { + super.initState(); tradeId = widget.tradeId; clipboard = widget.clipboard; transactionIfSentFromStack = widget.transactionIfSentFromStack; walletId = widget.walletId; if (ref.read(prefsChangeNotifierProvider).externalCalls) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final trade = ref - .read(tradesServiceProvider) - .trades - .firstWhere((e) => e.tradeId == tradeId); - - if (mounted && trade.exchangeName != "Majestic Bank") { - final exchange = Exchange.fromName(trade.exchangeName); - final response = await exchange.updateTrade(trade); - - if (mounted && response.value != null) { - await ref - .read(tradesServiceProvider) - .edit(trade: response.value!, shouldNotifyListeners: true); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_operationGuard.deletionRequested) { + _operationGuard.trackStatusRefresh(_refreshTrade()); } }); } - super.initState(); + } + + Future _refreshTrade() async { + try { + final tradesService = ref.read(tradesServiceProvider); + final trade = tradesService.get(tradeId); + if (!mounted || trade == null || trade.exchangeName == "Majestic Bank") { + return; + } + + final exchange = Exchange.fromName(trade.exchangeName); + final response = await exchange.updateTrade(trade); + + if (!mounted || + _operationGuard.deletionRequested || + response.value == null || + tradesService.get(tradeId) == null) { + return; + } + + await tradesService.edit( + trade: response.value!, + shouldNotifyListeners: true, + ); + } catch (e, s) { + Logging.instance.e("Failed to refresh trade", error: e, stackTrace: s); + } + } + + Future _deleteTrade(Trade trade) async { + final shouldDelete = await showDeleteTradeConfirmationDialog( + context: context, + isTerminalStatus: trade.isTerminalStatus, + ); + if (!shouldDelete || !mounted) { + return; + } + + final tradesService = ref.read(tradesServiceProvider); + setState(() {}); + + try { + final deleted = await _operationGuard.deleteAfterRefresh( + () => tradesService.delete(trade: trade, shouldNotifyListeners: true), + ); + if (deleted && mounted) { + // Desktop hosts this view in a nested Navigator that owns a single + // route; popping that one empties it and leaves the hosting dialog's + // barrier on screen. The root navigator owns the visible route in + // every host, mobile included. + Navigator.of(context, rootNavigator: true).pop(); + } + } catch (e, s) { + Logging.instance.e("Failed to delete trade", error: e, stackTrace: s); + if (mounted) { + setState(() {}); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Failed to delete trade. Please try again.", + context: context, + ), + ); + } + } } String _fetchIconAssetForStatus(String statusString, IThemeAssets assets) { @@ -166,10 +224,11 @@ class _TradeDetailsViewState extends ConsumerState { transactionIfSentFromStack != null && walletId != null; final trade = ref.watch( - tradesServiceProvider.select( - (value) => value.trades.firstWhere((e) => e.tradeId == tradeId), - ), + tradesServiceProvider.select((value) => value.get(tradeId)), ); + if (trade == null) { + return const SizedBox.shrink(); + } final bool hasTx = sentFromStack || @@ -214,6 +273,8 @@ class _TradeDetailsViewState extends ConsumerState { trade.status == "wait" || trade.status == "Waiting"); + void deleteTrade() => unawaited(_deleteTrade(trade)); + return ConditionalParent( condition: !isDesktop, builder: (child) => Background( @@ -234,6 +295,36 @@ class _TradeDetailsViewState extends ConsumerState { "Trade details", style: STextStyles.navBarTitle(context), ), + actions: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 10), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("tradeDetailsViewDeleteTradeButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.trash, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.accentColorDark, + BlendMode.srcIn, + ), + width: 20, + height: 20, + ), + onPressed: _operationGuard.deletionRequested + ? null + : deleteTrade, + ), + ), + ), + ], ), body: SafeArea( child: Padding( @@ -297,6 +388,13 @@ class _TradeDetailsViewState extends ConsumerState { ); }, ), + const SizedBox(height: 16), + SecondaryButton( + label: "Delete trade", + buttonHeight: ButtonHeight.l, + enabled: !_operationGuard.deletionRequested, + onPressed: deleteTrade, + ), const SizedBox(height: 32), ], ), diff --git a/lib/pages/exchange_view/trade_operation_guard.dart b/lib/pages/exchange_view/trade_operation_guard.dart new file mode 100644 index 0000000000..6fc974dd6f --- /dev/null +++ b/lib/pages/exchange_view/trade_operation_guard.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +class TradeOperationGuard { + /// A refresh must finish first so its database write cannot follow deletion. + /// Bounded because the exchange HTTP layer sets no response timeout, so a + /// stalled provider would otherwise block deletion forever. + static const refreshWaitTimeout = Duration(seconds: 5); + + Future _statusRefresh = Future.value(); + + bool _deletionRequested = false; + + bool get deletionRequested => _deletionRequested; + + void trackStatusRefresh(Future refresh) { + if (!_deletionRequested) { + _statusRefresh = refresh; + } + } + + Future deleteAfterRefresh(Future Function() delete) async { + if (_deletionRequested) { + return false; + } + + _deletionRequested = true; + try { + await _statusRefresh.timeout(refreshWaitTimeout, onTimeout: () {}); + await delete(); + return true; + } catch (_) { + _deletionRequested = false; + rethrow; + } + } +} diff --git a/test/models/exchange/trade_test.dart b/test/models/exchange/trade_test.dart new file mode 100644 index 0000000000..0367641cc0 --- /dev/null +++ b/test/models/exchange/trade_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; + +void main() { + group("Trade terminal status", () { + test("recognizes provider terminal statuses without case sensitivity", () { + for (final status in [ + "Finished", + "completed", + "SUCCESS", + "Failed", + "error", + "Refunded", + "overdue", + "Expired", + "Closed", + "cancelled", + "CANCELED", + "Not found", + " finished ", + ]) { + expect(Trade.isTerminalStatusValue(status), isTrue, reason: status); + } + }); + + test("keeps active and refund-in-progress statuses non-terminal", () { + for (final status in [ + "new", + "waiting", + "confirming", + "exchanging", + "sending", + "refund", + "unknown", + ]) { + expect(Trade.isTerminalStatusValue(status), isFalse, reason: status); + } + }); + + test("exposes terminal status on a trade", () { + expect(_tradeWithStatus(" Finished ").isTerminalStatus, isTrue); + expect(_tradeWithStatus("refund").isTerminalStatus, isFalse); + }); + }); +} + +Trade _tradeWithStatus(String status) => Trade( + uuid: "uuid", + tradeId: "tradeId", + rateType: "fixed", + direction: "direct", + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + updatedAt: DateTime.fromMillisecondsSinceEpoch(0), + payInCurrency: "btc", + payInAmount: "1", + payInAddress: "payInAddress", + payInNetwork: "btc", + payInExtraId: "", + payInTxid: "", + payOutCurrency: "xmr", + payOutAmount: "1", + payOutAddress: "payOutAddress", + payOutNetwork: "xmr", + payOutExtraId: "", + payOutTxid: "", + refundAddress: "refundAddress", + refundExtraId: "", + status: status, + exchangeName: "exchange", +); diff --git a/test/pages/exchange_view/trade_operation_guard_test.dart b/test/pages/exchange_view/trade_operation_guard_test.dart new file mode 100644 index 0000000000..5993cb2e57 --- /dev/null +++ b/test/pages/exchange_view/trade_operation_guard_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages/exchange_view/trade_operation_guard.dart'; + +void main() { + test("waits for an in-flight refresh before deleting", () async { + final refresh = Completer(); + final guard = TradeOperationGuard()..trackStatusRefresh(refresh.future); + var deleted = false; + + final result = guard.deleteAfterRefresh(() async => deleted = true); + + expect(guard.deletionRequested, isTrue); + expect(deleted, isFalse); + + refresh.complete(); + expect(await result, isTrue); + expect(deleted, isTrue); + }); + + testWidgets("bounds the wait so a stalled refresh cannot block deletion", ( + tester, + ) async { + final guard = TradeOperationGuard() + ..trackStatusRefresh(Completer().future); + var deleted = false; + bool? outcome; + + unawaited( + guard + .deleteAfterRefresh(() async => deleted = true) + .then((value) => outcome = value), + ); + + await tester.pump(); + expect(deleted, isFalse); + + await tester.pump( + TradeOperationGuard.refreshWaitTimeout + const Duration(seconds: 1), + ); + await tester.pump(); + + expect(deleted, isTrue); + expect(outcome, isTrue); + }); + + test("allows retry after a failed delete", () async { + final guard = TradeOperationGuard(); + + await expectLater( + guard.deleteAfterRefresh(() async => throw Exception("database error")), + throwsException, + ); + expect(guard.deletionRequested, isFalse); + expect(await guard.deleteAfterRefresh(() async {}), isTrue); + }); + + test("ignores duplicate delete requests", () async { + final refresh = Completer(); + final lateRefresh = Completer(); + final guard = TradeOperationGuard()..trackStatusRefresh(refresh.future); + + final first = guard.deleteAfterRefresh(() async {}); + guard.trackStatusRefresh(lateRefresh.future); + expect(await guard.deleteAfterRefresh(() async {}), isFalse); + + refresh.complete(); + expect(await first, isTrue); + }); +} diff --git a/test/widget_tests/delete_trade_confirmation_dialog_test.dart b/test/widget_tests/delete_trade_confirmation_dialog_test.dart new file mode 100644 index 0000000000..27bfb857dd --- /dev/null +++ b/test/widget_tests/delete_trade_confirmation_dialog_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/delete_trade_confirmation_dialog.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/dialogs/s_dialog.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + tearDown(() => Util.screenWidth = null); + + testWidgets("confirmation closes only its own route", (tester) async { + bool? result; + + await tester.pumpWidget( + _TestApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + body: Builder( + builder: (detailsContext) => Column( + children: [ + const Text("Trade details route"), + TextButton( + onPressed: () async { + result = + await showDeleteTradeConfirmationDialog( + context: detailsContext, + isTerminalStatus: true, + ); + }, + child: const Text("Delete trade"), + ), + ], + ), + ), + ), + ), + ); + }, + child: const Text("Open trade details"), + ), + ), + ), + ), + ); + + await tester.tap(find.text("Open trade details")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Delete trade")); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key("confirmDeleteTradeButton"))); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(find.text("Trade details route"), findsOneWidget); + expect(find.byType(DeleteTradeConfirmationDialog), findsNothing); + }); + + testWidgets("cancel returns false", (tester) async { + bool? result; + + await tester.pumpWidget( + _TestApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showDeleteTradeConfirmationDialog( + context: context, + isTerminalStatus: false, + ); + }, + child: const Text("Open"), + ), + ), + ), + ); + + await tester.tap(find.text("Open")); + await tester.pumpAndSettle(); + expect(find.text("Delete an active trade?"), findsOneWidget); + await tester.tap(find.byKey(const Key("cancelDeleteTradeButton"))); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + + testWidgets("uses a scrollable content-sized dialog on a narrow screen", ( + tester, + ) async { + Util.screenWidth = 320; + await tester.binding.setSurfaceSize(const Size(320, 480)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const _TestApp( + textScaleFactor: 2, + home: DeleteTradeConfirmationDialog(isTerminalStatus: false), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SDialog), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); +} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.home, this.textScaleFactor = 1}); + + final Widget home; + final double textScaleFactor; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScaleFactor)), + child: child!, + ), + home: home, + ); + } +} diff --git a/test/widget_tests/trade_details_view_test.dart b/test/widget_tests/trade_details_view_test.dart new file mode 100644 index 0000000000..24ff9dc969 --- /dev/null +++ b/test/widget_tests/trade_details_view_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/trade_details_view.dart'; +import 'package:stackwallet/providers/exchange/trade_note_service_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/trades_service_provider.dart'; +import 'package:stackwallet/route_generator.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; + +import '../sample_data/theme_json.dart'; +import '../screen_tests/exchange/exchange_view_test.mocks.dart'; + +/// Every asset getter resolves to a path flutter_svg will fail to load in a +/// test bundle; the widgets under test only need it to be non null. +class _FakeAssets implements IThemeAssets { + @override + String get bellNew => ""; + @override + String get buy => ""; + @override + String get exchange => ""; + @override + String get personaIncognito => ""; + @override + String get personaEasy => ""; + @override + String get stack => ""; + @override + String get stackIcon => ""; + @override + String get receive => ""; + @override + String get receivePending => ""; + @override + String get receiveCancelled => ""; + @override + String get send => ""; + @override + String get sendPending => ""; + @override + String get sendCancelled => ""; + @override + String get themeSelector => ""; + @override + String get themePreview => ""; + @override + String get txExchange => ""; + @override + String get txExchangePending => ""; + @override + String get txExchangeFailed => ""; + @override + String? get loadingGif => null; + @override + String? get background => null; +} + +Trade _trade() => Trade( + uuid: "uuid", + tradeId: "tradeId", + rateType: "fixed", + direction: "direct", + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + updatedAt: DateTime.fromMillisecondsSinceEpoch(0), + payInCurrency: "zzz", + payInAmount: "1", + payInAddress: "payInAddress", + payInNetwork: "zzz", + payInExtraId: "", + payInTxid: "", + payOutCurrency: "zzz", + payOutAmount: "1", + payOutAddress: "payOutAddress", + payOutNetwork: "zzz", + payOutExtraId: "", + payOutTxid: "", + refundAddress: "refundAddress", + refundExtraId: "", + status: "finished", + exchangeName: "ChangeNOW", +); + +void main() { + late MockPrefs prefs; + late MockTradesService trades; + late MockTradeNotesService notes; + + setUp(() { + prefs = MockPrefs(); + trades = MockTradesService(); + notes = MockTradeNotesService(); + when(prefs.externalCalls).thenAnswer((_) => false); + when(notes.getNote(tradeId: anyNamed("tradeId"))).thenAnswer((_) => ""); + + var deleted = false; + when(trades.get(any)).thenAnswer((_) => deleted ? null : _trade()); + when( + trades.delete( + trade: anyNamed("trade"), + shouldNotifyListeners: anyNamed("shouldNotifyListeners"), + ), + ).thenAnswer((_) async => deleted = true); + }); + + tearDown(() => Util.screenWidth = null); + + Widget app(Widget home) => ProviderScope( + overrides: [ + prefsChangeNotifierProvider.overrideWithProvider( + ChangeNotifierProvider((_) => prefs), + ), + tradesServiceProvider.overrideWithProvider( + ChangeNotifierProvider((_) => trades), + ), + tradeNoteServiceProvider.overrideWithProvider( + ChangeNotifierProvider((_) => notes), + ), + themeAssetsProvider.overrideWithProvider( + StateProvider((_) => _FakeAssets()), + ), + themeProvider.overrideWithProvider( + StateProvider((_) => StackTheme.fromJson(json: lightThemeJsonMap)), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: home, + ), + ); + + // Mirrors the production desktop hosts (desktop_trade_history.dart:128, + // desktop_all_trades_view.dart:396, transactions_list.dart:114): the details + // view lives in a nested Navigator owning a single route, inside a + // showDialog route on the root navigator. + Widget desktopNestedHost() => Builder( + builder: (ctx) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => showDialog( + context: ctx, + builder: (_) => Navigator( + initialRoute: TradeDetailsView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) => [ + FadePageRoute( + const DesktopDialog( + maxHeight: null, + maxWidth: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: SingleChildScrollView( + primary: false, + child: TradeDetailsView( + tradeId: "tradeId", + transactionIfSentFromStack: null, + walletName: null, + walletId: null, + ), + ), + ), + ], + ), + ), + const RouteSettings(name: TradeDetailsView.routeName), + ), + ], + ), + ), + child: const Text("open details"), + ), + ), + ), + ); + + testWidgets("desktop delete dismisses the hosting dialog", (tester) async { + expect(Util.isDesktop, isTrue, reason: "test host must be desktop"); + await tester.binding.setSurfaceSize(const Size(1400, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(app(desktopNestedHost())); + // MaterialApp's own home route already owns one (transparent) barrier. + final baselineBarriers = find.byType(ModalBarrier).evaluate().length; + + await tester.tap(find.text("open details")); + await tester.pumpAndSettle(); + tester.takeException(); // missing svg assets + expect(find.byType(TradeDetailsView), findsOneWidget); + expect(find.byType(DesktopDialog), findsOneWidget); + + final deleteButton = find.widgetWithText(SecondaryButton, "Delete trade"); + await tester.ensureVisible(deleteButton); + await tester.pumpAndSettle(); + tester.takeException(); + await tester.tap(deleteButton, warnIfMissed: false); + await tester.pumpAndSettle(); + tester.takeException(); + expect(find.text("Delete this trade?"), findsOneWidget); + + await tester.tap(find.byKey(const Key("confirmDeleteTradeButton"))); + await tester.pumpAndSettle(); + tester.takeException(); + + verify( + trades.delete( + trade: anyNamed("trade"), + shouldNotifyListeners: anyNamed("shouldNotifyListeners"), + ), + ).called(1); + + expect(find.byType(DesktopDialog), findsNothing); + expect( + find.byType(ModalBarrier).evaluate().length, + baselineBarriers, + reason: "hosting showDialog route must have been dismissed", + ); + }); +}