From ac8c766d932639a2122821f1324bf2e4b8290fc7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 28 Feb 2026 18:23:33 -0600 Subject: [PATCH 1/2] fix: remove double-pop from BuildingTransactionDialog cancel The dialog's cancel button popped the navigator itself and then invoked onCancel, while callers' onCancel handlers also popped, causing a double pop. Drop the internal pop so the caller owns the dismissal, and add the missing pop to Step4View's onCancel. --- .../exchange_step_views/step_4_view.dart | 1 + .../building_transaction_dialog.dart | 2 - .../building_transaction_dialog_test.dart | 118 ++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/pages/send_view/building_transaction_dialog_test.dart diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index a48b85b23c..6df00faecf 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -270,6 +270,7 @@ class _Step4ViewState extends ConsumerState { isSpark: wallet is FiroWallet && !firoPublicSend, onCancel: () { wasCancelled = true; + Navigator.of(context).pop(); }, ); }, diff --git a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart index 0d1e9ef344..4cba928080 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -115,7 +115,6 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); onCancel.call(); }, ), @@ -140,7 +139,6 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); onCancel.call(); }, ), diff --git a/test/pages/send_view/building_transaction_dialog_test.dart b/test/pages/send_view/building_transaction_dialog_test.dart new file mode 100644 index 0000000000..56b2dcb963 --- /dev/null +++ b/test/pages/send_view/building_transaction_dialog_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import 'package:stackwallet/themes/coin_image_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final isDesktop in [false, true]) { + testWidgets( + 'cancel dismisses only the ${isDesktop ? 'desktop' : 'mobile'} dialog', + (tester) async { + Util.screenWidth = isDesktop ? null : 400; + addTearDown(() => Util.screenWidth = null); + + var cancelCount = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + coinImageSecondaryProvider.overrideWithProvider( + (_) => Provider((_) => 'coin.svg'), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: _RootPage( + isDesktop: isDesktop, + onCancel: () => cancelCount++, + ), + ), + ), + ); + + await tester.tap(find.text('Open caller')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Build transaction')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(cancelCount, 1); + expect(find.byKey(const Key('caller page')), findsOneWidget); + expect(find.text('Generating transaction'), findsNothing); + expect( + tester.state(find.byType(Navigator)).canPop(), + isTrue, + ); + }, + ); + } +} + +class _RootPage extends StatelessWidget { + const _RootPage({required this.isDesktop, required this.onCancel}); + + final bool isDesktop; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + _CallerPage(isDesktop: isDesktop, onCancel: onCancel), + ), + ), + child: const Text('Open caller'), + ), + ); + } +} + +class _CallerPage extends StatelessWidget { + const _CallerPage({required this.isDesktop, required this.onCancel}); + + final bool isDesktop; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('caller page'), + body: TextButton( + onPressed: () => showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final child = BuildingTransactionDialog( + coin: Bitcoin(CryptoCurrencyNetwork.main), + isSpark: false, + onCancel: () { + onCancel(); + Navigator.of(dialogContext).pop(); + }, + ); + + return isDesktop ? DesktopDialog(child: child) : child; + }, + ), + child: const Text('Build transaction'), + ), + ); + } +} From 586a13b899fe47674e7613ee828f24a34121d15f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 15:58:00 -0500 Subject: [PATCH 2/2] fix: keep the send caller route when a build fails after cancel Seven send flows pop the caller's route from their catch block whenever a prepareSend failure arrives after the user already dismissed the building transaction dialog. On desktop that pop empties the root navigator and blanks the window; on mobile it drops the user back to the wallet view. Hoist wasCancelled out of the try so the catch can see it, as step_4_view and the ShopinBit/CakePay send flows already do. --- .../salvium_create_stake_view.dart | 5 +- lib/pages/send_view/send_view.dart | 5 +- lib/pages/send_view/sol_token_send_view.dart | 5 +- lib/pages/send_view/token_send_view.dart | 5 +- .../wallet_view/sub_widgets/desktop_send.dart | 5 +- .../sub_widgets/desktop_sol_token_send.dart | 5 +- .../sub_widgets/desktop_token_send.dart | 5 +- .../building_transaction_dialog_test.dart | 224 ++++++++++++++---- 8 files changed, 189 insertions(+), 70 deletions(-) diff --git a/lib/pages/salvium_stake/salvium_create_stake_view.dart b/lib/pages/salvium_stake/salvium_create_stake_view.dart index 58a9a5af4d..ad84a02e61 100644 --- a/lib/pages/salvium_stake/salvium_create_stake_view.dart +++ b/lib/pages/salvium_stake/salvium_create_stake_view.dart @@ -73,9 +73,8 @@ class _SalviumCreateStakeViewState if (_lock) return; _lock = true; + bool wasCancelled = false; try { - bool wasCancelled = false; - unawaited( showDialog( context: context, @@ -169,7 +168,7 @@ class _SalviumCreateStakeViewState } catch (e, s) { Logging.instance.e("Salvium stake preview: ", error: e, stackTrace: s); - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: Util.isDesktop).pop(); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 18b8d5be2e..fdd76b5737 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -937,9 +937,8 @@ class _SendViewState extends ConsumerState { } } + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -1162,7 +1161,7 @@ class _SendViewState extends ConsumerState { } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: true).pop(); diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6187d4c53a..651880cb38 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -424,9 +424,8 @@ class _SolTokenSendViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(walletId); final Amount amount = _amountToSend!; + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -503,7 +502,7 @@ class _SolTokenSendViewState extends ConsumerState { } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context).pop(); diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6a..a8ec46dba7 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -458,9 +458,8 @@ class _TokenSendViewState extends ConsumerState { // } // } + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -533,7 +532,7 @@ class _TokenSendViewState extends ConsumerState { } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context).pop(); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 5060d2bdb0..0ebd0ec1f3 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -540,9 +540,8 @@ class _DesktopSendState extends ConsumerState { } } + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -780,7 +779,7 @@ class _DesktopSendState extends ConsumerState { } } catch (e, s) { Logging.instance.e("Desktop send: ", error: e, stackTrace: s); - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: true).pop(); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index cd4e227a51..5d579f92a5 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -207,9 +207,8 @@ class _DesktopSolTokenSendState extends ConsumerState { } } + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -292,7 +291,7 @@ class _DesktopSolTokenSendState extends ConsumerState { ); } } catch (e) { - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: true).pop(); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index f01cdd2464..b3eea60b5f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -193,9 +193,8 @@ class _DesktopTokenSendState extends ConsumerState { } } + bool wasCancelled = false; try { - bool wasCancelled = false; - if (mounted) { unawaited( showDialog( @@ -275,7 +274,7 @@ class _DesktopTokenSendState extends ConsumerState { ); } } catch (e) { - if (mounted) { + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: true).pop(); diff --git a/test/pages/send_view/building_transaction_dialog_test.dart b/test/pages/send_view/building_transaction_dialog_test.dart index 56b2dcb963..eb2b151dff 100644 --- a/test/pages/send_view/building_transaction_dialog_test.dart +++ b/test/pages/send_view/building_transaction_dialog_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,62 +13,93 @@ import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; import '../../sample_data/theme_json.dart'; +Widget _app(Widget home) { + return ProviderScope( + overrides: [ + coinImageSecondaryProvider.overrideWithProvider( + (_) => Provider((_) => 'coin.svg'), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: home, + ), + ); +} + void main() { for (final isDesktop in [false, true]) { - testWidgets( - 'cancel dismisses only the ${isDesktop ? 'desktop' : 'mobile'} dialog', - (tester) async { - Util.screenWidth = isDesktop ? null : 400; - addTearDown(() => Util.screenWidth = null); - - var cancelCount = 0; - await tester.pumpWidget( - ProviderScope( - overrides: [ - coinImageSecondaryProvider.overrideWithProvider( - (_) => Provider((_) => 'coin.svg'), - ), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson(json: lightThemeJsonMap), - ), - ], - ), - home: _RootPage( - isDesktop: isDesktop, - onCancel: () => cancelCount++, - ), - ), - ), - ); + final label = isDesktop ? 'desktop' : 'mobile'; - await tester.tap(find.text('Open caller')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Build transaction')); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(cancelCount, 1); - expect(find.byKey(const Key('caller page')), findsOneWidget); - expect(find.text('Generating transaction'), findsNothing); - expect( - tester.state(find.byType(Navigator)).canPop(), - isTrue, - ); - }, - ); + testWidgets('cancel dismisses only the $label dialog', (tester) async { + Util.screenWidth = isDesktop ? null : 400; + addTearDown(() => Util.screenWidth = null); + + var cancelCount = 0; + await tester.pumpWidget( + _app(_RootPage(isDesktop: isDesktop, onCancel: () => cancelCount++)), + ); + + await tester.tap(find.text('Open caller')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Build transaction')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(cancelCount, 1); + expect(find.byKey(const Key('caller page')), findsOneWidget); + expect(find.text('Generating transaction'), findsNothing); + expect( + tester.state(find.byType(Navigator)).canPop(), + isTrue, + ); + }); + + testWidgets('a build failing after cancel keeps the $label caller route', ( + tester, + ) async { + Util.screenWidth = isDesktop ? null : 400; + addTearDown(() => Util.screenWidth = null); + + final txData = Completer(); + await tester.pumpWidget( + _app(_RootPage(isDesktop: isDesktop, txData: txData)), + ); + + await tester.tap(find.text('Open caller')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Build transaction')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Generating transaction'), findsNothing); + expect(find.byKey(const Key('caller page')), findsOneWidget); + + txData.completeError(Exception('insufficient funds')); + await tester.pump(const Duration(milliseconds: 2600)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('caller page')), findsOneWidget); + expect(find.byKey(const Key('failed dialog')), findsNothing); + expect(tester.takeException(), isNull); + }); } } class _RootPage extends StatelessWidget { - const _RootPage({required this.isDesktop, required this.onCancel}); + const _RootPage({required this.isDesktop, this.onCancel, this.txData}); final bool isDesktop; - final VoidCallback onCancel; + final VoidCallback? onCancel; + final Completer? txData; @override Widget build(BuildContext context) { @@ -74,8 +107,9 @@ class _RootPage extends StatelessWidget { body: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => - _CallerPage(isDesktop: isDesktop, onCancel: onCancel), + builder: (_) => txData == null + ? _CallerPage(isDesktop: isDesktop, onCancel: onCancel!) + : _PreviewCallerPage(isDesktop: isDesktop, txData: txData!), ), ), child: const Text('Open caller'), @@ -116,3 +150,95 @@ class _CallerPage extends StatelessWidget { ); } } + +/// Mirrors the preview flow shared by the send views, which cannot be pumped +/// directly here because they need live wallet providers. +class _PreviewCallerPage extends StatefulWidget { + const _PreviewCallerPage({required this.isDesktop, required this.txData}); + + final bool isDesktop; + final Completer txData; + + @override + State<_PreviewCallerPage> createState() => _PreviewCallerPageState(); +} + +class _PreviewCallerPageState extends State<_PreviewCallerPage> { + bool wasCancelled = false; + + Future _preview() async { + try { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final child = BuildingTransactionDialog( + coin: Bitcoin(CryptoCurrencyNetwork.main), + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(dialogContext).pop(); + }, + ); + + return widget.isDesktop ? DesktopDialog(child: child) : child; + }, + ), + ); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + final results = await Future.wait([widget.txData.future, time]); + + if (!wasCancelled && mounted) { + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + key: const Key('confirm page'), + body: Text('${results.first}'), + ), + ), + ), + ); + } + } catch (_) { + if (mounted && !wasCancelled) { + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => AlertDialog( + key: const Key('failed dialog'), + title: const Text('Transaction failed'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Ok'), + ), + ], + ), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('caller page'), + body: TextButton( + onPressed: () => unawaited(_preview()), + child: const Text('Build transaction'), + ), + ); + } +}