From 1f0ee995b9d435b8ab8ebf8847e09a5c80c05698 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 12 Nov 2024 09:29:12 -0600 Subject: [PATCH] ui view only wallet changes --- ...w_wallet_recovery_phrase_warning_view.dart | 2 - .../restore_options_view.dart | 774 +++++++++++------- .../restore_view_only_wallet_view.dart | 393 +++++++++ .../restore_wallet_view.dart | 22 +- .../wallet_settings_view.dart | 103 ++- .../delete_view_only_wallet_keys_view.dart | 204 +++++ .../delete_wallet_recovery_phrase_view.dart | 8 +- .../delete_wallet_warning_view.dart | 38 +- lib/pages/wallet_view/wallet_view.dart | 98 +-- .../desktop_attention_delete_wallet.dart | 59 +- .../sub_widgets/desktop_wallet_features.dart | 6 +- .../more_features/more_features_dialog.dart | 9 +- .../wallet_view/sub_widgets/my_wallet.dart | 34 +- .../unlock_wallet_keys_desktop.dart | 13 +- .../wallet_keys_desktop_popup.dart | 13 +- lib/route_generator.dart | 40 + lib/widgets/stack_text_field.dart | 115 +++ 17 files changed, 1501 insertions(+), 430 deletions(-) create mode 100644 lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart create mode 100644 lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart diff --git a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart index 275f7ff0e..2b9d787e3 100644 --- a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart @@ -12,8 +12,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:bip39/bip39.dart' as bip39; -import 'package:blockchain_utils/bip/bip/bip39/bip39_mnemonic.dart'; -import 'package:blockchain_utils/bip/bip/bip39/bip39_mnemonic_generator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index cd0115128..9d2ac8c1d 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -23,6 +23,7 @@ import '../../../../utilities/format.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/custom_buttons/checkbox_text_button.dart'; @@ -32,7 +33,9 @@ import '../../../../widgets/desktop/desktop_scaffold.dart'; import '../../../../widgets/expandable.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/toggle.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; +import '../restore_view_only_wallet_view.dart'; import '../restore_wallet_view.dart'; import '../sub_widgets/mnemonic_word_count_select_sheet.dart'; import 'sub_widgets/mobile_mnemonic_length_selector.dart'; @@ -69,7 +72,6 @@ class _RestoreOptionsViewState extends ConsumerState { final bool _nextEnabled = true; DateTime? _restoreFromDate; bool hidePassword = true; - bool _expandedAdavnced = false; bool get supportsMnemonicPassphrase => coin.hasMnemonicPassphraseSupport; @@ -99,27 +101,46 @@ class _RestoreOptionsViewState extends ConsumerState { super.dispose(); } + bool _nextLock = false; Future nextPressed() async { - if (!isDesktop) { - // hide keyboard if has focus - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); + if (_nextLock) return; + _nextLock = true; + try { + if (!isDesktop) { + // hide keyboard if has focus + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } } - } - if (mounted) { - await Navigator.of(context).pushNamed( - RestoreWalletView.routeName, - arguments: Tuple6( - walletName, - coin, - ref.read(mnemonicWordCountStateProvider.state).state, - _restoreFromDate, - passwordController.text, - enableLelantusScanning, - ), - ); + if (mounted) { + if (!_showViewOnlyOption) { + await Navigator.of(context).pushNamed( + RestoreWalletView.routeName, + arguments: Tuple6( + walletName, + coin, + ref.read(mnemonicWordCountStateProvider.state).state, + _restoreFromDate, + passwordController.text, + enableLelantusScanning, + ), + ); + } else { + await Navigator.of(context).pushNamed( + RestoreViewOnlyWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + restoreFromDate: _restoreFromDate, + enableLelantusScanning: enableLelantusScanning, + ), + ); + } + } + } finally { + _nextLock = false; } } @@ -164,17 +185,12 @@ class _RestoreOptionsViewState extends ConsumerState { ); } + bool _showViewOnlyOption = false; + @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType with ${coin.identifier} $walletName"); - final lengths = coin.possibleMnemonicLengths; - - final isMoneroAnd25 = coin is Monero && - ref.watch(mnemonicWordCountStateProvider.state).state == 25; - final isWowneroAnd25 = coin is Wownero && - ref.watch(mnemonicWordCountStateProvider.state).state == 25; - return MasterScaffold( isDesktop: isDesktop, appBar: isDesktop @@ -227,288 +243,57 @@ class _RestoreOptionsViewState extends ConsumerState { SizedBox( height: isDesktop ? 40 : 24, ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - Text( - "Choose start date", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) + if (coin is ViewOnlyOptionCurrencyInterface) SizedBox( - height: isDesktop ? 16 : 8, - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - if (!isDesktop) - RestoreFromDatePicker( - onTap: chooseDate, - controller: _dateController, - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - if (isDesktop) - // TODO desktop date picker - RestoreFromDatePicker( - onTap: chooseDesktopDate, - controller: _dateController, - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - const SizedBox( - height: 8, - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - RoundedWhiteContainer( - child: Center( - child: Text( - "Choose the date you made the wallet (approximate is fine)", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith( - fontSize: 10, - ), - ), - ), - ), - if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25) - SizedBox( - height: isDesktop ? 24 : 16, - ), - Text( - "Choose recovery phrase length", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - SizedBox( - height: isDesktop ? 16 : 8, - ), - if (isDesktop) - DropdownButtonHideUnderline( - child: DropdownButton2( - value: - ref.watch(mnemonicWordCountStateProvider.state).state, - items: [ - ...lengths.map( - (e) => DropdownMenuItem( - value: e, - child: Text( - "$e words", - style: STextStyles.desktopTextMedium(context), - ), - ), - ), - ], - onChanged: (value) { - if (value is int) { - ref.read(mnemonicWordCountStateProvider.state).state = - value; - } + height: isDesktop ? 56 : 48, + width: isDesktop ? 490 : null, + child: Toggle( + key: UniqueKey(), + onText: "Seed", + offText: "View Only", + onColor: + Theme.of(context).extension()!.popupBG, + offColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, + isOn: _showViewOnlyOption, + onValueChanged: (value) { + setState(() { + _showViewOnlyOption = value; + }); }, - isExpanded: true, - iconStyleData: IconStyleData( - icon: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, -10), - elevation: 0, - decoration: BoxDecoration( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), ), ), - if (!isDesktop) - MobileMnemonicLengthSelector( - chooseMnemonicLength: chooseMnemonicLength, - ), - if (supportsMnemonicPassphrase) + if (coin is ViewOnlyOptionCurrencyInterface) SizedBox( - height: isDesktop ? 24 : 16, + height: isDesktop ? 40 : 24, ), - if (supportsMnemonicPassphrase) - Expandable( - onExpandChanged: (state) { - setState(() { - _expandedAdavnced = state == ExpandableState.expanded; - }); - }, - header: Container( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 8.0, - right: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Advanced", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - SvgPicture.asset( - _expandedAdavnced - ? Assets.svg.chevronUp - : Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - ], - ), + _showViewOnlyOption + ? ViewOnlyRestoreOption( + coin: coin, + dateController: _dateController, + dateChooserFunction: + isDesktop ? chooseDesktopDate : chooseDate, + ) + : SeedRestoreOption( + coin: coin, + dateController: _dateController, + pwController: passwordController, + pwFocusNode: passwordFocusNode, + supportsMnemonicPassphrase: supportsMnemonicPassphrase, + dateChooserFunction: + isDesktop ? chooseDesktopDate : chooseDate, + chooseMnemonicLength: chooseMnemonicLength, + lelScanChanged: (value) { + enableLelantusScanning = value; + }, ), - ), - body: Container( - color: Colors.transparent, - child: Column( - children: [ - if (coin is Firo) - CheckboxTextButton( - label: "Scan for Lelantus transactions", - onChanged: (newValue) { - setState(() { - enableLelantusScanning = newValue ?? true; - }); - }, - ), - if (coin is Firo) - const SizedBox( - height: 8, - ), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - key: const Key("mnemonicPassphraseFieldKey1"), - focusNode: passwordFocusNode, - controller: passwordController, - style: isDesktop - ? STextStyles.desktopTextMedium(context) - .copyWith( - height: 2, - ) - : STextStyles.field(context), - obscureText: hidePassword, - enableSuggestions: false, - autocorrect: false, - decoration: standardInputDecoration( - "BIP39 passphrase", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: ConditionalParent( - condition: isDesktop, - builder: (child) => SizedBox( - height: 70, - child: child, - ), - child: Row( - children: [ - SizedBox( - width: isDesktop ? 24 : 16, - ), - GestureDetector( - key: const Key( - "mnemonicPassphraseFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword - ? Assets.svg.eye - : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: isDesktop ? 24 : 16, - height: isDesktop ? 24 : 16, - ), - ), - const SizedBox( - width: 12, - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox( - height: 8, - ), - RoundedWhiteContainer( - child: Center( - child: Text( - "If the recovery phrase you are about to restore " - "was created with an optional BIP39 passphrase " - "you can enter it here.", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, - ) - : STextStyles.itemSubtitle(context), - ), - ), - ), - const SizedBox( - height: 16, - ), - ], - ), - ), - ), if (!isDesktop) const Spacer( flex: 3, @@ -532,3 +317,394 @@ class _RestoreOptionsViewState extends ConsumerState { ); } } + +class SeedRestoreOption extends ConsumerStatefulWidget { + const SeedRestoreOption({ + super.key, + required this.coin, + required this.dateController, + required this.pwController, + required this.pwFocusNode, + required this.supportsMnemonicPassphrase, + required this.dateChooserFunction, + required this.chooseMnemonicLength, + required this.lelScanChanged, + }); + + final CryptoCurrency coin; + final TextEditingController dateController; + final TextEditingController pwController; + final FocusNode pwFocusNode; + final bool supportsMnemonicPassphrase; + + final Future Function() dateChooserFunction; + final Future Function() chooseMnemonicLength; + final void Function(bool) lelScanChanged; + + @override + ConsumerState createState() => _SeedRestoreOptionState(); +} + +class _SeedRestoreOptionState extends ConsumerState { + bool _hidePassword = true; + bool _expandedAdvanced = false; + bool _enableLelantusScanning = false; + + @override + Widget build(BuildContext context) { + final lengths = widget.coin.possibleMnemonicLengths; + + final isMoneroAnd25 = widget.coin is Monero && + ref.watch(mnemonicWordCountStateProvider.state).state == 25; + final isWowneroAnd25 = widget.coin is Wownero && + ref.watch(mnemonicWordCountStateProvider.state).state == 25; + + return Column( + children: [ + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + Text( + "Choose start date", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of(context).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + SizedBox( + height: Util.isDesktop ? 16 : 8, + ), + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + RestoreFromDatePicker( + onTap: widget.dateChooserFunction, + controller: widget.dateController, + ), + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + const SizedBox( + height: 8, + ), + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + RoundedWhiteContainer( + child: Center( + child: Text( + "Choose the date you made the wallet (approximate is fine)", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith( + fontSize: 10, + ), + ), + ), + ), + if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25) + SizedBox( + height: Util.isDesktop ? 24 : 16, + ), + Text( + "Choose recovery phrase length", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + SizedBox( + height: Util.isDesktop ? 16 : 8, + ), + if (Util.isDesktop) + DropdownButtonHideUnderline( + child: DropdownButton2( + value: ref.watch(mnemonicWordCountStateProvider.state).state, + items: [ + ...lengths.map( + (e) => DropdownMenuItem( + value: e, + child: Text( + "$e words", + style: STextStyles.desktopTextMedium(context), + ), + ), + ), + ], + onChanged: (value) { + if (value is int) { + ref.read(mnemonicWordCountStateProvider.state).state = value; + } + }, + isExpanded: true, + iconStyleData: IconStyleData( + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + ), + if (!Util.isDesktop) + MobileMnemonicLengthSelector( + chooseMnemonicLength: widget.chooseMnemonicLength, + ), + if (widget.supportsMnemonicPassphrase) + SizedBox( + height: Util.isDesktop ? 24 : 16, + ), + if (widget.supportsMnemonicPassphrase) + Expandable( + onExpandChanged: (state) { + setState(() { + _expandedAdvanced = state == ExpandableState.expanded; + }); + }, + header: Container( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + bottom: 8.0, + right: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Advanced", + style: Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + SvgPicture.asset( + _expandedAdvanced + ? Assets.svg.chevronUp + : Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + ], + ), + ), + ), + body: Container( + color: Colors.transparent, + child: Column( + children: [ + if (widget.coin is Firo) + CheckboxTextButton( + label: "Scan for Lelantus transactions", + onChanged: (newValue) { + setState(() { + _enableLelantusScanning = newValue ?? true; + }); + + widget.lelScanChanged(_enableLelantusScanning); + }, + ), + if (widget.coin is Firo) + const SizedBox( + height: 8, + ), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("mnemonicPassphraseFieldKey1"), + focusNode: widget.pwFocusNode, + controller: widget.pwController, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith( + height: 2, + ) + : STextStyles.field(context), + obscureText: _hidePassword, + enableSuggestions: false, + autocorrect: false, + decoration: standardInputDecoration( + "BIP39 passphrase", + widget.pwFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + height: 70, + child: child, + ), + child: Row( + children: [ + SizedBox( + width: Util.isDesktop ? 24 : 16, + ), + GestureDetector( + key: const Key( + "mnemonicPassphraseFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + _hidePassword = !_hidePassword; + }); + }, + child: SvgPicture.asset( + _hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of(context) + .extension()! + .textDark3, + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ), + ), + const SizedBox( + width: 12, + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox( + height: 8, + ), + RoundedWhiteContainer( + child: Center( + child: Text( + "If the recovery phrase you are about to restore " + "was created with an optional BIP39 passphrase " + "you can enter it here.", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) + : STextStyles.itemSubtitle(context), + ), + ), + ), + const SizedBox( + height: 16, + ), + ], + ), + ), + ), + ], + ); + } +} + +class ViewOnlyRestoreOption extends StatefulWidget { + const ViewOnlyRestoreOption({ + super.key, + required this.coin, + required this.dateController, + required this.dateChooserFunction, + }); + + final CryptoCurrency coin; + final TextEditingController dateController; + + final Future Function() dateChooserFunction; + + @override + State createState() => _ViewOnlyRestoreOptionState(); +} + +class _ViewOnlyRestoreOptionState extends State { + @override + Widget build(BuildContext context) { + final showDateOption = widget.coin is ViewOnlyOptionCurrencyInterface; + return Column( + children: [ + if (showDateOption) + Text( + "Choose start date", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of(context).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + if (showDateOption) + SizedBox( + height: Util.isDesktop ? 16 : 8, + ), + if (showDateOption) + RestoreFromDatePicker( + onTap: widget.dateChooserFunction, + controller: widget.dateController, + ), + if (showDateOption) + const SizedBox( + height: 8, + ), + if (showDateOption) + RoundedWhiteContainer( + child: Center( + child: Text( + "Choose the date you made the wallet (approximate is fine)", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith( + fontSize: 10, + ), + ), + ), + ), + if (showDateOption) + SizedBox( + height: Util.isDesktop ? 24 : 16, + ), + ], + ); + } +} diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart new file mode 100644 index 000000000..5f2bd08b0 --- /dev/null +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart @@ -0,0 +1,393 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cs_monero/src/deprecated/get_height_by_date.dart' + as cs_monero_deprecated; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../../pages_desktop_specific/desktop_home_view.dart'; +import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../providers/global/secure_store_provider.dart'; +import '../../../providers/providers.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/barcode_scanner_interface.dart'; +import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/models/wallet_info.dart'; +import '../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../wallets/wallet/impl/monero_wallet.dart'; +import '../../../wallets/wallet/impl/wownero_wallet.dart'; +import '../../../wallets/wallet/wallet.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/desktop/primary_button.dart'; +import '../../../widgets/stack_text_field.dart'; +import '../../home_view/home_view.dart'; +import 'confirm_recovery_dialog.dart'; +import 'sub_widgets/restore_failed_dialog.dart'; +import 'sub_widgets/restore_succeeded_dialog.dart'; +import 'sub_widgets/restoring_dialog.dart'; + +class RestoreViewOnlyWalletView extends ConsumerStatefulWidget { + const RestoreViewOnlyWalletView({ + super.key, + required this.walletName, + required this.coin, + required this.restoreFromDate, + this.enableLelantusScanning = false, + this.barcodeScanner = const BarcodeScannerWrapper(), + this.clipboard = const ClipboardWrapper(), + }); + + static const routeName = "/restoreViewOnlyWallet"; + + final String walletName; + final CryptoCurrency coin; + final DateTime? restoreFromDate; + final bool enableLelantusScanning; + final BarcodeScannerInterface barcodeScanner; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _RestoreViewOnlyWalletViewState(); +} + +class _RestoreViewOnlyWalletViewState + extends ConsumerState { + late final TextEditingController addressController; + late final TextEditingController viewKeyController; + + bool _enableRestoreButton = false; + + bool _buttonLock = false; + + Future _requestRestore() async { + if (_buttonLock) return; + _buttonLock = true; + + try { + if (!Util.isDesktop) { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 100), + ); + } + + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: _attemptRestore, + ); + }, + ); + } + } finally { + _buttonLock = false; + } + } + + Future _attemptRestore() async { + int height = 0; + final Map otherDataJson = { + WalletInfoKeys.isViewOnlyKey: true, + }; + + if (widget.restoreFromDate != null) { + if (widget.coin is Monero) { + height = cs_monero_deprecated.getMoneroHeightByDate( + date: widget.restoreFromDate!, + ); + } + if (widget.coin is Wownero) { + height = cs_monero_deprecated.getWowneroHeightByDate( + date: widget.restoreFromDate!, + ); + } + if (height < 0) { + height = 0; + } + } + + if (widget.coin is Firo) { + otherDataJson.addAll( + { + WalletInfoKeys.lelantusCoinIsarRescanRequired: false, + WalletInfoKeys.enableLelantusScanning: widget.enableLelantusScanning, + }, + ); + } + + if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable(); + + try { + final info = WalletInfo.createNew( + coin: widget.coin, + name: widget.walletName, + restoreHeight: height, + otherDataJsonString: jsonEncode(otherDataJson), + ); + + bool isRestoring = true; + // show restoring in progress + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return RestoringDialog( + onCancel: () async { + isRestoring = false; + + await ref.read(pWallets).deleteWallet( + info, + ref.read(secureStoreProvider), + ); + }, + ); + }, + ), + ); + } + + var node = ref + .read(nodeServiceChangeNotifierProvider) + .getPrimaryNodeFor(currency: widget.coin); + + if (node == null) { + node = widget.coin.defaultNode; + await ref.read(nodeServiceChangeNotifierProvider).setPrimaryNodeFor( + coin: widget.coin, + node: node, + ); + } + + try { + final wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + viewOnlyData: ViewOnlyWalletData( + address: addressController.text, + privateViewKey: viewKeyController.text, + ), + ); + + // TODO: extract interface with isRestore param + switch (wallet.runtimeType) { + case const (EpiccashWallet): + await (wallet as EpiccashWallet).init(isRestore: true); + break; + + case const (MoneroWallet): + await (wallet as MoneroWallet).init(isRestore: true); + break; + + case const (WowneroWallet): + await (wallet as WowneroWallet).init(isRestore: true); + break; + + default: + await wallet.init(); + } + + await wallet.recover(isRescan: false); + + // check if state is still active before continuing + if (mounted) { + // don't remove this setMnemonicVerified thing + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + ref.read(pWallets).addWallet(wallet); + + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context).popUntil( + ModalRoute.withName( + DesktopHomeView.routeName, + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + HomeView.routeName, + (route) => false, + ), + ); + } + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return const RestoreSucceededDialog(); + }, + ); + } + } + } catch (e) { + // check if state is still active and restore wasn't cancelled + // before continuing + if (mounted && isRestoring) { + // pop waiting dialog + Navigator.pop(context); + + // show restoring wallet failed dialog + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return RestoreFailedDialog( + errorMessage: e.toString(), + walletId: info.walletId, + walletName: info.name, + ); + }, + ); + } + } + } finally { + if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.disable(); + } + } + + @override + void initState() { + super.initState(); + addressController = TextEditingController(); + viewKeyController = TextEditingController(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + return MasterScaffold( + isDesktop: isDesktop, + appBar: isDesktop + ? const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + trailing: ExitToMyStackButton(), + ) + : AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 50), + ); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + body: Container( + color: Theme.of(context).extension()!.background, + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: isDesktop ? 480 : double.infinity, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (isDesktop) + const Spacer( + flex: 10, + ), + if (!isDesktop) + Text( + widget.walletName, + style: STextStyles.itemSubtitle(context), + ), + SizedBox( + height: isDesktop ? 0 : 4, + ), + Text( + "Enter view only details", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox( + height: isDesktop ? 24 : 16, + ), + FullTextField( + label: "Address", + controller: addressController, + onChanged: (newValue) { + setState(() { + _enableRestoreButton = newValue.isNotEmpty && + viewKeyController.text.isNotEmpty; + }); + }, + ), + SizedBox( + height: isDesktop ? 16 : 12, + ), + FullTextField( + label: "View Key", + controller: viewKeyController, + onChanged: (value) { + setState(() { + _enableRestoreButton = value.isNotEmpty && + addressController.text.isNotEmpty; + }); + }, + ), + if (!isDesktop) const Spacer(), + SizedBox( + height: isDesktop ? 24 : 16, + ), + PrimaryButton( + enabled: _enableRestoreButton, + onPressed: _requestRestore, + width: isDesktop ? 480 : null, + label: "Restore", + ), + if (isDesktop) + const Spacer( + flex: 15, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index ce2d20832..2fae2352c 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -655,16 +655,18 @@ class _RestoreWalletViewState extends ConsumerState { const Duration(milliseconds: 100), ); - await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return ConfirmRecoveryDialog( - onConfirm: attemptRestore, - ); - }, - ); + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: attemptRestore, + ); + }, + ); + } } @override diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 22d3a4073..494834b02 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -39,6 +39,7 @@ import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; @@ -273,6 +274,7 @@ class _WalletSettingsViewState extends ConsumerState { String keys })? prevGen, })? frostWalletData; + ViewOnlyWalletData? voData; if (wallet is BitcoinFrostWallet) { final futures = [ wallet.getSerializedKeys(), @@ -298,10 +300,17 @@ class _WalletSettingsViewState extends ConsumerState { ), ); } - } else if (wallet - is MnemonicInterface) { - mnemonic = - await wallet.getMnemonicAsWords(); + } else { + if (wallet + is ViewOnlyOptionInterface && + wallet.isViewOnly) { + voData = await wallet + .getViewOnlyWalletData(); + } else if (wallet + is MnemonicInterface) { + mnemonic = await wallet + .getMnemonicAsWords(); + } } KeyDataInterface? keyData; @@ -312,36 +321,68 @@ class _WalletSettingsViewState extends ConsumerState { } if (context.mounted) { - await Navigator.push( - context, - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: (_) => LockscreenView( - routeOnSuccessArguments: ( - walletId: walletId, - mnemonic: mnemonic ?? [], - frostWalletData: - frostWalletData, - keyData: keyData, + if (voData != null) { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: + MobileKeyDataView + .routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery data", + biometricsAuthenticationTitle: + "View recovery data", + ), + settings: const RouteSettings( + name: + "/viewRecoveryDataLockscreen", ), - showBackButton: true, - routeOnSuccess: - WalletBackupView.routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to view recovery phrase", - biometricsAuthenticationTitle: - "View recovery phrase", ), - settings: const RouteSettings( - name: - "/viewRecoverPhraseLockscreen", + ); + } else { + await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + builder: (_) => LockscreenView( + routeOnSuccessArguments: ( + walletId: walletId, + mnemonic: mnemonic ?? [], + frostWalletData: + frostWalletData, + keyData: keyData, + ), + showBackButton: true, + routeOnSuccess: + WalletBackupView + .routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to view recovery phrase", + biometricsAuthenticationTitle: + "View recovery phrase", + ), + settings: const RouteSettings( + name: + "/viewRecoverPhraseLockscreen", + ), ), - ), - ); + ); + } } }, ); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart new file mode 100644 index 000000000..cfa60e76c --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../app_config.dart'; +import '../../../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; +import '../../../../providers/global/secure_store_provider.dart'; +import '../../../../providers/global/wallets_provider.dart'; +import '../../../../route_generator.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/view_only_option_interface.dart'; +import '../../../../widgets/conditional_parent.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/detail_item.dart'; +import '../../../../widgets/rounded_white_container.dart'; +import '../../../../widgets/stack_dialog.dart'; +import '../../../home_view/home_view.dart'; +import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart'; + +class DeleteViewOnlyWalletKeysView extends ConsumerStatefulWidget { + const DeleteViewOnlyWalletKeysView({ + super.key, + required this.walletId, + required this.data, + }); + + static const routeName = "/deleteWalletViewOnlyData"; + + final String walletId; + final ViewOnlyWalletData data; + + @override + ConsumerState createState() => + _DeleteViewOnlyWalletKeysViewState(); +} + +class _DeleteViewOnlyWalletKeysViewState + extends ConsumerState { + bool _lock = false; + void _continuePressed() async { + if (_lock) { + return; + } + _lock = true; + try { + if (Util.isDesktop) { + await Navigator.of(context).push( + RouteGenerator.getRoute( + builder: (context) { + return ConfirmDelete( + walletId: widget.walletId, + ); + }, + settings: const RouteSettings( + name: "/desktopConfirmDelete", + ), + ), + ); + } else { + await showDialog( + barrierDismissible: true, + context: context, + builder: (_) => StackDialog( + title: "Thanks! Your wallet will be deleted.", + leftButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () { + Navigator.pop(context); + }, + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () async { + await ref.read(pWallets).deleteWallet( + ref.read(pWalletInfo(widget.walletId)), + ref.read(secureStoreProvider), + ); + + if (mounted) { + Navigator.of(context).popUntil( + ModalRoute.withName(HomeView.routeName), + ); + } + }, + child: Text( + "Ok", + style: STextStyles.button(context), + ), + ), + ), + ); + } + } finally { + _lock = false; + } + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, cons) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: cons.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Text( + "Please write down your backup data. Keep it safe and " + "never share it with anyone. " + "Your backup data is the only way you can access your " + "wallet if you forget your PIN, lose your phone, etc." + "\n\n" + "${AppConfig.appName} does not keep nor is able to restore " + "your backup data. " + "Only you have access to your wallet.", + style: STextStyles.label(context), + ), + ), + const SizedBox( + height: 24, + ), + if (widget.data.address != null) + DetailItem( + title: "Address", + detail: widget.data.address!, + button: Util.isDesktop + ? IconCopyButton( + data: widget.data.address!, + ) + : SimpleCopyButton( + data: widget.data.address!, + ), + ), + if (widget.data.address != null) + const SizedBox( + height: 16, + ), + if (widget.data.privateViewKey != null) + DetailItem( + title: "Private view key", + detail: widget.data.privateViewKey!, + button: Util.isDesktop + ? IconCopyButton( + data: widget.data.privateViewKey!, + ) + : SimpleCopyButton( + data: widget.data.privateViewKey!, + ), + ), + if (!Util.isDesktop) const Spacer(), + const SizedBox( + height: 16, + ), + PrimaryButton( + label: "Continue", + onPressed: _continuePressed, + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart index f418ed230..16c48cbb6 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart @@ -14,11 +14,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; + import '../../../../app_config.dart'; import '../../../../notifications/show_flush_bar.dart'; -import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; -import '../../../home_view/home_view.dart'; -import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart'; import '../../../../providers/global/secure_store_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../themes/stack_colors.dart'; @@ -35,6 +33,9 @@ import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/detail_item.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_dialog.dart'; +import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart'; +import '../../../home_view/home_view.dart'; +import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart'; class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget { const DeleteWalletRecoveryPhraseView({ @@ -69,7 +70,6 @@ class _DeleteWalletRecoveryPhraseViewState late ClipboardInterface _clipboardInterface; bool _lock = false; - void _continuePressed() { if (_lock) { return; diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart index afb5137b4..9421b251d 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart @@ -17,9 +17,11 @@ import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/rounded_container.dart'; +import 'delete_view_only_wallet_keys_view.dart'; import 'delete_wallet_recovery_phrase_view.dart'; class DeleteWalletWarningView extends ConsumerWidget { @@ -118,6 +120,7 @@ class DeleteWalletWarningView extends ConsumerWidget { String keys, ({String config, String keys})? prevGen, })? frostWalletData; + ViewOnlyWalletData? viewOnlyData; if (wallet is BitcoinFrostWallet) { final futures = [ @@ -142,18 +145,33 @@ class DeleteWalletWarningView extends ConsumerWidget { ), ); } - } else if (wallet is MnemonicInterface) { - mnemonic = await wallet.getMnemonicAsWords(); + } else { + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly) { + viewOnlyData = await wallet.getViewOnlyWalletData(); + } else if (wallet is MnemonicInterface) { + mnemonic = await wallet.getMnemonicAsWords(); + } } if (context.mounted) { - await Navigator.of(context).pushNamed( - DeleteWalletRecoveryPhraseView.routeName, - arguments: ( - walletId: walletId, - mnemonicWords: mnemonic ?? [], - frostWalletData: frostWalletData, - ), - ); + if (viewOnlyData != null) { + await Navigator.of(context).pushNamed( + DeleteViewOnlyWalletKeysView.routeName, + arguments: ( + walletId: walletId, + data: viewOnlyData, + ), + ); + } else { + await Navigator.of(context).pushNamed( + DeleteWalletRecoveryPhraseView.routeName, + arguments: ( + walletId: walletId, + mnemonicWords: mnemonic ?? [], + frostWalletData: frostWalletData, + ), + ); + } } }, child: Text( diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 0e6930222..bb0cf2037 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -56,6 +56,7 @@ import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_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'; @@ -524,6 +525,10 @@ class _WalletViewState extends ConsumerState { final prefs = ref.watch(prefsChangeNotifierProvider); final showExchange = prefs.enableExchange; + final wallet = ref.watch(pWallets).getWallet(walletId); + + final viewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + return ConditionalParent( condition: _rescanningOnOpen, builder: (child) { @@ -1026,38 +1031,39 @@ class _WalletViewState extends ConsumerState { icon: const FrostSignNavIcon(), onTap: () => _onFrostSignPressed(context), ), - WalletNavigationBarItemData( - label: "Send", - icon: const SendNavIcon(), - onTap: () { - // not sure what this is supposed to accomplish? - // switch (ref - // .read(walletBalanceToggleStateProvider.state) - // .state) { - // case WalletBalanceToggleState.full: - // ref - // .read(publicPrivateBalanceStateProvider.state) - // .state = "Public"; - // break; - // case WalletBalanceToggleState.available: - // ref - // .read(publicPrivateBalanceStateProvider.state) - // .state = "Private"; - // break; - // } - Navigator.of(context).pushNamed( - ref.read(pWallets).getWallet(walletId) - is BitcoinFrostWallet - ? FrostSendView.routeName - : SendView.routeName, - arguments: ( - walletId: walletId, - coin: coin, - ), - ); - }, - ), - if (Constants.enableExchange && + if (!viewOnly) + WalletNavigationBarItemData( + label: "Send", + icon: const SendNavIcon(), + onTap: () { + // not sure what this is supposed to accomplish? + // switch (ref + // .read(walletBalanceToggleStateProvider.state) + // .state) { + // case WalletBalanceToggleState.full: + // ref + // .read(publicPrivateBalanceStateProvider.state) + // .state = "Public"; + // break; + // case WalletBalanceToggleState.available: + // ref + // .read(publicPrivateBalanceStateProvider.state) + // .state = "Private"; + // break; + // } + Navigator.of(context).pushNamed( + wallet is BitcoinFrostWallet + ? FrostSendView.routeName + : SendView.routeName, + arguments: ( + walletId: walletId, + coin: coin, + ), + ); + }, + ), + if (!viewOnly && + Constants.enableExchange && ref.watch(pWalletCoin(walletId)) is! FrostCurrency && AppConfig.hasFeature(AppFeature.swap) && showExchange) @@ -1113,12 +1119,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (ref.watch( - pWallets.select( - (value) => value.getWallet(widget.walletId) - is CoinControlInterface, - ), - ) && + if (wallet is CoinControlInterface && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -1137,12 +1138,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (ref.watch( - pWallets.select( - (value) => - value.getWallet(widget.walletId) is PaynymInterface, - ), - )) + if (wallet is PaynymInterface) WalletNavigationBarItemData( label: "PayNym", icon: const PaynymNavIcon(), @@ -1213,12 +1209,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (ref.watch( - pWallets.select( - (value) => value.getWallet(widget.walletId) - is CashFusionInterface, - ), - )) + if (wallet is CashFusionInterface && !viewOnly) WalletNavigationBarItemData( label: "Fusion", icon: const FusionNavIcon(), @@ -1229,12 +1220,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (ref.watch( - pWallets.select( - (value) => - value.getWallet(widget.walletId) is LibMoneroWallet, - ), - )) + if (wallet is LibMoneroWallet && !viewOnly) WalletNavigationBarItemData( label: "Churn", icon: const ChurnNavIcon(), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart index 21d4bfbf5..16032b36b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart @@ -10,18 +10,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + import '../../../../app_config.dart'; -import 'delete_wallet_keys_popup.dart'; +import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/rounded_container.dart'; -import 'package:tuple/tuple.dart'; +import 'delete_wallet_keys_popup.dart'; class DesktopAttentionDeleteWallet extends ConsumerStatefulWidget { const DesktopAttentionDeleteWallet({ @@ -114,6 +117,58 @@ class _DesktopAttentionDeleteWallet onPressed: () async { final wallet = ref.read(pWallets).getWallet(widget.walletId); + + if (wallet is ViewOnlyOptionInterface && + wallet.isViewOnly) { + final data = await wallet.getViewOnlyWalletData(); + if (context.mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (builder) => DesktopDialog( + maxWidth: 614, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "Wallet keys", + style: STextStyles.desktopH3( + context, + ), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, + ), + ], + ), + Padding( + padding: const EdgeInsets.all(32), + child: DeleteViewOnlyWalletKeysView( + walletId: widget.walletId, + data: data, + ), + ), + ], + ), + ), + ), + ); + } + } else + // TODO: [prio=med] handle other types wallet deletion // All wallets currently are mnemonic based if (wallet is MnemonicInterface) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index bcc5a1146..470c1f89b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -39,6 +39,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface import '../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -380,9 +381,12 @@ class _DesktopWalletFeaturesState extends ConsumerState { wallet is OrdinalsInterface || wallet is CashFusionInterface; + final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + return Row( children: [ - if (Constants.enableExchange && + if (!isViewOnly && + Constants.enableExchange && AppConfig.hasFeature(AppFeature.swap) && showExchange) SecondaryButton( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index d77c989ad..95147a002 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -31,6 +31,7 @@ import '../../../../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface import '../../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import '../../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../../widgets/custom_buttons/draggable_switch_button.dart'; import '../../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../../widgets/desktop/desktop_dialog_close_button.dart'; @@ -238,6 +239,8 @@ class _MoreFeaturesDialogState extends ConsumerState { ), ); + final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + return DesktopDialog( child: Column( mainAxisSize: MainAxisSize.min, @@ -257,7 +260,7 @@ class _MoreFeaturesDialogState extends ConsumerState { const DesktopDialogCloseButton(), ], ), - if (wallet.info.coin is Firo) + if (!isViewOnly && wallet.info.coin is Firo) _MoreFeaturesItem( label: "Anonymize funds", detail: "Anonymize funds", @@ -300,14 +303,14 @@ class _MoreFeaturesDialogState extends ConsumerState { iconAsset: Assets.svg.monkey, onPressed: () async => widget.onMonkeyPressed?.call(), ), - if (wallet is CashFusionInterface) + if (!isViewOnly && wallet is CashFusionInterface) _MoreFeaturesItem( label: "Fusion", detail: "Decentralized mixing protocol", iconAsset: Assets.svg.cashFusion, onPressed: () async => widget.onFusionPressed?.call(), ), - if (wallet is LibMoneroWallet) + if (!isViewOnly && wallet is LibMoneroWallet) _MoreFeaturesItem( label: "Churn", detail: "Churning", diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 092f9884f..622630c4a 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -10,20 +10,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../../frost_route_generator.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; -import '../../my_stack_view.dart'; -import 'desktop_receive.dart'; -import 'desktop_send.dart'; -import 'desktop_token_send.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_tab_view.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/frost_scaffold.dart'; import '../../../../widgets/rounded_white_container.dart'; +import '../../my_stack_view.dart'; +import 'desktop_receive.dart'; +import 'desktop_send.dart'; +import 'desktop_token_send.dart'; class MyWallet extends ConsumerStatefulWidget { const MyWallet({ @@ -48,6 +50,7 @@ class _MyWalletState extends ConsumerState { late final bool isEth; late final CryptoCurrency coin; late final bool isFrost; + late final bool isViewOnly; @override void initState() { @@ -60,11 +63,34 @@ class _MyWalletState extends ConsumerState { titles.add("Transactions"); } + isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; + if (isViewOnly) { + titles.remove("Receive"); + } + super.initState(); } @override Widget build(BuildContext context) { + if (isViewOnly) { + return ListView( + primary: false, + children: [ + RoundedWhiteContainer( + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(20), + child: DesktopReceive( + walletId: widget.walletId, + contractAddress: widget.contractAddress, + ), + ), + ), + ], + ); + } + return ListView( primary: false, children: [ diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index 3ae249dfb..3db6866a8 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -26,6 +26,7 @@ import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -100,7 +101,11 @@ class _UnlockWalletKeysDesktopState throw Exception("FIXME ~= see todo in code"); } } else { - words = await wallet.getMnemonicAsWords(); + if (wallet is ViewOnlyOptionInterface) { + // TODO: is something needed here? + } else { + words = await wallet.getMnemonicAsWords(); + } } KeyDataInterface? keyData; @@ -347,7 +352,11 @@ class _UnlockWalletKeysDesktopState throw Exception("FIXME ~= see todo in code"); } } else { - words = await wallet.getMnemonicAsWords(); + if (wallet is ViewOnlyOptionInterface) { + // TODO: is something needed here? + } else { + words = await wallet.getMnemonicAsWords(); + } } KeyDataInterface? keyData; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart index f1bc2d9f6..e80e42158 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart @@ -182,17 +182,18 @@ class WalletKeysDesktopPopup extends ConsumerWidget { : keyData != null ? CustomTabView( titles: [ - "Mnemonic", + if (words.isNotEmpty) "Mnemonic", if (keyData is XPrivData) "XPriv(s)", if (keyData is CWKeyData) "Keys", ], children: [ - Padding( - padding: const EdgeInsets.only(top: 16), - child: _Mnemonic( - words: words, + if (words.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: _Mnemonic( + words: words, + ), ), - ), if (keyData is XPrivData) WalletXPrivs( xprivData: keyData as XPrivData, diff --git a/lib/route_generator.dart b/lib/route_generator.dart index f18184a0f..41aec3834 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -37,6 +37,7 @@ import 'pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart'; import 'pages/add_wallet_views/new_wallet_recovery_phrase_view/new_wallet_recovery_phrase_view.dart'; import 'pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; +import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart'; import 'pages/add_wallet_views/select_wallet_for_token_view.dart'; import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; @@ -130,6 +131,7 @@ import 'pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_bac import 'pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; +import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/lelantus_settings_view.dart'; @@ -203,6 +205,7 @@ import 'wallets/crypto_currency/intermediate/frost_currency.dart'; import 'wallets/models/tx_data.dart'; import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; +import 'wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import 'widgets/choose_coin_view.dart'; import 'widgets/frost_scaffold.dart'; @@ -1519,6 +1522,28 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case RestoreViewOnlyWalletView.routeName: + if (args is ({ + String walletName, + CryptoCurrency coin, + DateTime? restoreFromDate, + bool enableLelantusScanning, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => RestoreViewOnlyWalletView( + walletName: args.walletName, + coin: args.coin, + restoreFromDate: args.restoreFromDate, + enableLelantusScanning: args.enableLelantusScanning, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case NewWalletRecoveryPhraseView.routeName: if (args is Tuple2>) { return getRoute( @@ -1927,6 +1952,21 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DeleteViewOnlyWalletKeysView.routeName: + if (args is ({String walletId, ViewOnlyWalletData data})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DeleteViewOnlyWalletKeysView( + data: args.data, + walletId: args.walletId, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // exchange steps case Step1View.routeName: diff --git a/lib/widgets/stack_text_field.dart b/lib/widgets/stack_text_field.dart index c64c6a5e8..318b59119 100644 --- a/lib/widgets/stack_text_field.dart +++ b/lib/widgets/stack_text_field.dart @@ -9,10 +9,15 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; import '../utilities/text_styles.dart'; import '../utilities/util.dart'; +import 'icon_widgets/clipboard_icon.dart'; +import 'icon_widgets/x_icon.dart'; +import 'textfield_icon_button.dart'; InputDecoration standardInputDecoration( String? labelText, @@ -52,3 +57,113 @@ InputDecoration standardInputDecoration( focusedErrorBorder: InputBorder.none, ); } + +class FullTextField extends StatefulWidget { + const FullTextField({ + super.key, + this.controller, + this.focusNode, + required this.label, + this.onChanged, + }); + + final String label; + + final TextEditingController? controller; + final FocusNode? focusNode; + + final void Function(String)? onChanged; + + @override + State createState() => _FullTextFieldState(); +} + +class _FullTextFieldState extends State { + late final TextEditingController controller; + late final FocusNode focusNode; + + bool _hasValue = false; + + @override + void initState() { + super.initState(); + controller = widget.controller ?? TextEditingController(); + focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void dispose() { + if (widget.controller == null) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + controller: controller, + autocorrect: false, + enableSuggestions: false, + onChanged: (newValue) { + widget.onChanged?.call(newValue); + }, + focusNode: focusNode, + style: STextStyles.field(context), + decoration: standardInputDecoration( + widget.label, + focusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: controller.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextFieldIconButton( + onTap: () async { + if (_hasValue) { + controller.text = ""; + + setState(() { + _hasValue = false; + }); + } else { + final data = + await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + final content = data.text!.trim(); + + controller.text = content; + setState(() { + _hasValue = content.isNotEmpty; + }); + } + } + + widget.onChanged?.call(controller.text); + }, + child: _hasValue ? const XIcon() : const ClipboardIcon(), + ), + ], + ), + ), + ), + ), + ), + ); + } +}