diff --git a/lib/pages/receive_view/addresses/wallet_addresses_view.dart b/lib/pages/receive_view/addresses/wallet_addresses_view.dart index 7dcf0ea840..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(); @@ -82,24 +89,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 []; @@ -161,32 +167,46 @@ 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( - 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: [ @@ -258,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 && @@ -268,21 +296,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..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, @@ -82,25 +102,33 @@ 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, ), 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), ], ), ), @@ -135,10 +163,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!, ), ), ), 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, + ); + }); +}