From 69ff7bd8c40c3daa2103c53c0506eecb48df6dfd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:06:59 -0500 Subject: [PATCH 1/2] chore: dart format address list views No behaviour change; the formatter is enforced on changed files in CI. --- .../addresses/wallet_addresses_view.dart | 107 +++++++++--------- .../desktop_wallet_addresses_view.dart | 21 ++-- 2 files changed, 61 insertions(+), 67 deletions(-) diff --git a/lib/pages/receive_view/addresses/wallet_addresses_view.dart b/lib/pages/receive_view/addresses/wallet_addresses_view.dart index 7dcf0ea840..17652c51ca 100644 --- a/lib/pages/receive_view/addresses/wallet_addresses_view.dart +++ b/lib/pages/receive_view/addresses/wallet_addresses_view.dart @@ -82,24 +82,23 @@ class _WalletAddressesViewState extends ConsumerState { .findAll(); } - final labels = - await MainDB.instance - .getAddressLabels(widget.walletId) - .filter() - .group( - (q) => q - .valueContains(term, caseSensitive: false) - .or() - .addressStringContains(term, caseSensitive: false) - .or() - .group( - (q) => q.tagsIsNotNull().and().tagsElementContains( - term, - caseSensitive: false, - ), - ), - ) - .findAll(); + final labels = await MainDB.instance + .getAddressLabels(widget.walletId) + .filter() + .group( + (q) => q + .valueContains(term, caseSensitive: false) + .or() + .addressStringContains(term, caseSensitive: false) + .or() + .group( + (q) => q.tagsIsNotNull().and().tagsElementContains( + term, + caseSensitive: false, + ), + ), + ) + .findAll(); if (labels.isEmpty) { return []; @@ -163,30 +162,29 @@ class _WalletAddressesViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of( - context, - ).extension()!.backgroundAppBar, - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - titleSpacing: 0, - title: Text( - "Wallet addresses", - style: STextStyles.navBarTitle(context), - ), - ), - body: Padding(padding: const EdgeInsets.all(16), child: child), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Wallet addresses", + style: STextStyles.navBarTitle(context), ), ), + body: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), child: SafeArea( child: Column( children: [ @@ -268,21 +266,20 @@ class _WalletAddressesViewState extends ConsumerState { return ListView.separated( itemCount: snapshot.data!.length, separatorBuilder: (_, __) => Container(height: 10), - itemBuilder: - (_, index) => AddressCard( - walletId: widget.walletId, - addressId: snapshot.data![index], - coin: coin, - onPressed: () { - Navigator.of(context).pushNamed( - AddressDetailsView.routeName, - arguments: Tuple2( - snapshot.data![index], - widget.walletId, - ), - ); - }, - ), + itemBuilder: (_, index) => AddressCard( + walletId: widget.walletId, + addressId: snapshot.data![index], + coin: coin, + onPressed: () { + Navigator.of(context).pushNamed( + AddressDetailsView.routeName, + arguments: Tuple2( + snapshot.data![index], + widget.walletId, + ), + ); + }, + ), ); } else { return const Center( diff --git a/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart b/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart index f3bdb2bc2f..2436bcffa5 100644 --- a/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart +++ b/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart @@ -82,19 +82,17 @@ class _DesktopWalletAddressesViewState const SizedBox(width: 32), AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -135,10 +133,9 @@ class _DesktopWalletAddressesViewState "currentDesktopAddressDetails_key_${ref.watch(desktopSelectedAddressId.state).state}", ), walletId: widget.walletId, - addressId: - ref - .watch(desktopSelectedAddressId.state) - .state!, + addressId: ref + .watch(desktopSelectedAddressId.state) + .state!, ), ), ), From da800a2d6e99f4e30425f602f87893e3261b9e50 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:07:20 -0500 Subject: [PATCH 2/2] feat: add "Generate new address" button to wallet address list views Both the mobile and desktop address lists gain a guarded button that derives the next receiving address. GenerateAddressButton owns the whole flow: it suppresses repeat taps, shows a non dismissible progress dialog on the root navigator, closes exactly that dialog's route when generation settles, and surfaces failures instead of stranding the modal. The button is only offered to wallets that can actually derive another address, so view only wallets holding just an address or a spark view key do not get it. closes #430 --- .../addresses/wallet_addresses_view.dart | 30 +++ .../desktop_wallet_addresses_view.dart | 30 +++ lib/widgets/generate_address_button.dart | 126 +++++++++ .../generate_address_button_test.dart | 242 ++++++++++++++++++ 4 files changed, 428 insertions(+) create mode 100644 lib/widgets/generate_address_button.dart create mode 100644 test/widget_tests/generate_address_button_test.dart diff --git a/lib/pages/receive_view/addresses/wallet_addresses_view.dart b/lib/pages/receive_view/addresses/wallet_addresses_view.dart index 17652c51ca..046b0f7d11 100644 --- a/lib/pages/receive_view/addresses/wallet_addresses_view.dart +++ b/lib/pages/receive_view/addresses/wallet_addresses_view.dart @@ -15,13 +15,18 @@ import 'package:tuple/tuple.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/keys/view_only_wallet_data.dart'; +import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/generate_address_button.dart'; import '../../../widgets/loading_indicator.dart'; import 'address_card.dart'; import 'address_details_view.dart'; @@ -43,6 +48,8 @@ class _WalletAddressesViewState extends ConsumerState { final String _searchString = ""; + int _futureKey = 0; + // late final TextEditingController _searchController; // final searchFieldFocusNode = FocusNode(); @@ -160,6 +167,21 @@ class _WalletAddressesViewState extends ConsumerState { Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(widget.walletId)); + final wallet = ref.watch( + pWallets.select((value) => value.getWallet(widget.walletId)), + ); + + final bool canGen; + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + // Neither type carries an xpub, so no further address can be derived. + (wallet.viewOnlyType == ViewOnlyWalletType.addressOnly || + wallet.viewOnlyType == ViewOnlyWalletType.spark)) { + canGen = false; + } else { + canGen = wallet is MultiAddressInterface; + } + return ConditionalParent( condition: !isDesktop, builder: (child) => Background( @@ -256,8 +278,16 @@ class _WalletAddressesViewState extends ConsumerState { // SizedBox( // height: isDesktop ? 20 : 16, // ), + if (canGen) + GenerateAddressButton( + generateAddress: (wallet as MultiAddressInterface) + .generateNewReceivingAddress, + onGenerated: () => setState(() => _futureKey++), + ), + if (canGen) const SizedBox(height: 12), Expanded( child: FutureBuilder( + key: ValueKey(_futureKey), future: _search(_searchString), builder: (context, AsyncSnapshot> snapshot) { if (snapshot.connectionState == ConnectionState.done && diff --git a/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart b/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart index 2436bcffa5..4e17c48782 100644 --- a/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart +++ b/lib/pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart @@ -14,14 +14,19 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../models/keys/view_only_wallet_data.dart'; import '../../pages/receive_view/addresses/address_details_view.dart'; import '../../providers/db/main_db_provider.dart'; +import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/generate_address_button.dart'; import 'sub_widgets/desktop_address_list.dart'; final desktopSelectedAddressId = StateProvider.autoDispose((ref) => null); @@ -73,6 +78,21 @@ class _DesktopWalletAddressesViewState @override Widget build(BuildContext context) { + final wallet = ref.watch( + pWallets.select((value) => value.getWallet(widget.walletId)), + ); + + final bool canGen; + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly && + // Neither type carries an xpub, so no further address can be derived. + (wallet.viewOnlyType == ViewOnlyWalletType.addressOnly || + wallet.viewOnlyType == ViewOnlyWalletType.spark)) { + canGen = false; + } else { + canGen = wallet is MultiAddressInterface; + } + return DesktopScaffold( appBar: DesktopAppBar( background: Theme.of(context).extension()!.popupBG, @@ -99,6 +119,16 @@ class _DesktopWalletAddressesViewState const SizedBox(width: 12), Text("Address list", style: STextStyles.desktopH3(context)), const Spacer(), + if (canGen) + SizedBox( + width: 200, + child: GenerateAddressButton( + buttonHeight: ButtonHeight.l, + generateAddress: (wallet as MultiAddressInterface) + .generateNewReceivingAddress, + ), + ), + const SizedBox(width: 32), ], ), ), diff --git a/lib/widgets/generate_address_button.dart b/lib/widgets/generate_address_button.dart new file mode 100644 index 0000000000..1b66199813 --- /dev/null +++ b/lib/widgets/generate_address_button.dart @@ -0,0 +1,126 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../notifications/show_flush_bar.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/logger.dart'; +import 'custom_loading_overlay.dart'; +import 'desktop/secondary_button.dart'; + +export 'desktop/secondary_button.dart' show ButtonHeight; + +class GenerateAddressButton extends StatefulWidget { + const GenerateAddressButton({ + super.key, + required this.generateAddress, + this.onGenerated, + this.buttonHeight, + }); + + final Future Function() generateAddress; + final VoidCallback? onGenerated; + final ButtonHeight? buttonHeight; + + @override + State createState() => _GenerateAddressButtonState(); +} + +class _GenerateAddressButtonState extends State { + bool _isGenerating = false; + + Future _generate() async { + if (_isGenerating) { + return; + } + + setState(() => _isGenerating = true); + + final dialogContext = Completer(); + final dialogClosed = showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (context) { + if (!dialogContext.isCompleted) { + dialogContext.complete(context); + } + return PopScope( + canPop: false, + child: Container( + color: Theme.of( + context, + ).extension()!.overlay.withValues(alpha: 0.5), + child: const CustomLoadingOverlay( + message: "Generating address", + eventBus: null, + ), + ), + ); + }, + ); + + Object? failure; + try { + await dialogContext.future; + await widget.generateAddress(); + } catch (error, stackTrace) { + failure = error; + Logging.instance.e( + "Failed to generate a receiving address", + error: error, + stackTrace: stackTrace, + ); + } finally { + final context = await dialogContext.future; + if (context.mounted) { + // Close this dialog's own route. A bare pop() would instead drop + // whatever sits on top of the root navigator, stranding this + // non dismissible dialog behind anything pushed above it (the idle + // lockscreen, say) while generation was running. + final route = ModalRoute.of(context); + if (route != null) { + if (route.isCurrent) { + Navigator.of(context).pop(); + } else if (route.isActive) { + Navigator.of(context).removeRoute(route); + } + } + } + await dialogClosed; + } + + if (!mounted) { + return; + } + + setState(() => _isGenerating = false); + if (failure == null) { + widget.onGenerated?.call(); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Failed to generate a new address", + context: context, + ), + ); + } + } + + @override + Widget build(BuildContext context) => SecondaryButton( + label: "Generate new address", + buttonHeight: widget.buttonHeight, + enabled: !_isGenerating, + onPressed: _generate, + ); +} diff --git a/test/widget_tests/generate_address_button_test.dart b/test/widget_tests/generate_address_button_test.dart new file mode 100644 index 0000000000..6554c6b2c6 --- /dev/null +++ b/test/widget_tests/generate_address_button_test.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +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/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/widgets/generate_address_button.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + Widget testApp( + Widget child, { + GlobalKey? rootKey, + GlobalKey? nestedKey, + }) { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + return ProviderScope( + overrides: [themeProvider.overrideWithValue(StateController(theme))], + child: MaterialApp( + navigatorKey: rootKey, + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: Scaffold( + body: Navigator( + key: nestedKey, + onGenerateRoute: (_) => + MaterialPageRoute(builder: (_) => child), + ), + ), + ), + ); + } + + testWidgets("closes only its dialog after generation", (tester) async { + final generation = Completer(); + var generated = 0; + + await tester.pumpWidget( + testApp( + Column( + children: [ + const Text("Nested page remains"), + GenerateAddressButton( + generateAddress: () => generation.future, + onGenerated: () => generated++, + ), + ], + ), + ), + ); + + await tester.tap(find.text("Generate new address")); + await tester.pump(); + + expect(find.text("Generating address"), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text("Generating address"), findsOneWidget); + + generation.complete(); + await tester.pumpAndSettle(); + + expect(find.text("Generating address"), findsNothing); + expect(find.text("Nested page remains"), findsOneWidget); + expect(generated, 1); + }); + + testWidgets("ignores repeated taps while generation is running", ( + tester, + ) async { + final generation = Completer(); + var calls = 0; + + await tester.pumpWidget( + testApp( + GenerateAddressButton( + generateAddress: () { + calls++; + return generation.future; + }, + ), + ), + ); + + final button = tester.widget(find.byType(TextButton)); + button.onPressed!.call(); + button.onPressed!.call(); + await tester.pump(); + + expect(calls, 1); + + generation.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets("closes the dialog and reports generation failures", ( + tester, + ) async { + await tester.pumpWidget( + testApp( + GenerateAddressButton( + generateAddress: () => throw StateError("native failure"), + ), + ), + ); + + await tester.tap(find.text("Generate new address")); + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + + expect(find.text("Generating address"), findsNothing); + expect(find.text("Failed to generate a new address"), findsOneWidget); + + final button = tester.widget(find.byType(TextButton)); + expect(button.onPressed, isNotNull); + }); + + testWidgets("shows the progress dialog on the root navigator", ( + tester, + ) async { + final rootKey = GlobalKey(); + final nestedKey = GlobalKey(); + final generation = Completer(); + + await tester.pumpWidget( + testApp( + GenerateAddressButton(generateAddress: () => generation.future), + rootKey: rootKey, + nestedKey: nestedKey, + ), + ); + + expect(rootKey.currentState!.canPop(), isFalse); + await tester.tap(find.text("Generate new address")); + await tester.pump(); + + expect(find.text("Generating address"), findsOneWidget); + expect( + rootKey.currentState!.canPop(), + isTrue, + reason: "the dialog must cover the whole app, not just a nested pane", + ); + expect(nestedKey.currentState!.canPop(), isFalse); + + generation.complete(); + await tester.pumpAndSettle(); + + expect(rootKey.currentState!.canPop(), isFalse); + expect(find.text("Generating address"), findsNothing); + }); + + testWidgets("closes its dialog even if the button is disposed mid run", ( + tester, + ) async { + final generation = Completer(); + var generated = 0; + final show = ValueNotifier(true); + + await tester.pumpWidget( + testApp( + ValueListenableBuilder( + valueListenable: show, + builder: (_, visible, __) => visible + ? GenerateAddressButton( + generateAddress: () => generation.future, + onGenerated: () => generated++, + ) + : const Text("button gone"), + ), + ), + ); + + await tester.tap(find.text("Generate new address")); + await tester.pump(); + expect(find.text("Generating address"), findsOneWidget); + + show.value = false; + await tester.pump(); + expect(find.byType(GenerateAddressButton), findsNothing); + expect(find.text("Generating address"), findsOneWidget); + + generation.complete(); + await tester.pumpAndSettle(); + + expect(find.text("Generating address"), findsNothing); + expect(find.text("button gone"), findsOneWidget); + expect(generated, 0, reason: "callback must not fire after dispose"); + expect(tester.takeException(), isNull); + }); + + testWidgets("leaves routes pushed above the dialog alone", (tester) async { + final rootKey = GlobalKey(); + final generation = Completer(); + var generated = 0; + + await tester.pumpWidget( + testApp( + GenerateAddressButton( + generateAddress: () => generation.future, + onGenerated: () => generated++, + ), + rootKey: rootKey, + ), + ); + + await tester.tap(find.text("Generate new address")); + await tester.pump(); + expect(find.text("Generating address"), findsOneWidget); + + // Stands in for the idle lockscreen, or any other event driven route that + // can land on the root navigator while generation is still running. + unawaited( + rootKey.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text("Intruder route")), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("Intruder route"), findsOneWidget); + + generation.complete(); + // Fixed pumps rather than pumpAndSettle: a stranded dialog animates its + // loading indicator forever, which would time out instead of failing here. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text("Intruder route"), findsOneWidget); + expect(find.text("Generating address", skipOffstage: false), findsNothing); + expect(generated, 1); + + rootKey.currentState!.pop(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect( + tester.widget(find.byType(TextButton)).onPressed, + isNotNull, + ); + }); +}