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 c44c5f2645..978e61545a 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 @@ -8,52 +8,60 @@ * */ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:logger/logger.dart'; import 'package:tuple/tuple.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import '../../../../models/keys/view_only_wallet_data.dart'; +import '../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; +import '../../../../providers/global/secure_store_provider.dart'; +import '../../../../providers/providers.dart'; import '../../../../providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart'; import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; -import '../../../../utilities/format.dart'; -import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../../../../wallets/isar/models/wallet_info.dart'; +import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../../wallets/wallet/wallet.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../../../widgets/custom_buttons/blue_text_button.dart'; -import '../../../../widgets/date_picker/date_picker.dart'; import '../../../../widgets/desktop/desktop_app_bar.dart'; import '../../../../widgets/desktop/desktop_scaffold.dart'; import '../../../../widgets/expandable.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/options.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/start_height_picker.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../../widgets/toggle.dart'; -import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; -import '../../../../wl_gen/interfaces/cs_salvium_interface.dart'; -import '../../../../wl_gen/interfaces/cs_wownero_interface.dart'; +import '../../../home_view/home_view.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; +import '../confirm_recovery_dialog.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/restore_failed_dialog.dart'; +import '../sub_widgets/restore_succeeded_dialog.dart'; +import '../sub_widgets/restoring_dialog.dart'; import 'sub_widgets/mobile_mnemonic_length_selector.dart'; -import 'sub_widgets/restore_from_date_picker.dart'; import 'sub_widgets/restore_options_next_button.dart'; import 'sub_widgets/restore_options_platform_layout.dart'; -final _pIsUsingDate = StateProvider.autoDispose((_) => true); - class RestoreOptionsView extends ConsumerStatefulWidget { const RestoreOptionsView({ super.key, @@ -75,15 +83,11 @@ class _RestoreOptionsViewState extends ConsumerState { late final CryptoCurrency coin; late final bool isDesktop; - late TextEditingController _dateController; - late TextEditingController _blockHeightController; - late FocusNode _blockHeightFocusNode; late FocusNode textFieldFocusNode; late final FocusNode passwordFocusNode; late final TextEditingController passwordController; + late final StartHeightPickerController _heightController; - bool _hasBlockHeight = false; - DateTime? _restoreFromDate; bool hidePassword = true; @override @@ -93,36 +97,25 @@ class _RestoreOptionsViewState extends ConsumerState { coin = widget.coin; isDesktop = Util.isDesktop; - _dateController = TextEditingController(); textFieldFocusNode = FocusNode(); passwordController = TextEditingController(); passwordFocusNode = FocusNode(); - _blockHeightController = TextEditingController(); - _blockHeightFocusNode = FocusNode(); - - _blockHeightController.addListener(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - if (!ref.read(_pIsUsingDate)) { - setState(() { - _hasBlockHeight = _blockHeightController.text.isNotEmpty; - }); - } - } - }); - }); + _heightController = StartHeightPickerController(); } @override void dispose() { - _dateController.dispose(); - _blockHeightController.dispose(); textFieldFocusNode.dispose(); passwordController.dispose(); passwordFocusNode.dispose(); + _heightController.dispose(); super.dispose(); } + // 0 = Seed, 1 = View Only, 2 = URI (Monero only) + int _restoreMode = 0; + WalletUriData? _uriData; + bool _nextLock = false; Future nextPressed() async { if (_nextLock) return; @@ -137,32 +130,33 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - int height = 0; - if (ref.read(_pIsUsingDate)) { - height = getBlockHeightFromDate(_restoreFromDate); - } else { - height = int.tryParse(_blockHeightController.text) ?? 0; - } - if (!_showViewOnlyOption) { - await Navigator.of(context).pushNamed( - RestoreWalletView.routeName, - arguments: Tuple5( - walletName, - coin, - ref.read(mnemonicWordCountStateProvider.state).state, - height, - passwordController.text, - ), - ); - } else { - await Navigator.of(context).pushNamed( - RestoreViewOnlyWalletView.routeName, - arguments: ( - walletName: walletName, - coin: coin, - restoreBlockHeight: height, - ), - ); + final int height = _heightController.height; + switch (_restoreMode) { + case 0: // Seed + await Navigator.of(context).pushNamed( + RestoreWalletView.routeName, + arguments: Tuple5( + walletName, + coin, + ref.read(mnemonicWordCountStateProvider.state).state, + height, + passwordController.text, + ), + ); + break; + case 1: // View Only + await Navigator.of(context).pushNamed( + RestoreViewOnlyWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + restoreBlockHeight: height, + ), + ); + break; + case 2: // URI + await _attemptUriRestore(height); + break; } } } finally { @@ -170,91 +164,205 @@ class _RestoreOptionsViewState extends ConsumerState { } } - Future chooseDate() async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { + Future _attemptUriRestore(int fallbackHeight) async { + final data = _uriData; + if (data == null) return; + + if (!isDesktop) { FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); + await Future.delayed(const Duration(milliseconds: 100)); } - if (mounted) { - final date = await showSWDatePicker(context); - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - } + if (!mounted) return; - Future chooseDesktopDate() async { - final date = await showSWDatePicker(context); - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - - Future chooseMnemonicLength() async { - await showModalBottomSheet( - backgroundColor: Colors.transparent, + await showDialog( context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) { - return MnemonicWordCountSelectSheet( - lengthOptions: coin.possibleMnemonicLengths, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: () => _doUriRestore(data, fallbackHeight), ); }, ); } - int getBlockHeightFromDate(DateTime? date) { + Future _doUriRestore(WalletUriData data, int fallbackHeight) async { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); + + final restoreHeight = data.height ?? fallbackHeight; + try { - int height = 0; - if (date != null) { - if (widget.coin is Monero) { - height = csMonero.getHeightByDate(date); - } - if (widget.coin is Wownero) { - height = csWownero.getHeightByDate(date); + final Map otherDataJson; + if (data.seed != null) { + otherDataJson = {}; + } else if (data.isViewOnly) { + otherDataJson = { + WalletInfoKeys.isViewOnlyKey: true, + WalletInfoKeys.viewOnlyTypeIndexKey: + ViewOnlyWalletType.cryptonote.index, + }; + } else { + otherDataJson = {WalletInfoKeys.isRestoredFromKeysKey: true}; + } + + final info = WalletInfo.createNew( + coin: coin, + name: walletName, + restoreHeight: restoreHeight, + otherDataJsonString: jsonEncode(otherDataJson), + ); + + bool isRestoring = true; + 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)); + }, + ); + }, + ), + ); + } + + try { + var node = ref + .read(nodeServiceChangeNotifierProvider) + .getPrimaryNodeFor(currency: coin); + + if (node == null) { + node = coin.defaultNode(isPrimary: true); + await ref + .read(nodeServiceChangeNotifierProvider) + .save(node, null, false); } - if (widget.coin is Salvium) { - height = csSalvium.getHeightByDate( - DateTime.now().subtract(const Duration(days: 7)), + + final Wallet wallet; + if (data.seed != null) { + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + mnemonic: data.seed, + ); + } else if (data.isViewOnly) { + final viewOnlyData = CryptonoteViewOnlyWalletData( + walletId: info.walletId, + address: data.address ?? "", + privateViewKey: data.viewKey!, + ); + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + viewOnlyData: viewOnlyData, + ); + } else { + final keysRestoreData = jsonEncode({ + "address": data.address ?? "", + "viewKey": data.viewKey!, + "spendKey": data.spendKey!, + }); + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + keysRestoreData: keysRestoreData, ); } - if (height < 0) { - height = 0; + + if (wallet is CryptonoteWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); } - if (widget.coin is Epiccash) { - final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int epicCashFirstBlock = 1565370278; - const double overestimateSecondsPerBlock = 61; - final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; - final int approximateHeight = - chosenSeconds ~/ overestimateSecondsPerBlock; - - height = approximateHeight; - if (height < 0) { - height = 0; + await wallet.recover(isRescan: false); + + if (mounted) { + await wallet.info.setMnemonicVerified( + isar: ref.read(mainDBProvider).isar, + ); + + if (ref.read(pDuress)) { + await wallet.info.updateDuressVisibilityStatus( + isDuressVisible: true, + isar: ref.read(mainDBProvider).isar, + ); + } + + ref.read(pWallets).addWallet(wallet); + + if (mounted) { + if (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) => const RestoreSucceededDialog(), + ); } } - } else { - height = 0; + } catch (e) { + if (mounted && isRestoring) { + Navigator.pop(context); + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) => RestoreFailedDialog( + errorMessage: e.toString(), + walletId: info.walletId, + walletName: info.name, + ), + ); + } } - return height; - } catch (e) { - Logging.instance.log( - Level.info, - "Error getting block height from date: $e", - ); - return 0; + } finally { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); } } - bool _showViewOnlyOption = false; + Future chooseMnemonicLength() async { + await showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) { + return MnemonicWordCountSelectSheet( + lengthOptions: coin.possibleMnemonicLengths, + ); + }, + ); + } @override Widget build(BuildContext context) { @@ -306,61 +414,84 @@ class _RestoreOptionsViewState extends ConsumerState { SizedBox( 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; - }); - }, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), + child: coin is Monero + ? Options( + key: UniqueKey(), + texts: const ["Seed", "View Only", "URI"], + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + selectedIndex: _restoreMode, + onValueChanged: (value) { + setState(() { + _restoreMode = value; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ) + : Toggle( + key: UniqueKey(), + onText: "Seed", + offText: "View Only", + onColor: Theme.of( + context, + ).extension()!.popupBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + isOn: _restoreMode == 1, + onValueChanged: (value) { + setState(() { + _restoreMode = value ? 1 : 0; + }); + }, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), ), if (coin is ViewOnlyOptionCurrencyInterface) SizedBox(height: isDesktop ? 40 : 24), - _showViewOnlyOption - ? ViewOnlyRestoreOption( - coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - ) - : SeedRestoreOption( - coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - pwController: passwordController, - pwFocusNode: passwordFocusNode, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - chooseMnemonicLength: chooseMnemonicLength, - ), + if (_restoreMode == 1) + ViewOnlyRestoreOption( + coin: coin, + heightController: _heightController, + ) + else if (_restoreMode == 2) + UriRestoreOption( + coin: coin, + heightController: _heightController, + onParsed: (data) => setState(() => _uriData = data), + ) + else + SeedRestoreOption( + coin: coin, + heightController: _heightController, + pwController: passwordController, + pwFocusNode: passwordFocusNode, + chooseMnemonicLength: chooseMnemonicLength, + ), if (!isDesktop) const Spacer(flex: 3), SizedBox(height: isDesktop ? 32 : 12), - RestoreOptionsNextButton( - isDesktop: isDesktop, - onPressed: ref.watch(_pIsUsingDate) || _hasBlockHeight - ? nextPressed - : null, + ListenableBuilder( + listenable: _heightController, + builder: (context, _) => RestoreOptionsNextButton( + isDesktop: isDesktop, + onPressed: _restoreMode == 2 + ? (_uriData != null ? nextPressed : null) + : (_heightController.canProceed ? nextPressed : null), + ), ), if (isDesktop) const Spacer(flex: 15), ], @@ -375,23 +506,17 @@ class SeedRestoreOption extends ConsumerStatefulWidget { const SeedRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, required this.pwController, required this.pwFocusNode, - required this.dateChooserFunction, required this.chooseMnemonicLength, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; + final StartHeightPickerController heightController; final TextEditingController pwController; final FocusNode pwFocusNode; - final Future Function() dateChooserFunction; final Future Function() chooseMnemonicLength; @override @@ -401,7 +526,6 @@ class SeedRestoreOption extends ConsumerStatefulWidget { class _SeedRestoreOptionState extends ConsumerState { bool _hidePassword = true; bool _expandedAdvanced = false; - bool _blockFieldEmpty = true; @override Widget build(BuildContext context) { @@ -426,120 +550,13 @@ class _SeedRestoreOptionState extends ConsumerState { children: [ if (isCnAnd25 || widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () => ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ), - ), - ], + widget.coin is Mimblewimblecoin) ...[ + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - SizedBox(height: Util.isDesktop ? 16 : 8), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - ref.watch(_pIsUsingDate) - ? RestoreFromDatePicker( - onTap: widget.dateChooserFunction, - controller: widget.dateController, - ) - : ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - focusNode: widget.blockHeightFocusNode, - controller: widget.blockHeightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) - : STextStyles.field(context), - onChanged: (value) { - setState(() { - _blockFieldEmpty = value.isEmpty; - }); - }, - decoration: - standardInputDecoration( - "Start scanning from...", - widget.blockHeightFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: TextFieldIconButton( - child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", - excludeSemantics: true, - child: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - ), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), - ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - const SizedBox(height: 8), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) - RoundedWhiteContainer( - child: Center( - child: Text( - ref.watch(_pIsUsingDate) - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the initial block height of the wallet", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), - ), - ), - ), - if (isCnAnd25 || - widget.coin is Epiccash || - widget.coin is Mimblewimblecoin) SizedBox(height: Util.isDesktop ? 24 : 16), + ], Text( "Choose recovery phrase length", style: Util.isDesktop @@ -753,156 +770,135 @@ class _SeedRestoreOptionState extends ConsumerState { ], ); } +} - @override - void initState() { - super.initState(); +class ViewOnlyRestoreOption extends StatelessWidget { + const ViewOnlyRestoreOption({ + super.key, + required this.coin, + required this.heightController, + }); - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; + final CryptoCurrency coin; + final StartHeightPickerController heightController; + + @override + Widget build(BuildContext context) { + final showDateOption = coin is CryptonoteCurrency; + return Column( + children: [ + if (showDateOption) ...[ + StartHeightPicker(coin: coin, controller: heightController), + SizedBox(height: Util.isDesktop ? 24 : 16), + ], + ], + ); } } -class ViewOnlyRestoreOption extends ConsumerStatefulWidget { - const ViewOnlyRestoreOption({ +class UriRestoreOption extends ConsumerStatefulWidget { + const UriRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.dateChooserFunction, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, + required this.onParsed, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; - - final Future Function() dateChooserFunction; + final StartHeightPickerController heightController; + final void Function(WalletUriData?) onParsed; @override - ConsumerState createState() => - _ViewOnlyRestoreOptionState(); + ConsumerState createState() => _UriRestoreOptionState(); } -class _ViewOnlyRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; +class _UriRestoreOptionState extends ConsumerState { + late final TextEditingController _uriController; + + @override + void initState() { + super.initState(); + _uriController = TextEditingController(); + } + + @override + void dispose() { + _uriController.dispose(); + super.dispose(); + } + + void _onUriChanged(String value) { + WalletUriData? parsed; + try { + parsed = WalletUriData.fromUriString(value.trim()); + } catch (_) { + parsed = null; + } + + // If the URI carries a height, push it into the shared controller. + if (parsed?.height != null) { + widget.heightController.setBlockHeight(parsed!.height!); + } + + widget.onParsed(parsed); + setState(() {}); // redraw clear button + } @override Widget build(BuildContext context) { - final showDateOption = widget.coin is CryptonoteCurrency; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showDateOption) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - ref.watch(_pIsUsingDate) ? "Choose start date" : "Block height", - style: Util.isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ) - : STextStyles.smallMed12(context), - textAlign: TextAlign.left, - ), - CustomTextButton( - text: ref.watch(_pIsUsingDate) - ? "Use block height" - : "Use date", - onTap: () { - ref.read(_pIsUsingDate.notifier).state = !ref.read( - _pIsUsingDate, - ); - }, - ), - ], - ), - if (showDateOption) SizedBox(height: Util.isDesktop ? 16 : 8), - if (showDateOption) - ref.watch(_pIsUsingDate) - ? RestoreFromDatePicker( - onTap: widget.dateChooserFunction, - controller: widget.dateController, + Text( + "Paste wallet URI", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark3, ) - : ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - focusNode: widget.blockHeightFocusNode, - controller: widget.blockHeightController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textInputAction: TextInputAction.done, - style: Util.isDesktop - ? STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2) - : STextStyles.field(context), - onChanged: (value) { - setState(() { - _blockFieldEmpty = value.isEmpty; - }); - }, - decoration: - standardInputDecoration( - "Start scanning from...", - widget.blockHeightFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: TextFieldIconButton( - child: Semantics( - label: - "Clear Block Height Field Button. Clears the block height field", - excludeSemantics: true, - child: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - ), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + controller: _uriController, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith(height: 2) + : STextStyles.field(context), + decoration: standardInputDecoration( + "monero_wallet:
?seed=...", + FocusNode(), + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: _uriController.text.isNotEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + onTap: () { + _uriController.clear(); + _onUriChanged(""); + }, ), - if (showDateOption) const SizedBox(height: 8), - if (showDateOption) - RoundedWhiteContainer( - child: Center( - child: Text( - ref.watch(_pIsUsingDate) - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the initial block height of the wallet", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), ), ), + maxLines: 3, + minLines: 1, + onChanged: _onUriChanged, ), - if (showDateOption) SizedBox(height: Util.isDesktop ? 24 : 16), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, + ), ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart index 530233ec4d..294321ab0b 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart @@ -9,19 +9,55 @@ */ import 'package:flutter/material.dart'; + import '../../../../../themes/stack_colors.dart'; import '../../../../../utilities/text_styles.dart'; import '../../../../../utilities/util.dart'; +import '../../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../../wallets/crypto_currency/intermediate/cryptonote_currency.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/stack_dialog.dart'; +import '../../../../../widgets/start_height_picker.dart'; + +class ConfirmFullRescanDialog extends StatefulWidget { + const ConfirmFullRescanDialog({ + super.key, + required this.coin, + required this.onConfirm, + }); + + final CryptoCurrency coin; + final void Function(int height) onConfirm; + + @override + State createState() => + _ConfirmFullRescanDialogState(); +} -class ConfirmFullRescanDialog extends StatelessWidget { - const ConfirmFullRescanDialog({super.key, required this.onConfirm}); +class _ConfirmFullRescanDialogState extends State { + late final StartHeightPickerController _heightController; - final VoidCallback onConfirm; + @override + void initState() { + super.initState(); + _heightController = StartHeightPickerController(); + } + + @override + void dispose() { + _heightController.dispose(); + super.dispose(); + } + + bool get _showHeightPicker => + widget.coin is CryptonoteCurrency || + widget.coin is Epiccash || + widget.coin is Mimblewimblecoin; + + int get _selectedHeight => _showHeightPicker ? _heightController.height : 0; @override Widget build(BuildContext context) { @@ -35,9 +71,7 @@ class ConfirmFullRescanDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.only( - left: 32, - ), + padding: const EdgeInsets.only(left: 32), child: Text( "Rescan blockchain", style: STextStyles.desktopH3(context), @@ -60,9 +94,14 @@ class ConfirmFullRescanDialog extends StatelessWidget { "Warning! It may take a while. If you exit before completion, you will have to redo the process.", style: STextStyles.desktopTextSmall(context), ), - const SizedBox( - height: 43, - ), + if (_showHeightPicker) ...[ + const SizedBox(height: 24), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), + ], + const SizedBox(height: 43), Row( children: [ Expanded( @@ -72,15 +111,13 @@ class ConfirmFullRescanDialog extends StatelessWidget { label: "Cancel", ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, onPressed: () { Navigator.of(context).pop(); - onConfirm.call(); + widget.onConfirm(_selectedHeight); }, label: "Rescan", ), @@ -98,34 +135,59 @@ class ConfirmFullRescanDialog extends StatelessWidget { onWillPop: () async { return true; }, - child: StackDialog( - title: "Rescan blockchain", - message: - "Warning! It may take a while. If you exit before completion, you will have to redo the process.", - leftButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Cancel", - style: STextStyles.itemSubtitle12(context), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text( - "Rescan", - style: STextStyles.button(context), - ), - onPressed: () { - Navigator.of(context).pop(); - onConfirm.call(); - }, + child: StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + "Rescan blockchain", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + SelectableText( + "Warning! It may take a while. If you exit before completion, you will have to redo the process.", + style: STextStyles.smallMed14(context), + ), + if (_showHeightPicker) ...[ + const SizedBox(height: 16), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Cancel", + style: STextStyles.itemSubtitle12(context), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + child: Text("Rescan", style: STextStyles.button(context)), + onPressed: () { + Navigator.of(context).pop(); + widget.onConfirm(_selectedHeight); + }, + ), + ), + ], + ), + ], ), ), ); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart index 3eccb79285..0a02bd0ec1 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart @@ -131,7 +131,7 @@ class _WalletNetworkSettingsViewState } } - Future _attemptRescan() async { + Future _attemptRescan(int height) async { if (!Platform.isLinux) await WakelockPlus.enable(); try { @@ -148,6 +148,10 @@ class _WalletNetworkSettingsViewState try { final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (height > 0 && wallet is CryptonoteWallet) { + wallet.setRefreshFromBlockHeight(height); + } + await wallet.recover(isRescan: true); if (mounted) { @@ -449,6 +453,11 @@ class _WalletNetworkSettingsViewState barrierDismissible: true, builder: (context) { return ConfirmFullRescanDialog( + coin: ref.read( + pWalletCoin( + widget.walletId, + ), + ), onConfirm: _attemptRescan, ); }, @@ -1078,6 +1087,7 @@ class _WalletNetworkSettingsViewState await Navigator.of(context).push( FadePageRoute( ConfirmFullRescanDialog( + coin: ref.read(pWalletCoin(widget.walletId)), onConfirm: _attemptRescan, ), const RouteSettings(), diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec7..6e735302ec 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -262,6 +262,43 @@ class AddressUtils { return epicAddress; } + /// Parses a wallet URI (e.g. monero_wallet:...) and returns a Map. + /// + /// Returns null on failure to parse. + static Map? _parseWalletUri(String uri) { + final String scheme; + final Map parsedData = {}; + + final rawScheme = uri.split(":")[0]; + final normalizedScheme = rawScheme.replaceAll("-", "_"); + if (normalizedScheme != rawScheme) { + uri = normalizedScheme + uri.substring(rawScheme.length); + } + + if (uri.split(":")[0].contains("_")) { + // RFC 3986 does not allow underscores in the scheme, so strip one for + // compatibility with Uri.parse. + final String compatibleUri = uri.replaceFirst("_", ""); + scheme = uri.split(":")[0]; + parsedData.addAll(_parseUri(compatibleUri)); + } else { + parsedData.addAll(_parseUri(uri)); + scheme = parsedData['scheme'] as String? ?? ''; + } + + final possibleCoins = AppConfig.coins.where( + (e) => "${e.uriScheme}_wallet".contains(scheme), + ); + + if (possibleCoins.length != 1) { + return null; + } + + parsedData["coin"] = possibleCoins.first; + + return parsedData; + } + /// Formats an address string to remove any unnecessary prefixes or suffixes. String formatAddressMwc(String mimblewimblecoinAddress) { // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an mwcmqs address) @@ -324,3 +361,119 @@ class PaymentUriData { "additionalParams: $additionalParams" " }"; } + +class WalletUriData { + final CryptoCurrency coin; + final String? address; + final String? seed; + final String? spendKey; + final String? viewKey; + final int? height; + final List? txids; + + bool get isViewOnly => spendKey == null && seed == null; + + WalletUriData({ + required this.coin, + this.address, + this.seed, + this.spendKey, + this.viewKey, + this.height, + this.txids, + }); + + factory WalletUriData.fromUriString(String uri) { + final map = AddressUtils._parseWalletUri(uri); + + if (map == null) { + throw Exception("Invalid wallet URI"); + } + + return WalletUriData.fromJson(map, map["coin"] as CryptoCurrency); + } + + /// Factory constructor with validation logic according to the spec: + /// https://github.com/monero-project/monero/wiki/URI-Formatting#wallet-definition-scheme + factory WalletUriData.fromJson( + Map json, + CryptoCurrency coin, + ) { + final address = json["address"] as String?; + final spendKey = json["spend_key"] as String?; + final viewKey = json["view_key"] as String?; + final seed = json["seed"] as String?; + final height = json["height"] != null + ? int.tryParse(json["height"].toString()) + : null; + final txid = json["txid"] as String?; + + final hasSeed = seed != null; + final hasKeys = viewKey != null; + + if (hasSeed && hasKeys) { + throw const FormatException( + "Invalid: cannot specify both seed and keys.", + ); + } + if (!hasSeed && !hasKeys) { + throw const FormatException( + "Invalid: must specify either seed or view_key.", + ); + } + + if (spendKey != null && viewKey == null) { + throw const FormatException("Invalid: spend_key requires view_key."); + } + + if (height != null && txid != null) { + throw const FormatException( + "Invalid: cannot specify both height and txid.", + ); + } + + List? txids; + if (txid != null && txid.isNotEmpty) { + txids = txid + .split(";") + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + return WalletUriData( + coin: coin, + address: address, + spendKey: spendKey, + viewKey: viewKey, + seed: seed, + height: height, + txids: txids, + ); + } + + @override + String toString() { + return "WalletUriData { " + "coin: $coin, " + "address: $address, " + "seed: $seed, " + "spendKey: $spendKey, " + "viewKey: $viewKey, " + "height: $height, " + "txids: $txids" + " }"; + } + + String toJson() { + return jsonEncode({ + "coin": coin.prettyName, + "address": address, + "seed": seed, + "spendKey": spendKey, + "viewKey": viewKey, + "height": height, + "txids": txids, + }); + } +} diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index db0a65c701..b9759da3da 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -581,4 +581,5 @@ abstract class WalletInfoKeys { static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; static const String solanaCustomTokenMintAddresses = "solanaCustomTokenMintAddressesKey"; + static const String isRestoredFromKeysKey = "isRestoredFromKeysKey"; } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a7..0850cd6f4f 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -153,6 +153,7 @@ abstract class Wallet { String? mnemonicPassphrase, String? privateKey, ViewOnlyWalletData? viewOnlyData, + String? keysRestoreData, }) async { // TODO: rework soon? if (walletInfo.isViewOnly && viewOnlyData == null) { @@ -223,6 +224,13 @@ abstract class Wallet { ); } + if (keysRestoreData != null) { + await secureStorageInterface.write( + key: keysRestoreDataKey(walletId: walletInfo.walletId), + value: keysRestoreData, + ); + } + // Store in db after wallet creation await wallet.mainDB.isar.writeTxn(() async { await wallet.mainDB.isar.walletInfo.put(walletInfo); @@ -321,6 +329,10 @@ abstract class Wallet { static String getViewOnlyWalletDataSecStoreKey({required String walletId}) => "${walletId}_viewOnlyWalletData"; + // secure storage key + static String keysRestoreDataKey({required String walletId}) => + "${walletId}_keysRestoreData"; + //============================================================================ // ========== Private ======================================================== diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart new file mode 100644 index 0000000000..782fea7a24 --- /dev/null +++ b/lib/widgets/start_height_picker.dart @@ -0,0 +1,333 @@ +/* + * 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. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; +import '../utilities/format.dart'; +import '../utilities/logger.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import '../wl_gen/interfaces/cs_monero_interface.dart'; +import '../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../wl_gen/interfaces/cs_wownero_interface.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'date_picker/date_picker.dart'; +import 'icon_widgets/x_icon.dart'; +import '../pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; +import 'rounded_white_container.dart'; +import 'stack_text_field.dart'; +import 'textfield_icon_button.dart'; + +/// Controller that exposes the current height selection state to a parent +/// widget. Create one instance per [StartHeightPicker] and pass it in; listen +/// to it (e.g. with [ListenableBuilder]) to react to changes. +class StartHeightPickerController extends ChangeNotifier { + bool _isUsingDate = true; + int _height = 0; + bool _hasBlockHeight = false; + + /// The currently computed block height (0 when using date with no date + /// chosen, or when no block-height text has been entered). + int get height => _height; + + /// Whether the picker is in date mode. + bool get isUsingDate => _isUsingDate; + + /// Whether the user has entered a block height value in block-height mode. + bool get hasBlockHeight => _hasBlockHeight; + + /// Whether the current state satisfies the minimum requirement to proceed + /// (either date mode is active, or a block height has been typed in). + bool get canProceed => _isUsingDate || _hasBlockHeight; + + // Called by StartHeightPicker whenever its internal state changes. + void _update({ + required bool isUsingDate, + required int height, + required bool hasBlockHeight, + }) { + _isUsingDate = isUsingDate; + _height = height; + _hasBlockHeight = hasBlockHeight; + notifyListeners(); + } + + /// Called externally (e.g. when a URI containing a height is parsed) to + /// programmatically switch the picker to block-height mode and fill in a + /// value. The [StartHeightPicker] widget listens to this controller and + /// will update its own UI state accordingly. + void setBlockHeight(int height) { + _requestedHeight = height; + _update( + isUsingDate: false, + height: height, + hasBlockHeight: height > 0, + ); + } + + /// Non-null while a height request from [setBlockHeight] has not yet been + /// consumed by the widget. + int? _requestedHeight; +} + +/// A self-contained widget that lets the user choose either a calendar date or +/// a raw block height as the starting point for a wallet scan or restore. +/// +/// All internal state is managed here; the parent receives updates through +/// [StartHeightPickerController]. +class StartHeightPicker extends StatefulWidget { + const StartHeightPicker({ + super.key, + required this.coin, + required this.controller, + }); + + final CryptoCurrency coin; + final StartHeightPickerController controller; + + @override + State createState() => _StartHeightPickerState(); +} + +class _StartHeightPickerState extends State { + late final TextEditingController _dateController; + late final TextEditingController _blockHeightController; + late final FocusNode _blockHeightFocusNode; + + bool _isUsingDate = true; + DateTime? _restoreFromDate; + bool _blockFieldEmpty = true; + + @override + void initState() { + super.initState(); + _dateController = TextEditingController(); + _blockHeightController = TextEditingController(); + _blockHeightFocusNode = FocusNode(); + widget.controller.addListener(_onControllerChanged); + // Notify the controller after the first frame so that any ListenableBuilder + // watching it doesn't rebuild during its own build phase. + WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _dateController.dispose(); + _blockHeightController.dispose(); + _blockHeightFocusNode.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + final req = widget.controller._requestedHeight; + if (req != null) { + widget.controller._requestedHeight = null; // consume + setState(() { + _isUsingDate = false; + _blockHeightController.text = req.toString(); + _blockFieldEmpty = req == 0; + }); + _notifyController(); + } + } + + void _notifyController() { + widget.controller._update( + isUsingDate: _isUsingDate, + height: _currentHeight, + hasBlockHeight: !_blockFieldEmpty, + ); + } + + int _getBlockHeightFromDate(DateTime? date) { + try { + int height = 0; + if (date != null) { + if (widget.coin is Monero) { + height = csMonero.getHeightByDate(date); + } + if (widget.coin is Wownero) { + height = csWownero.getHeightByDate(date); + } + if (widget.coin is Salvium) { + height = csSalvium.getHeightByDate( + DateTime.now().subtract(const Duration(days: 7)), + ); + } + if (height < 0) { + height = 0; + } + + if (widget.coin is Epiccash) { + final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; + const int epicCashFirstBlock = 1565370278; + const double overestimateSecondsPerBlock = 61; + final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; + final int approximateHeight = + chosenSeconds ~/ overestimateSecondsPerBlock; + + height = approximateHeight; + if (height < 0) { + height = 0; + } + } + } else { + height = 0; + } + return height; + } catch (e) { + Logging.instance.log( + Level.info, + "Error getting block height from date: $e", + ); + return 0; + } + } + + int get _currentHeight { + if (_isUsingDate) { + return _getBlockHeightFromDate(_restoreFromDate); + } else { + return int.tryParse(_blockHeightController.text) ?? 0; + } + } + + Future _chooseDate() async { + if (!Util.isDesktop && FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + if (mounted) { + final date = await showSWDatePicker(context); + if (date != null) { + setState(() { + _restoreFromDate = date; + _dateController.text = Format.formatDate(date); + }); + _notifyController(); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isUsingDate ? "Choose start date" : "Block height", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: _isUsingDate ? "Use block height" : "Use date", + onTap: () { + setState(() { + _isUsingDate = !_isUsingDate; + }); + _notifyController(); + }, + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + _isUsingDate + ? RestoreFromDatePicker( + onTap: _chooseDate, + controller: _dateController, + ) + : ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + focusNode: _blockHeightFocusNode, + controller: _blockHeightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + style: Util.isDesktop + ? STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2) + : STextStyles.field(context), + onChanged: (value) { + setState(() { + _blockFieldEmpty = value.isEmpty; + }); + _notifyController(); + }, + decoration: + standardInputDecoration( + "Start scanning from...", + _blockHeightFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: Semantics( + label: + "Clear Block Height Field Button. Clears the block height field", + excludeSemantics: true, + child: !_blockFieldEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + ), + onTap: () { + _blockHeightController.text = ""; + setState(() { + _blockFieldEmpty = true; + }); + _notifyController(); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + _isUsingDate + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the initial block height of the wallet", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } +}