From 93e6aefbe1fa1c2ae34db8187d7f9145a0452cb6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 11:02:39 -0600 Subject: [PATCH 01/15] feat: add key-based wallet restore interface and data model --- lib/wallets/isar/models/wallet_info.dart | 1 + lib/wallets/wallet/impl/monero_wallet.dart | 18 +++++++++++++ lib/wallets/wallet/wallet.dart | 12 +++++++++ .../interfaces/cs_monero_interface.dart | 10 +++++++ ...XMR_cs_monero_interface_impl.template.dart | 27 +++++++++++++++++++ 5 files changed, 68 insertions(+) diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 12329f8ce..4eb5d577d 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -584,4 +584,5 @@ abstract class WalletInfoKeys { "solanaCustomTokenMintAddressesKey"; static const String firoMasternodeCollateralDismissed = "firoMasternodeCollateralDismissedKey"; + static const String isRestoredFromKeysKey = "isRestoredFromKeysKey"; } diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart index 935d5ad3a..469382b91 100644 --- a/lib/wallets/wallet/impl/monero_wallet.dart +++ b/lib/wallets/wallet/impl/monero_wallet.dart @@ -88,6 +88,24 @@ class MoneroWallet extends LibMoneroWallet { height: height, ); + @override + Future getRestoredFromKeysWallet({ + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }) => csMonero.getRestoredFromKeysWallet( + walletId: walletId, + path: path, + password: password, + address: address, + privateViewKey: privateViewKey, + privateSpendKey: privateSpendKey, + height: height, + ); + @override void invalidSeedLengthCheck(int length) { if (length != 25 && length != 16) { diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a..0850cd6f4 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/wl_gen/interfaces/cs_monero_interface.dart b/lib/wl_gen/interfaces/cs_monero_interface.dart index f9f30d5c8..284fe17a5 100644 --- a/lib/wl_gen/interfaces/cs_monero_interface.dart +++ b/lib/wl_gen/interfaces/cs_monero_interface.dart @@ -58,6 +58,16 @@ abstract class CsMoneroInterface { int height = 0, }); + Future getRestoredFromKeysWallet({ + required String walletId, + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }); + Future getTxKey(WrappedWallet wallet, String txid); Future save(WrappedWallet wallet); diff --git a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart index b957ad36d..e053bf9d1 100644 --- a/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart +++ b/tool/wl_templates/XMR_cs_monero_interface_impl.template.dart @@ -173,6 +173,33 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface { ); } + @override + Future getRestoredFromKeysWallet({ + required String walletId, + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int network = 0, // default to mainnet + int height = 0, + }) async { + return WrappedWallet( + await lib_monero.MoneroWallet.restoreWalletFromKeys( + path: path, + password: password, + language: "", + address: address, + viewKey: privateViewKey, + spendKey: privateSpendKey, + restoreHeight: height, + networkType: lib_monero.Network.values.firstWhere( + (e) => e.value == network, + ), + ), + ); + } + @override Future getTxKey(WrappedWallet wallet, String txid) => wallet.get().getTxKey(txid); From 67e68e7e8a7f42d66907e5bb7af14ea79becd3bb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 11:45:25 -0600 Subject: [PATCH 02/15] feat: add key-based recovery path for Monero wallets --- .../intermediate/lib_monero_wallet.dart | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index 6c0c49884..c29b5c8b2 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -163,6 +163,15 @@ abstract class LibMoneroWallet int height = 0, }); + Future getRestoredFromKeysWallet({ + required String path, + required String password, + required String address, + required String privateViewKey, + required String privateSpendKey, + int height = 0, + }); + void invalidSeedLengthCheck(int length); bool walletExists(String path); @@ -406,6 +415,14 @@ abstract class LibMoneroWallet return; } + final keysDataJson = await secureStorageInterface.read( + key: Wallet.keysRestoreDataKey(walletId: walletId), + ); + if (keysDataJson != null) { + await _recoverFromKeys(keysDataJson); + return; + } + await refreshMutex.protect(() async { final mnemonic = await getMnemonic(); final seedOffset = await getMnemonicPassphrase(); @@ -1543,6 +1560,102 @@ abstract class LibMoneroWallet csMonero.setRefreshFromBlockHeight(wallet!, newHeight); } + // ============== Key-based restore ========================================== + + Future _recoverFromKeys(String keysDataJson) async { + await refreshMutex.protect(() async { + final data = jsonDecode(keysDataJson) as Map; + final address = data["address"] as String; + final viewKey = data["viewKey"] as String; + final spendKey = data["spendKey"] as String; + + try { + final height = max(info.restoreHeight, 0); + + await info.updateRestoreHeight( + newRestoreHeight: height, + isar: mainDB.isar, + ); + + final String name = walletId; + final path = await pathForWallet(name: name, type: compatType); + + final password = generatePassword(); + await secureStorageInterface.write( + key: lib_monero_compat.libMoneroWalletPasswordKey(walletId), + value: password, + ); + + final wallet = await getRestoredFromKeysWallet( + path: path, + password: password, + address: address, + privateViewKey: viewKey, + privateSpendKey: spendKey, + height: height, + ); + + if (this.wallet != null) { + await exit(); + } + this.wallet = wallet; + + _setListener(); + + // Try to recover the mnemonic from the restored wallet + try { + final seed = await csMonero.getSeed(wallet); + if (seed.isNotEmpty) { + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: walletId), + value: seed, + ); + await secureStorageInterface.write( + key: Wallet.mnemonicPassphraseKey(walletId: walletId), + value: "", + ); + } + } catch (_) { + // Not all key-restored wallets can recover the seed + } + + final newReceivingAddress = + await getCurrentReceivingAddress() ?? + Address( + walletId: walletId, + derivationIndex: 0, + derivationPath: null, + value: await csMonero.getAddress(this.wallet!), + publicKey: [], + type: AddressType.cryptonote, + subType: AddressSubType.receiving, + ); + + await mainDB.updateOrPutAddresses([newReceivingAddress]); + await info.updateReceivingAddress( + newAddress: newReceivingAddress.value, + isar: mainDB.isar, + ); + + await updateNode(); + _setListener(); + + await csMonero.rescanBlockchain(this.wallet!); + await csMonero.startSyncing(this.wallet!); + + await csMonero.startListeners(this.wallet!); + csMonero.startAutoSaving(this.wallet!); + } catch (e, s) { + Logging.instance.e( + "Exception rethrown from _recoverFromKeys(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + }); + } + // ============== View only ================================================== @override From 82c567f3cb9538639a056808167f1beeb3baf58b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 15:32:58 -0600 Subject: [PATCH 03/15] feat: add URI restore option to Monero wallet restore UI --- .../restore_options_view.dart | 579 ++++++++++++++++-- 1 file changed, 512 insertions(+), 67 deletions(-) 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 e650564a1..626d7b4d7 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,6 +8,10 @@ * */ +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'; @@ -15,10 +19,16 @@ 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'; @@ -28,6 +38,9 @@ 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'; @@ -36,6 +49,7 @@ 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/textfield_icon_button.dart'; @@ -43,10 +57,15 @@ 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'; @@ -85,6 +104,7 @@ class _RestoreOptionsViewState extends ConsumerState { bool _hasBlockHeight = false; DateTime? _restoreFromDate; bool hidePassword = true; + WalletUriData? _uriData; @override void initState() { @@ -143,26 +163,32 @@ class _RestoreOptionsViewState extends ConsumerState { } 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, - ), - ); + 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 { @@ -254,7 +280,193 @@ class _RestoreOptionsViewState extends ConsumerState { } } - bool _showViewOnlyOption = false; + Future _attemptUriRestore(int fallbackHeight) async { + final data = _uriData; + if (data == null) return; + + if (!isDesktop) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (!mounted) return; + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: () => _doUriRestore(data, fallbackHeight), + ); + }, + ); + } + + Future _doUriRestore(WalletUriData data, int fallbackHeight) async { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); + + final restoreHeight = data.height ?? fallbackHeight; + + try { + 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); + } + + 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 (wallet is CryptonoteWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); + } + + 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(), + ); + } + } + } 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, + ), + ); + } + } + } finally { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); + } + } + + // 0 = Seed, 1 = View Only, 2 = URI (Monero only) + int _restoreMode = 0; @override Widget build(BuildContext context) { @@ -306,59 +518,96 @@ 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, + dateController: _dateController, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + ) + else if (_restoreMode == 2) + UriRestoreOption( + coin: coin, + dateController: _dateController, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + onParsed: (data) => setState(() => _uriData = data), + ) + else + SeedRestoreOption( + coin: coin, + dateController: _dateController, + blockHeightController: _blockHeightController, + blockHeightFocusNode: _blockHeightFocusNode, + pwController: passwordController, + pwFocusNode: passwordFocusNode, + dateChooserFunction: isDesktop + ? chooseDesktopDate + : chooseDate, + chooseMnemonicLength: chooseMnemonicLength, + ), if (!isDesktop) const Spacer(flex: 3), SizedBox(height: isDesktop ? 32 : 12), RestoreOptionsNextButton( isDesktop: isDesktop, - onPressed: ref.watch(_pIsUsingDate) || _hasBlockHeight + onPressed: _restoreMode == 2 + ? (_uriData != null ? nextPressed : null) + : ref.watch(_pIsUsingDate) || _hasBlockHeight ? nextPressed : null, ), @@ -906,3 +1155,199 @@ class _ViewOnlyRestoreOptionState extends ConsumerState { _blockFieldEmpty = widget.blockHeightController.text.isEmpty; } } + +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.onParsed, + }); + + final CryptoCurrency coin; + final TextEditingController dateController; + final TextEditingController blockHeightController; + final FocusNode blockHeightFocusNode; + final void Function(WalletUriData?) onParsed; + + final Future Function() dateChooserFunction; + + @override + ConsumerState createState() => _UriRestoreOptionState(); +} + +class _UriRestoreOptionState extends ConsumerState { + bool _blockFieldEmpty = true; + late final TextEditingController _uriController; + + @override + void initState() { + super.initState(); + _blockFieldEmpty = widget.blockHeightController.text.isEmpty; + _uriController = TextEditingController(); + } + + @override + void dispose() { + _uriController.dispose(); + super.dispose(); + } + + void _onUriChanged(String value) { + WalletUriData? parsed; + try { + parsed = WalletUriData.fromUriString(value); + } catch (_) { + parsed = null; + } + widget.onParsed(parsed); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Paste wallet URI", + 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), + 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(""); + }, + ), + ), + ), + maxLines: 3, + minLines: 1, + onChanged: _onUriChanged, + ), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + 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, + ), + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + 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: !_blockFieldEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + onTap: () { + widget.blockHeightController.text = ""; + setState(() { + _blockFieldEmpty = true; + }); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + 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), + ), + ), + ), + ], + ); + } +} From b323b34110406e18d0d168056d04c2ef6a5af624 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 16:33:29 -0600 Subject: [PATCH 04/15] feat: add generic WalletUriData class and wallet URI parser Co-Authored-By: detherminal <76167420+detherminal@users.noreply.github.com> --- lib/utilities/address_utils.dart | 190 +++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 10 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec..e1a465c3d 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -85,34 +85,51 @@ class AddressUtils { return result; } + /// Strips surrounding single or double quotes from a string. + static String _stripQuotes(String value) { + if (value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")))) { + return value.substring(1, value.length - 1); + } + return value; + } + /// Helper method to parse and normalize query parameters. + /// + /// Keys are lowercased and dashes are replaced with underscores so that + /// e.g. `spend-key` and `spend_key` are treated identically. + /// Surrounding quotation marks on values are stripped. static Map _parseQueryParameters(Map params) { final Map result = {}; params.forEach((key, value) { - final lowerKey = key.toLowerCase(); - if (recognizedParams.contains(lowerKey)) { - switch (lowerKey) { + // Normalize: lowercase, dashes -> underscores. + final normalizedKey = key.toLowerCase().replaceAll('-', '_'); + final strippedValue = _stripQuotes(value); + + if (recognizedParams.contains(normalizedKey)) { + switch (normalizedKey) { case 'amount': case 'tx_amount': - result['amount'] = _normalizeAmount(value); + result['amount'] = _normalizeAmount(strippedValue); break; case 'label': case 'recipient_name': - result['label'] = Uri.decodeComponent(value); + result['label'] = Uri.decodeComponent(strippedValue); break; case 'message': case 'tx_description': - result['message'] = Uri.decodeComponent(value); + result['message'] = Uri.decodeComponent(strippedValue); break; case 'tx_payment_id': - result['tx_payment_id'] = Uri.decodeComponent(value); + result['tx_payment_id'] = Uri.decodeComponent(strippedValue); break; default: - result[lowerKey] = Uri.decodeComponent(value); + result[normalizedKey] = Uri.decodeComponent(strippedValue); } } else { - // Include unrecognized parameters as-is. - result[key] = Uri.decodeComponent(value); + // Include unrecognized parameters with normalized key. + result[normalizedKey] = Uri.decodeComponent(strippedValue); } }); return result; @@ -174,6 +191,38 @@ class AddressUtils { } } + /// Parses a wallet URI and returns a Map. + /// + /// Returns null on failure to parse. + static Map? _parseWalletUri(String uri) { + final String scheme; + final Map parsedData = {}; + if (uri.split(":")[0].contains("_")) { + // We need to check if the uri is compatible because RFC 3986 + // does not allow underscores in the scheme. + final String compatibleUri = uri.replaceFirst("_", ""); + scheme = uri.split(":")[0]; + parsedData.addAll(_parseUri(compatibleUri)); + } else { + parsedData.addAll(_parseUri(uri)); + scheme = parsedData['scheme'] as String? ?? ''; + } + + // not sure this is the best way to handle this but will leave + // as is for now + final possibleCoins = AppConfig.coins.where( + (e) => "${e.uriScheme}_wallet".contains(scheme), + ); + + if (possibleCoins.length != 1) { + return null; + } + + parsedData["coin"] = possibleCoins.first; + + return parsedData; + } + /// Builds a uri string with the given address and query parameters (if any) static String buildUriString( String scheme, @@ -324,3 +373,124 @@ 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?; + + // Must have seed XOR view_key (spend_key is optional). + // May have seed only, view_key + spend_key, or view_key only. + 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.", + ); + } + + // Spend_key requires view_key. + if (spendKey != null && viewKey == null) { + throw const FormatException("Invalid: spend_key requires view_key."); + } + + // Height requires absence of txid. + if (height != null && txid != null) { + throw const FormatException( + "Invalid: cannot specify both height and txid.", + ); + } + + // Parse txids if present. + 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, + }); + } +} From 8a38c3ff8f2075b5f6ae82b352bc897c5a1898d2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:05:16 -0600 Subject: [PATCH 05/15] fix: guard against short addresses these shouldn't exist/happen, but do/can --- lib/utilities/address_utils.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index e1a465c3d..2960c70be 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -27,6 +27,7 @@ class AddressUtils { }; static String condenseAddress(String address) { + if (address.length < 10) return address; return '${address.substring(0, 5)}...${address.substring(address.length - 5)}'; } From cfcaf0745ae646a17540b90b07165140cd50767c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:17:12 -0600 Subject: [PATCH 06/15] feat: use height param --- .../restore_options_view/restore_options_view.dart | 7 +++++++ 1 file changed, 7 insertions(+) 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 626d7b4d7..30118d886 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 @@ -1203,6 +1203,13 @@ class _UriRestoreOptionState extends ConsumerState { } catch (_) { parsed = null; } + + // If the URI contains a height, switch to block height mode and populate. + if (parsed?.height != null) { + ref.read(_pIsUsingDate.notifier).state = false; + widget.blockHeightController.text = parsed!.height.toString(); + } + widget.onParsed(parsed); } From 90089a3ed3078fe794eff6e712cc873f3d2c8ae2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:51:35 -0600 Subject: [PATCH 07/15] fix: fix non-view-only keys-only wallet keys dialog --- .../wallet_settings_view/wallet_settings_view.dart | 6 +++++- .../delete_wallet_warning_view.dart | 6 +++++- .../sub_widgets/desktop_attention_delete_wallet.dart | 7 ++++++- .../sub_widgets/unlock_wallet_keys_desktop.dart | 6 +++++- 4 files changed, 21 insertions(+), 4 deletions(-) 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 4fd2e8f95..516dde641 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 @@ -200,7 +200,11 @@ class _WalletSettingsViewState extends ConsumerState { (wallet as ViewOnlyOptionInterface).isViewOnly) { // TODO: is something needed here? } else { - mnemonic = await wallet.getMnemonicAsWords(); + try { + mnemonic = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } } 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 d6bc5f2e2..a05405465 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 @@ -141,7 +141,11 @@ class DeleteWalletWarningView extends ConsumerWidget { wallet.isViewOnly) { viewOnlyData = await wallet.getViewOnlyWalletData(); } else if (wallet is MnemonicInterface) { - mnemonic = await wallet.getMnemonicAsWords(); + try { + mnemonic = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } if (context.mounted) { 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 17de7cd21..6611a3e8d 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 @@ -166,7 +166,12 @@ class _DesktopAttentionDeleteWallet // TODO: [prio=med] handle other types wallet deletion // All wallets currently are mnemonic based if (wallet is MnemonicInterface) { - final words = await wallet.getMnemonicAsWords(); + List words = []; + try { + words = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } if (context.mounted) { await Navigator.of(context).pushNamed( 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 dc1577183..66363d13f 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 @@ -119,7 +119,11 @@ class _UnlockWalletKeysDesktopState (wallet as ViewOnlyOptionInterface).isViewOnly) { // TODO: is something needed here? } else { - words = await wallet.getMnemonicAsWords(); + try { + words = await wallet.getMnemonicAsWords(); + } catch (_) { + // Key-restored wallets may not have a mnemonic. + } } } From fc2359935a274bb825e4874224cbb8e273e2b332 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 14:23:41 -0600 Subject: [PATCH 08/15] fix: time wallet uri previously if it had a newline at the beginning it would choke --- .../restore_options_view/restore_options_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 30118d886..2e76fc4e8 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 @@ -1199,7 +1199,7 @@ class _UriRestoreOptionState extends ConsumerState { void _onUriChanged(String value) { WalletUriData? parsed; try { - parsed = WalletUriData.fromUriString(value); + parsed = WalletUriData.fromUriString(value.trim()); } catch (_) { parsed = null; } From 546f4ba9d386c5ec6637dda022df9da3cecd4a12 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 14:25:56 -0600 Subject: [PATCH 09/15] feat: allow dashes in wallet uri scheme monero_wallet worked. now monero-wallet works, too there's a frustrating variety of xmr wallet uri schemes: some with underscores, some with dashes --- lib/utilities/address_utils.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 2960c70be..acb26607f 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -198,6 +198,13 @@ class AddressUtils { 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("_")) { // We need to check if the uri is compatible because RFC 3986 // does not allow underscores in the scheme. From 47ed3236813a6af7586fc0faf2a22d5d8b701198 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:31:52 -0600 Subject: [PATCH 10/15] feat: add blockheight to rescan confirm dialog and wallet recovery --- .../sub_widgets/confirm_full_rescan.dart | 14 ++++++++++---- .../wallet_network_settings_view.dart | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) 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 530233ec4..2838bba6b 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 @@ -12,6 +12,7 @@ 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 '../../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../../widgets/desktop/primary_button.dart'; @@ -19,9 +20,14 @@ import '../../../../../widgets/desktop/secondary_button.dart'; import '../../../../../widgets/stack_dialog.dart'; class ConfirmFullRescanDialog extends StatelessWidget { - const ConfirmFullRescanDialog({super.key, required this.onConfirm}); + const ConfirmFullRescanDialog({ + super.key, + required this.coin, + required this.onConfirm, + }); - final VoidCallback onConfirm; + final CryptoCurrency coin; + final void Function(int height) onConfirm; @override Widget build(BuildContext context) { @@ -80,7 +86,7 @@ class ConfirmFullRescanDialog extends StatelessWidget { buttonHeight: ButtonHeight.l, onPressed: () { Navigator.of(context).pop(); - onConfirm.call(); + onConfirm(0); }, label: "Rescan", ), @@ -124,7 +130,7 @@ class ConfirmFullRescanDialog extends StatelessWidget { ), onPressed: () { Navigator.of(context).pop(); - onConfirm.call(); + onConfirm(0); }, ), ), 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 3eccb7928..0a02bd0ec 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(), From b504047d2cc2776226beaf0974a69572591e576e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:32:37 -0600 Subject: [PATCH 11/15] feat: add date and block height picker to rescan blockchain dialog --- .../sub_widgets/confirm_full_rescan.dart | 301 ++++++++++++++++-- 1 file changed, 271 insertions(+), 30 deletions(-) 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 2838bba6b..35dfb553d 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,17 +9,34 @@ */ 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 '../../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../../../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../../../widgets/date_picker/date_picker.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/icon_widgets/x_icon.dart'; +import '../../../../../widgets/rounded_white_container.dart'; import '../../../../../widgets/stack_dialog.dart'; +import '../../../../../widgets/stack_text_field.dart'; +import '../../../../../widgets/textfield_icon_button.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 '../../../../add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; -class ConfirmFullRescanDialog extends StatelessWidget { +class ConfirmFullRescanDialog extends StatefulWidget { const ConfirmFullRescanDialog({ super.key, required this.coin, @@ -29,6 +46,205 @@ class ConfirmFullRescanDialog extends StatelessWidget { final CryptoCurrency coin; final void Function(int height) onConfirm; + @override + State createState() => + _ConfirmFullRescanDialogState(); +} + +class _ConfirmFullRescanDialogState 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(); + } + + @override + void dispose() { + _dateController.dispose(); + _blockHeightController.dispose(); + _blockHeightFocusNode.dispose(); + super.dispose(); + } + + 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; + } + } + + 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); + }); + } + } + } + + int get _selectedHeight { + if (_isUsingDate) { + return _getBlockHeightFromDate(_restoreFromDate); + } else { + return int.tryParse(_blockHeightController.text) ?? 0; + } + } + + Widget _buildHeightPickerSection() { + 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), + ), + ], + ), + 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; + }); + }, + 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; + }); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + _isUsingDate + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the block height to start rescanning from", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { if (Util.isDesktop) { @@ -66,6 +282,8 @@ 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: 24), + _buildHeightPickerSection(), const SizedBox( height: 43, ), @@ -86,7 +304,7 @@ class ConfirmFullRescanDialog extends StatelessWidget { buttonHeight: ButtonHeight.l, onPressed: () { Navigator.of(context).pop(); - onConfirm(0); + widget.onConfirm(_selectedHeight); }, label: "Rescan", ), @@ -104,34 +322,57 @@ 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(0); - }, + 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), + ), + const SizedBox(height: 16), + _buildHeightPickerSection(), + 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); + }, + ), + ), + ], + ), + ], ), ), ); From 32d1431e3cbf073232b9ae3ed97302ae17aa8273 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:35:54 -0600 Subject: [PATCH 12/15] feat: restrict rescan height picker to cryptonote and mimblewimble coins --- .../sub_widgets/confirm_full_rescan.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 35dfb553d..928f859b0 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 @@ -137,7 +137,13 @@ class _ConfirmFullRescanDialogState extends State { } } + bool get _showHeightPicker => + widget.coin is CryptonoteCurrency || + widget.coin is Epiccash || + widget.coin is Mimblewimblecoin; + int get _selectedHeight { + if (!_showHeightPicker) return 0; if (_isUsingDate) { return _getBlockHeightFromDate(_restoreFromDate); } else { @@ -282,8 +288,10 @@ class _ConfirmFullRescanDialogState extends State { "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: 24), - _buildHeightPickerSection(), + if (_showHeightPicker) ...[ + const SizedBox(height: 24), + _buildHeightPickerSection(), + ], const SizedBox( height: 43, ), @@ -335,8 +343,10 @@ class _ConfirmFullRescanDialogState extends State { "Warning! It may take a while. If you exit before completion, you will have to redo the process.", style: STextStyles.smallMed14(context), ), - const SizedBox(height: 16), - _buildHeightPickerSection(), + if (_showHeightPicker) ...[ + const SizedBox(height: 16), + _buildHeightPickerSection(), + ], const SizedBox(height: 20), Row( children: [ From 85c2a798b8aa7885d4fe3a1c6234c217465db58f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:48:59 -0600 Subject: [PATCH 13/15] refactor: extract date/height picker into StartHeightPicker widget --- .../restore_options_view.dart | 971 ++---------------- .../sub_widgets/confirm_full_rescan.dart | 229 +---- lib/widgets/start_height_picker.dart | 296 ++++++ 3 files changed, 394 insertions(+), 1102 deletions(-) create mode 100644 lib/widgets/start_height_picker.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 2e76fc4e8..a68bf7106 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,71 +8,39 @@ * */ -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/textfield_icon_button.dart'; +import '../../../../widgets/start_height_picker.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, @@ -94,17 +62,12 @@ 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; - WalletUriData? _uriData; @override void initState() { @@ -113,33 +76,18 @@ 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(); } @@ -157,38 +105,27 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - int height = 0; - if (ref.read(_pIsUsingDate)) { - height = getBlockHeightFromDate(_restoreFromDate); + final int height = _heightController.height; + if (!_showViewOnlyOption) { + await Navigator.of(context).pushNamed( + RestoreWalletView.routeName, + arguments: Tuple5( + walletName, + coin, + ref.read(mnemonicWordCountStateProvider.state).state, + height, + passwordController.text, + ), + ); } else { - height = int.tryParse(_blockHeightController.text) ?? 0; - } - 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; + await Navigator.of(context).pushNamed( + RestoreViewOnlyWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + restoreBlockHeight: height, + ), + ); } } } finally { @@ -196,30 +133,6 @@ class _RestoreOptionsViewState extends ConsumerState { } } - Future chooseDate() async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = (await showSWDatePicker(context))?.first; - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - } - - Future chooseDesktopDate() async { - final date = (await showSWDatePicker(context))?.first; - if (date != null) { - _restoreFromDate = date; - _dateController.text = Format.formatDate(date); - } - } - Future chooseMnemonicLength() async { await showModalBottomSheet( backgroundColor: Colors.transparent, @@ -235,238 +148,7 @@ class _RestoreOptionsViewState extends ConsumerState { ); } - 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; - } - } - - Future _attemptUriRestore(int fallbackHeight) async { - final data = _uriData; - if (data == null) return; - - if (!isDesktop) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 100)); - } - - if (!mounted) return; - - await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return ConfirmRecoveryDialog( - onConfirm: () => _doUriRestore(data, fallbackHeight), - ); - }, - ); - } - - Future _doUriRestore(WalletUriData data, int fallbackHeight) async { - if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); - - final restoreHeight = data.height ?? fallbackHeight; - - try { - 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); - } - - 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 (wallet is CryptonoteWallet) { - await wallet.init(isRestore: true); - } else { - await wallet.init(); - } - - 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(), - ); - } - } - } 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, - ), - ); - } - } - } finally { - if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); - } - } - - // 0 = Seed, 1 = View Only, 2 = URI (Monero only) - int _restoreMode = 0; + bool _showViewOnlyOption = false; @override Widget build(BuildContext context) { @@ -518,98 +200,52 @@ class _RestoreOptionsViewState extends ConsumerState { SizedBox( height: isDesktop ? 56 : 48, width: isDesktop ? 490 : null, - 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, - ), - ), - ), + 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, + ), + ), + ), ), if (coin is ViewOnlyOptionCurrencyInterface) SizedBox(height: isDesktop ? 40 : 24), - if (_restoreMode == 1) - ViewOnlyRestoreOption( - coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - ) - else if (_restoreMode == 2) - UriRestoreOption( - coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - onParsed: (data) => setState(() => _uriData = data), - ) - else - SeedRestoreOption( - coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, - pwController: passwordController, - pwFocusNode: passwordFocusNode, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - chooseMnemonicLength: chooseMnemonicLength, - ), + _showViewOnlyOption + ? ViewOnlyRestoreOption( + coin: coin, + heightController: _heightController, + ) + : 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: _restoreMode == 2 - ? (_uriData != null ? nextPressed : null) - : ref.watch(_pIsUsingDate) || _hasBlockHeight - ? nextPressed - : null, + ListenableBuilder( + listenable: _heightController, + builder: (context, _) => RestoreOptionsNextButton( + isDesktop: isDesktop, + onPressed: _heightController.canProceed ? nextPressed : null, + ), ), if (isDesktop) const Spacer(flex: 15), ], @@ -624,23 +260,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 @@ -650,7 +280,6 @@ class SeedRestoreOption extends ConsumerStatefulWidget { class _SeedRestoreOptionState extends ConsumerState { bool _hidePassword = true; bool _expandedAdvanced = false; - bool _blockFieldEmpty = true; @override Widget build(BuildContext context) { @@ -675,120 +304,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 @@ -1002,358 +524,27 @@ class _SeedRestoreOptionState extends ConsumerState { ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } -class ViewOnlyRestoreOption extends ConsumerStatefulWidget { +class ViewOnlyRestoreOption extends StatelessWidget { const ViewOnlyRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.dateChooserFunction, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; - - final Future Function() dateChooserFunction; - - @override - ConsumerState createState() => - _ViewOnlyRestoreOptionState(); -} - -class _ViewOnlyRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; + final StartHeightPickerController heightController; @override Widget build(BuildContext context) { - final showDateOption = widget.coin is CryptonoteCurrency; + final showDateOption = coin is CryptonoteCurrency; return Column( 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, - ) - : 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 (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), - ), - ), - ), - if (showDateOption) SizedBox(height: Util.isDesktop ? 24 : 16), - ], - ); - } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } -} - -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.onParsed, - }); - - final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; - final void Function(WalletUriData?) onParsed; - - final Future Function() dateChooserFunction; - - @override - ConsumerState createState() => _UriRestoreOptionState(); -} - -class _UriRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; - late final TextEditingController _uriController; - - @override - void initState() { - super.initState(); - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - _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 contains a height, switch to block height mode and populate. - if (parsed?.height != null) { - ref.read(_pIsUsingDate.notifier).state = false; - widget.blockHeightController.text = parsed!.height.toString(); - } - - widget.onParsed(parsed); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Paste wallet URI", - 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), - 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(""); - }, - ), - ), - ), - maxLines: 3, - minLines: 1, - onChanged: _onUriChanged, - ), - ), - SizedBox(height: Util.isDesktop ? 24 : 16), - 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, - ), - ), - ], - ), - SizedBox(height: Util.isDesktop ? 16 : 8), - 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: !_blockFieldEmpty - ? XIcon( - width: Util.isDesktop ? 24 : 16, - height: Util.isDesktop ? 24 : 16, - ) - : const SizedBox.shrink(), - onTap: () { - widget.blockHeightController.text = ""; - setState(() { - _blockFieldEmpty = true; - }); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 8), - 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 (showDateOption) ...[ + StartHeightPicker(coin: coin, controller: heightController), + SizedBox(height: Util.isDesktop ? 24 : 16), + ], ], ); } 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 928f859b0..294321ab0 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,32 +9,18 @@ */ 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 '../../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; -import '../../../../../widgets/custom_buttons/blue_text_button.dart'; -import '../../../../../widgets/date_picker/date_picker.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/icon_widgets/x_icon.dart'; -import '../../../../../widgets/rounded_white_container.dart'; import '../../../../../widgets/stack_dialog.dart'; -import '../../../../../widgets/stack_text_field.dart'; -import '../../../../../widgets/textfield_icon_button.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 '../../../../add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; +import '../../../../../widgets/start_height_picker.dart'; class ConfirmFullRescanDialog extends StatefulWidget { const ConfirmFullRescanDialog({ @@ -52,204 +38,26 @@ class ConfirmFullRescanDialog extends StatefulWidget { } class _ConfirmFullRescanDialogState extends State { - late final TextEditingController _dateController; - late final TextEditingController _blockHeightController; - late final FocusNode _blockHeightFocusNode; - - bool _isUsingDate = true; - DateTime? _restoreFromDate; - bool _blockFieldEmpty = true; + late final StartHeightPickerController _heightController; @override void initState() { super.initState(); - _dateController = TextEditingController(); - _blockHeightController = TextEditingController(); - _blockHeightFocusNode = FocusNode(); + _heightController = StartHeightPickerController(); } @override void dispose() { - _dateController.dispose(); - _blockHeightController.dispose(); - _blockHeightFocusNode.dispose(); + _heightController.dispose(); super.dispose(); } - 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; - } - } - - 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); - }); - } - } - } - bool get _showHeightPicker => widget.coin is CryptonoteCurrency || widget.coin is Epiccash || widget.coin is Mimblewimblecoin; - int get _selectedHeight { - if (!_showHeightPicker) return 0; - if (_isUsingDate) { - return _getBlockHeightFromDate(_restoreFromDate); - } else { - return int.tryParse(_blockHeightController.text) ?? 0; - } - } - - Widget _buildHeightPickerSection() { - 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), - ), - ], - ), - 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; - }); - }, - 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; - }); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 8), - RoundedWhiteContainer( - child: Center( - child: Text( - _isUsingDate - ? "Choose the date you made the wallet (approximate is fine)" - : "Enter the block height to start rescanning from", - style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ) - : STextStyles.smallMed12(context).copyWith(fontSize: 10), - ), - ), - ), - ], - ); - } + int get _selectedHeight => _showHeightPicker ? _heightController.height : 0; @override Widget build(BuildContext context) { @@ -263,9 +71,7 @@ class _ConfirmFullRescanDialogState extends State { 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), @@ -290,11 +96,12 @@ class _ConfirmFullRescanDialogState extends State { ), if (_showHeightPicker) ...[ const SizedBox(height: 24), - _buildHeightPickerSection(), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), ], - const SizedBox( - height: 43, - ), + const SizedBox(height: 43), Row( children: [ Expanded( @@ -304,9 +111,7 @@ class _ConfirmFullRescanDialogState extends State { label: "Cancel", ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( buttonHeight: ButtonHeight.l, @@ -345,7 +150,10 @@ class _ConfirmFullRescanDialogState extends State { ), if (_showHeightPicker) ...[ const SizedBox(height: 16), - _buildHeightPickerSection(), + StartHeightPicker( + coin: widget.coin, + controller: _heightController, + ), ], const SizedBox(height: 20), Row( @@ -370,10 +178,7 @@ class _ConfirmFullRescanDialogState extends State { style: Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), - child: Text( - "Rescan", - style: STextStyles.button(context), - ), + child: Text("Rescan", style: STextStyles.button(context)), onPressed: () { Navigator.of(context).pop(); widget.onConfirm(_selectedHeight); diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart new file mode 100644 index 000000000..bb8f5952e --- /dev/null +++ b/lib/widgets/start_height_picker.dart @@ -0,0 +1,296 @@ +/* + * 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'; + +/// Exposes the current height selection of a [StartHeightPicker] to its parent. +/// Create one per picker, pass it in, and listen to it to react to changes. +class StartHeightPickerController extends ChangeNotifier { + bool _isUsingDate = true; + int _height = 0; + bool _hasBlockHeight = false; + + /// Computed block height. 0 when in date mode with no date chosen, or when no + /// block height has been entered. + int get height => _height; + + /// Whether the picker is in date mode. + bool get isUsingDate => _isUsingDate; + + /// Whether a block height has been entered while in block height mode. + bool get hasBlockHeight => _hasBlockHeight; + + /// Whether the current state is enough to proceed: date mode is active, or a + /// block height has been entered. + 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(); + } +} + +/// Lets the user choose either a calendar date or a block height as the +/// starting point for a wallet scan or restore. State is managed internally; +/// 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(); + // Notify after the first frame so a watching ListenableBuilder does not + // rebuild during its own build phase. + WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); + } + + @override + void dispose() { + _dateController.dispose(); + _blockHeightController.dispose(); + _blockHeightFocusNode.dispose(); + super.dispose(); + } + + 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(date); + } + 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))?.first; + 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), + ), + ), + ), + ], + ); + } +} From 29b9ede612377518e389bdb2930ab5c3f0c4406f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 5 Mar 2026 00:12:08 -0600 Subject: [PATCH 14/15] feat: add URI restore to restore options, using StartHeightPickerController --- .../restore_options_view.dart | 463 +++++++++++++++--- lib/widgets/start_height_picker.dart | 31 ++ 2 files changed, 439 insertions(+), 55 deletions(-) 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 a68bf7106..978e61545 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,15 +8,25 @@ * */ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.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/text_styles.dart'; @@ -24,19 +34,30 @@ 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/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 '../../../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_options_next_button.dart'; import 'sub_widgets/restore_options_platform_layout.dart'; @@ -91,6 +112,10 @@ class _RestoreOptionsViewState extends ConsumerState { 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; @@ -106,30 +131,221 @@ class _RestoreOptionsViewState extends ConsumerState { if (mounted) { final int height = _heightController.height; - if (!_showViewOnlyOption) { - await Navigator.of(context).pushNamed( - RestoreWalletView.routeName, - arguments: Tuple5( - walletName, - coin, - ref.read(mnemonicWordCountStateProvider.state).state, - height, - passwordController.text, - ), + 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 { + _nextLock = false; + } + } + + Future _attemptUriRestore(int fallbackHeight) async { + final data = _uriData; + if (data == null) return; + + if (!isDesktop) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (!mounted) return; + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return ConfirmRecoveryDialog( + onConfirm: () => _doUriRestore(data, fallbackHeight), + ); + }, + ); + } + + Future _doUriRestore(WalletUriData data, int fallbackHeight) async { + if (!Platform.isLinux && !isDesktop) await WakelockPlus.enable(); + + final restoreHeight = data.height ?? fallbackHeight; + + try { + 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); + } + + 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 { - await Navigator.of(context).pushNamed( - RestoreViewOnlyWalletView.routeName, - arguments: ( - walletName: walletName, - coin: coin, - restoreBlockHeight: height, + 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 (wallet is CryptonoteWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); + } + + 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(), + ); + } + } + } 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, ), ); } } } finally { - _nextLock = false; + if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); } } @@ -148,8 +364,6 @@ class _RestoreOptionsViewState extends ConsumerState { ); } - bool _showViewOnlyOption = false; - @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType with ${coin.identifier} $walletName"); @@ -200,51 +414,83 @@ 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, - heightController: _heightController, - ) - : SeedRestoreOption( - coin: coin, - heightController: _heightController, - pwController: passwordController, - pwFocusNode: passwordFocusNode, - 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), ListenableBuilder( listenable: _heightController, builder: (context, _) => RestoreOptionsNextButton( isDesktop: isDesktop, - onPressed: _heightController.canProceed ? nextPressed : null, + onPressed: _restoreMode == 2 + ? (_uriData != null ? nextPressed : null) + : (_heightController.canProceed ? nextPressed : null), ), ), if (isDesktop) const Spacer(flex: 15), @@ -549,3 +795,110 @@ class ViewOnlyRestoreOption extends StatelessWidget { ); } } + +class UriRestoreOption extends ConsumerStatefulWidget { + const UriRestoreOption({ + super.key, + required this.coin, + required this.heightController, + required this.onParsed, + }); + + final CryptoCurrency coin; + final StartHeightPickerController heightController; + final void Function(WalletUriData?) onParsed; + + @override + ConsumerState createState() => _UriRestoreOptionState(); +} + +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) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Paste wallet URI", + 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), + 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(""); + }, + ), + ), + ), + maxLines: 3, + minLines: 1, + onChanged: _onUriChanged, + ), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, + ), + ], + ); + } +} diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart index bb8f5952e..999cb640d 100644 --- a/lib/widgets/start_height_picker.dart +++ b/lib/widgets/start_height_picker.dart @@ -62,6 +62,22 @@ class StartHeightPickerController extends ChangeNotifier { _hasBlockHeight = hasBlockHeight; notifyListeners(); } + + /// Switches the picker to block height mode and fills in a value. Called + /// externally, e.g. when a URI containing a height is parsed. The picker + /// listens to this controller and updates its UI accordingly. + void setBlockHeight(int height) { + _requestedHeight = height; + _update( + isUsingDate: false, + height: height, + hasBlockHeight: height > 0, + ); + } + + /// Non-null while a [setBlockHeight] request has not yet been consumed by the + /// widget. + int? _requestedHeight; } /// Lets the user choose either a calendar date or a block height as the @@ -96,6 +112,7 @@ class _StartHeightPickerState extends State { _dateController = TextEditingController(); _blockHeightController = TextEditingController(); _blockHeightFocusNode = FocusNode(); + widget.controller.addListener(_onControllerChanged); // Notify after the first frame so a watching ListenableBuilder does not // rebuild during its own build phase. WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); @@ -103,12 +120,26 @@ class _StartHeightPickerState extends State { @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, From 592f10d701d01d104e73646060919ed8fec83fba Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 11:26:20 -0500 Subject: [PATCH 15/15] chore: dart format `git diff --name-only --diff-filter=d origin/staging | grep '\.dart$' | xargs -r dart format && git diff --name-only --diff-filter=d | grep '\.dart$' | xargs -r git commit -m "chore: dart format"` --- .../restore_options_view.dart | 39 ++++++++++--------- .../wallet_settings_view.dart | 7 ++-- .../delete_wallet_warning_view.dart | 28 ++++++------- lib/utilities/address_utils.dart | 22 +++++++---- lib/widgets/start_height_picker.dart | 6 +-- 5 files changed, 51 insertions(+), 51 deletions(-) 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 978e61545..eae32c580 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 @@ -868,26 +868,27 @@ class _UriRestoreOptionState extends ConsumerState { 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(""); - }, + 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(""); + }, + ), + ), ), - ), - ), maxLines: 3, minLines: 1, onChanged: _onUriChanged, 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 516dde641..a3c31d170 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 @@ -421,10 +421,9 @@ class _WalletSettingsViewState extends ConsumerState { iconSize: 16, title: "Epicbox Servers", onPressed: () { - Navigator.of(context).pushNamed( - ManageEpicboxView.routeName, - arguments: walletId, - ); + Navigator.of( + context, + ).pushNamed(ManageEpicboxView.routeName, arguments: walletId); }, ), if (canBackup) const SizedBox(height: 8), 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 a05405465..4562ee8fd 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 @@ -59,10 +59,9 @@ class DeleteWalletWarningView extends ConsumerWidget { ), const SizedBox(height: 16), RoundedContainer( - color: - Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of( + context, + ).extension()!.warningBackground, child: Text( "You are going to permanently delete your wallet.\n\n" "If you delete your wallet, the only way you can have access" @@ -70,10 +69,9 @@ class DeleteWalletWarningView extends ConsumerWidget { "${AppConfig.appName} does not keep nor is able to restore " "your backup key or your wallet.\n\nPLEASE SAVE YOUR BACKUP KEY.", style: STextStyles.baseXS(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, ), ), ), @@ -88,10 +86,9 @@ class DeleteWalletWarningView extends ConsumerWidget { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -130,10 +127,9 @@ class DeleteWalletWarningView extends ConsumerWidget { myName: wallet.frostInfo.myName, config: results[1]!, keys: results[0]!, - prevGen: - results[2] == null || results[3] == null - ? null - : (config: results[3]!, keys: results[2]!), + prevGen: results[2] == null || results[3] == null + ? null + : (config: results[3]!, keys: results[2]!), ); } } else { diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index acb26607f..036d4869e 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -325,21 +325,29 @@ class AddressUtils { if ((mimblewimblecoinAddress.startsWith("http://") || mimblewimblecoinAddress.startsWith("https://")) && mimblewimblecoinAddress.contains("@")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("http://", ""); - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("https://", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "http://", + "", + ); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "https://", + "", + ); } // strip mailto: prefix if (mimblewimblecoinAddress.startsWith("mailto:")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("mailto:", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "mailto:", + "", + ); } // strip / suffix if the address contains an @ symbol (and is thus an mwcmqs address) if (mimblewimblecoinAddress.endsWith("/") && mimblewimblecoinAddress.contains("@")) { mimblewimblecoinAddress = mimblewimblecoinAddress.substring( - 0, mimblewimblecoinAddress.length - 1); + 0, + mimblewimblecoinAddress.length - 1, + ); } return mimblewimblecoinAddress; } diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart index 999cb640d..41c570f29 100644 --- a/lib/widgets/start_height_picker.dart +++ b/lib/widgets/start_height_picker.dart @@ -68,11 +68,7 @@ class StartHeightPickerController extends ChangeNotifier { /// listens to this controller and updates its UI accordingly. void setBlockHeight(int height) { _requestedHeight = height; - _update( - isUsingDate: false, - height: height, - hasBlockHeight: height > 0, - ); + _update(isUsingDate: false, height: height, hasBlockHeight: height > 0); } /// Non-null while a [setBlockHeight] request has not yet been consumed by the