From 134ffaecbc2145d7b3ca97c049bc38a9c750fa73 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 15:44:06 -0600 Subject: [PATCH 01/19] 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 1cc2bbfe4168ede4e004ac7d3598415cf43a7502 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 15:45:25 -0600 Subject: [PATCH 02/19] 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 dd9f73e7cf9d072df6e53fc062734b4a565dec27 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 15:47:04 -0600 Subject: [PATCH 03/19] 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 3fa83a6cd69122eacf35e5bacdcb991067e5b4ce Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:31:52 -0600 Subject: [PATCH 04/19] 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 0f125214c7196b1e01335114df5a47df789630fc Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:32:37 -0600 Subject: [PATCH 05/19] 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 4f108bfec5a10bf493db1bf194348b5b997c8784 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:35:54 -0600 Subject: [PATCH 06/19] 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 bc101af432aebe7b06c33a96077c929e99abc972 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 4 Mar 2026 22:48:59 -0600 Subject: [PATCH 07/19] refactor: extract date/height picker into StartHeightPicker widget --- .../restore_options_view.dart | 413 ++---------------- .../sub_widgets/confirm_full_rescan.dart | 229 +--------- lib/widgets/start_height_picker.dart | 301 +++++++++++++ 3 files changed, 346 insertions(+), 597 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 e650564a1..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 @@ -10,10 +10,8 @@ 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 '../../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; @@ -21,8 +19,6 @@ import '../../../../providers/ui/verify_recovery_phrase/mnemonic_word_count_stat import '../../../../themes/stack_colors.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'; @@ -30,30 +26,21 @@ import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.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/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 '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; import '../restore_view_only_wallet_view.dart'; import '../restore_wallet_view.dart'; import '../sub_widgets/mnemonic_word_count_select_sheet.dart'; import 'sub_widgets/mobile_mnemonic_length_selector.dart'; -import 'sub_widgets/restore_from_date_picker.dart'; import 'sub_widgets/restore_options_next_button.dart'; import 'sub_widgets/restore_options_platform_layout.dart'; -final _pIsUsingDate = StateProvider.autoDispose((_) => true); - class RestoreOptionsView extends ConsumerStatefulWidget { const RestoreOptionsView({ super.key, @@ -75,15 +62,11 @@ class _RestoreOptionsViewState extends ConsumerState { late final CryptoCurrency coin; late final bool isDesktop; - late TextEditingController _dateController; - late TextEditingController _blockHeightController; - late FocusNode _blockHeightFocusNode; late FocusNode textFieldFocusNode; late final FocusNode passwordFocusNode; late final TextEditingController passwordController; + late final StartHeightPickerController _heightController; - bool _hasBlockHeight = false; - DateTime? _restoreFromDate; bool hidePassword = true; @override @@ -93,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(); } @@ -137,12 +105,7 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - int height = 0; - if (ref.read(_pIsUsingDate)) { - height = getBlockHeightFromDate(_restoreFromDate); - } else { - height = int.tryParse(_blockHeightController.text) ?? 0; - } + final int height = _heightController.height; if (!_showViewOnlyOption) { await Navigator.of(context).pushNamed( RestoreWalletView.routeName, @@ -170,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, @@ -209,51 +148,6 @@ 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; - } - } - bool _showViewOnlyOption = false; @override @@ -335,32 +229,23 @@ class _RestoreOptionsViewState extends ConsumerState { _showViewOnlyOption ? ViewOnlyRestoreOption( coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, ) : SeedRestoreOption( coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, 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 - ? nextPressed - : null, + ListenableBuilder( + listenable: _heightController, + builder: (context, _) => RestoreOptionsNextButton( + isDesktop: isDesktop, + onPressed: _heightController.canProceed ? nextPressed : null, + ), ), if (isDesktop) const Spacer(flex: 15), ], @@ -375,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 @@ -401,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) { @@ -426,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 @@ -753,156 +524,28 @@ 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), + if (showDateOption) ...[ + StartHeightPicker(coin: coin, controller: heightController), + SizedBox(height: Util.isDesktop ? 24 : 16), + ], ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/sub_widgets/confirm_full_rescan.dart index 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..d1993e917 --- /dev/null +++ b/lib/widgets/start_height_picker.dart @@ -0,0 +1,301 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; +import '../utilities/format.dart'; +import '../utilities/logger.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import '../wl_gen/interfaces/cs_monero_interface.dart'; +import '../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../wl_gen/interfaces/cs_wownero_interface.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'date_picker/date_picker.dart'; +import 'icon_widgets/x_icon.dart'; +import '../pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; +import 'rounded_white_container.dart'; +import 'stack_text_field.dart'; +import 'textfield_icon_button.dart'; + +/// Controller that exposes the current height selection state to a parent +/// widget. Create one instance per [StartHeightPicker] and pass it in; listen +/// to it (e.g. with [ListenableBuilder]) to react to changes. +class StartHeightPickerController extends ChangeNotifier { + bool _isUsingDate = true; + int _height = 0; + bool _hasBlockHeight = false; + + /// The currently computed block height (0 when using date with no date + /// chosen, or when no block-height text has been entered). + int get height => _height; + + /// Whether the picker is in date mode. + bool get isUsingDate => _isUsingDate; + + /// Whether the user has entered a block height value in block-height mode. + bool get hasBlockHeight => _hasBlockHeight; + + /// Whether the current state satisfies the minimum requirement to proceed + /// (either date mode is active, or a block height has been typed in). + bool get canProceed => _isUsingDate || _hasBlockHeight; + + // Called by StartHeightPicker whenever its internal state changes. + void _update({ + required bool isUsingDate, + required int height, + required bool hasBlockHeight, + }) { + _isUsingDate = isUsingDate; + _height = height; + _hasBlockHeight = hasBlockHeight; + notifyListeners(); + } +} + +/// A self-contained widget that lets the user choose either a calendar date or +/// a raw block height as the starting point for a wallet scan or restore. +/// +/// All internal state is managed here; the parent receives updates through +/// [StartHeightPickerController]. +class StartHeightPicker extends StatefulWidget { + const StartHeightPicker({ + super.key, + required this.coin, + required this.controller, + }); + + final CryptoCurrency coin; + final StartHeightPickerController controller; + + @override + State createState() => _StartHeightPickerState(); +} + +class _StartHeightPickerState extends State { + late final TextEditingController _dateController; + late final TextEditingController _blockHeightController; + late final FocusNode _blockHeightFocusNode; + + bool _isUsingDate = true; + DateTime? _restoreFromDate; + bool _blockFieldEmpty = true; + + @override + void initState() { + super.initState(); + _dateController = TextEditingController(); + _blockHeightController = TextEditingController(); + _blockHeightFocusNode = FocusNode(); + // Notify the controller after the first frame so that any ListenableBuilder + // watching it doesn't rebuild during its own build phase. + WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); + } + + @override + void dispose() { + _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( + DateTime.now().subtract(const Duration(days: 7)), + ); + } + if (height < 0) { + height = 0; + } + + if (widget.coin is Epiccash) { + final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; + const int epicCashFirstBlock = 1565370278; + const double overestimateSecondsPerBlock = 61; + final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; + final int approximateHeight = + chosenSeconds ~/ overestimateSecondsPerBlock; + + height = approximateHeight; + if (height < 0) { + height = 0; + } + } + } else { + height = 0; + } + return height; + } catch (e) { + Logging.instance.log( + Level.info, + "Error getting block height from date: $e", + ); + return 0; + } + } + + int get _currentHeight { + if (_isUsingDate) { + return _getBlockHeightFromDate(_restoreFromDate); + } else { + return int.tryParse(_blockHeightController.text) ?? 0; + } + } + + Future _chooseDate() async { + if (!Util.isDesktop && FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + if (mounted) { + final date = await showSWDatePicker(context); + if (date != null) { + setState(() { + _restoreFromDate = date; + _dateController.text = Format.formatDate(date); + }); + _notifyController(); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isUsingDate ? "Choose start date" : "Block height", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: _isUsingDate ? "Use block height" : "Use date", + onTap: () { + setState(() { + _isUsingDate = !_isUsingDate; + }); + _notifyController(); + }, + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + _isUsingDate + ? RestoreFromDatePicker( + onTap: _chooseDate, + controller: _dateController, + ) + : ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + focusNode: _blockHeightFocusNode, + controller: _blockHeightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + style: Util.isDesktop + ? STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2) + : STextStyles.field(context), + onChanged: (value) { + setState(() { + _blockFieldEmpty = value.isEmpty; + }); + _notifyController(); + }, + decoration: + standardInputDecoration( + "Start scanning from...", + _blockHeightFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: Semantics( + label: + "Clear Block Height Field Button. Clears the block height field", + excludeSemantics: true, + child: !_blockFieldEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + ), + onTap: () { + _blockHeightController.text = ""; + setState(() { + _blockFieldEmpty = true; + }); + _notifyController(); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + _isUsingDate + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the initial block height of the wallet", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } +} From 65d0321ed3e06797a82bff2f61a15ea82cf7c0f0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 5 Mar 2026 00:12:08 -0600 Subject: [PATCH 08/19] feat: add URI restore to restore options, using StartHeightPickerController --- .../restore_options_view.dart | 463 +++++++++++++++--- lib/utilities/address_utils.dart | 153 ++++++ lib/wallets/isar/models/wallet_info.dart | 1 + lib/wallets/wallet/wallet.dart | 12 + lib/widgets/start_height_picker.dart | 32 ++ 5 files changed, 606 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/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec..6e735302e 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -262,6 +262,43 @@ class AddressUtils { return epicAddress; } + /// Parses a wallet URI (e.g. monero_wallet:...) and returns a Map. + /// + /// Returns null on failure to parse. + static Map? _parseWalletUri(String uri) { + final String scheme; + final Map parsedData = {}; + + final rawScheme = uri.split(":")[0]; + final normalizedScheme = rawScheme.replaceAll("-", "_"); + if (normalizedScheme != rawScheme) { + uri = normalizedScheme + uri.substring(rawScheme.length); + } + + if (uri.split(":")[0].contains("_")) { + // RFC 3986 does not allow underscores in the scheme, so strip one for + // compatibility with Uri.parse. + final String compatibleUri = uri.replaceFirst("_", ""); + scheme = uri.split(":")[0]; + parsedData.addAll(_parseUri(compatibleUri)); + } else { + parsedData.addAll(_parseUri(uri)); + scheme = parsedData['scheme'] as String? ?? ''; + } + + final possibleCoins = AppConfig.coins.where( + (e) => "${e.uriScheme}_wallet".contains(scheme), + ); + + if (possibleCoins.length != 1) { + return null; + } + + parsedData["coin"] = possibleCoins.first; + + return parsedData; + } + /// Formats an address string to remove any unnecessary prefixes or suffixes. String formatAddressMwc(String mimblewimblecoinAddress) { // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an mwcmqs address) @@ -324,3 +361,119 @@ class PaymentUriData { "additionalParams: $additionalParams" " }"; } + +class WalletUriData { + final CryptoCurrency coin; + final String? address; + final String? seed; + final String? spendKey; + final String? viewKey; + final int? height; + final List? txids; + + bool get isViewOnly => spendKey == null && seed == null; + + WalletUriData({ + required this.coin, + this.address, + this.seed, + this.spendKey, + this.viewKey, + this.height, + this.txids, + }); + + factory WalletUriData.fromUriString(String uri) { + final map = AddressUtils._parseWalletUri(uri); + + if (map == null) { + throw Exception("Invalid wallet URI"); + } + + return WalletUriData.fromJson(map, map["coin"] as CryptoCurrency); + } + + /// Factory constructor with validation logic according to the spec: + /// https://github.com/monero-project/monero/wiki/URI-Formatting#wallet-definition-scheme + factory WalletUriData.fromJson( + Map json, + CryptoCurrency coin, + ) { + final address = json["address"] as String?; + final spendKey = json["spend_key"] as String?; + final viewKey = json["view_key"] as String?; + final seed = json["seed"] as String?; + final height = json["height"] != null + ? int.tryParse(json["height"].toString()) + : null; + final txid = json["txid"] as String?; + + final hasSeed = seed != null; + final hasKeys = viewKey != null; + + if (hasSeed && hasKeys) { + throw const FormatException( + "Invalid: cannot specify both seed and keys.", + ); + } + if (!hasSeed && !hasKeys) { + throw const FormatException( + "Invalid: must specify either seed or view_key.", + ); + } + + if (spendKey != null && viewKey == null) { + throw const FormatException("Invalid: spend_key requires view_key."); + } + + if (height != null && txid != null) { + throw const FormatException( + "Invalid: cannot specify both height and txid.", + ); + } + + List? txids; + if (txid != null && txid.isNotEmpty) { + txids = txid + .split(";") + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + return WalletUriData( + coin: coin, + address: address, + spendKey: spendKey, + viewKey: viewKey, + seed: seed, + height: height, + txids: txids, + ); + } + + @override + String toString() { + return "WalletUriData { " + "coin: $coin, " + "address: $address, " + "seed: $seed, " + "spendKey: $spendKey, " + "viewKey: $viewKey, " + "height: $height, " + "txids: $txids" + " }"; + } + + String toJson() { + return jsonEncode({ + "coin": coin.prettyName, + "address": address, + "seed": seed, + "spendKey": spendKey, + "viewKey": viewKey, + "height": height, + "txids": txids, + }); + } +} diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index 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/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/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart index d1993e917..782fea7a2 100644 --- a/lib/widgets/start_height_picker.dart +++ b/lib/widgets/start_height_picker.dart @@ -63,6 +63,23 @@ class StartHeightPickerController extends ChangeNotifier { _hasBlockHeight = hasBlockHeight; notifyListeners(); } + + /// Called externally (e.g. when a URI containing a height is parsed) to + /// programmatically switch the picker to block-height mode and fill in a + /// value. The [StartHeightPicker] widget listens to this controller and + /// will update its own UI state accordingly. + void setBlockHeight(int height) { + _requestedHeight = height; + _update( + isUsingDate: false, + height: height, + hasBlockHeight: height > 0, + ); + } + + /// Non-null while a height request from [setBlockHeight] has not yet been + /// consumed by the widget. + int? _requestedHeight; } /// A self-contained widget that lets the user choose either a calendar date or @@ -99,6 +116,7 @@ class _StartHeightPickerState extends State { _dateController = TextEditingController(); _blockHeightController = TextEditingController(); _blockHeightFocusNode = FocusNode(); + widget.controller.addListener(_onControllerChanged); // Notify the controller after the first frame so that any ListenableBuilder // watching it doesn't rebuild during its own build phase. WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); @@ -106,12 +124,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 09bd34af922b4908b8c9e2bc8a25a6f80499f1a0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 21 Feb 2026 16:18:56 -0600 Subject: [PATCH 09/19] 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..7771ec0f4 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 33b1bd9c6790cb8f14c42f8955d286144c694f54 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 12:25:59 -0600 Subject: [PATCH 10/19] 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 7771ec0f4..f8988a8c3 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 e94ea75eb04e66d1c458816b1bf5b3684819af22 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 13:47:25 -0600 Subject: [PATCH 11/19] 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 59c5f9c1e65c7fad987ab0e0738cb803202e32ce Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 22 Feb 2026 14:07:32 -0600 Subject: [PATCH 12/19] 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 aa72cced52d170c80525ef594b81129d73bdd409 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 5 Mar 2026 16:26:03 -0600 Subject: [PATCH 13/19] refactor: use StartHeightPickerController for URI restore --- .../restore_options_view.dart | 583 ++---------------- lib/widgets/start_height_picker.dart | 333 ++++++++++ 2 files changed, 396 insertions(+), 520 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 30118d886..052c93a96 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 @@ -14,10 +14,8 @@ 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'; @@ -31,8 +29,6 @@ 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'; @@ -43,8 +39,6 @@ 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'; @@ -52,11 +46,9 @@ import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/options.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/start_height_picker.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../../widgets/toggle.dart'; -import '../../../../wl_gen/interfaces/cs_monero_interface.dart'; -import '../../../../wl_gen/interfaces/cs_salvium_interface.dart'; -import '../../../../wl_gen/interfaces/cs_wownero_interface.dart'; import '../../../home_view/home_view.dart'; import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart'; import '../confirm_recovery_dialog.dart'; @@ -67,12 +59,9 @@ 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,15 +83,11 @@ class _RestoreOptionsViewState extends ConsumerState { late final CryptoCurrency coin; late final bool isDesktop; - late TextEditingController _dateController; - late TextEditingController _blockHeightController; - late FocusNode _blockHeightFocusNode; late FocusNode textFieldFocusNode; late final FocusNode passwordFocusNode; late final TextEditingController passwordController; + late final StartHeightPickerController _heightController; - bool _hasBlockHeight = false; - DateTime? _restoreFromDate; bool hidePassword = true; WalletUriData? _uriData; @@ -113,33 +98,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,12 +127,7 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - int height = 0; - if (ref.read(_pIsUsingDate)) { - height = getBlockHeightFromDate(_restoreFromDate); - } else { - height = int.tryParse(_blockHeightController.text) ?? 0; - } + final int height = _heightController.height; switch (_restoreMode) { case 0: // Seed await Navigator.of(context).pushNamed( @@ -196,30 +161,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,51 +176,6 @@ 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; @@ -468,6 +364,7 @@ class _RestoreOptionsViewState extends ConsumerState { // 0 = Seed, 1 = View Only, 2 = URI (Monero only) int _restoreMode = 0; + @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType with ${coin.identifier} $walletName"); @@ -570,46 +467,32 @@ class _RestoreOptionsViewState extends ConsumerState { if (_restoreMode == 1) ViewOnlyRestoreOption( coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, ) else if (_restoreMode == 2) UriRestoreOption( coin: coin, - dateController: _dateController, - dateChooserFunction: isDesktop - ? chooseDesktopDate - : chooseDate, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, onParsed: (data) => setState(() => _uriData = data), ) else SeedRestoreOption( coin: coin, - dateController: _dateController, - blockHeightController: _blockHeightController, - blockHeightFocusNode: _blockHeightFocusNode, + heightController: _heightController, 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: _restoreMode == 2 - ? (_uriData != null ? nextPressed : null) - : ref.watch(_pIsUsingDate) || _hasBlockHeight - ? nextPressed - : null, + ListenableBuilder( + listenable: _heightController, + builder: (context, _) => RestoreOptionsNextButton( + isDesktop: isDesktop, + onPressed: _restoreMode == 2 + ? (_uriData != null ? nextPressed : null) + : (_heightController.canProceed ? nextPressed : null), + ), ), if (isDesktop) const Spacer(flex: 15), ], @@ -624,23 +507,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 +527,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 +551,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,191 +771,54 @@ 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), + if (showDateOption) ...[ + StartHeightPicker(coin: coin, controller: heightController), + SizedBox(height: Util.isDesktop ? 24 : 16), + ], ], ); } - - @override - void initState() { - super.initState(); - - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; - } } -class UriRestoreOption extends ConsumerStatefulWidget { +class UriRestoreOption extends StatefulWidget { const UriRestoreOption({ super.key, required this.coin, - required this.dateController, - required this.dateChooserFunction, - required this.blockHeightController, - required this.blockHeightFocusNode, + required this.heightController, required this.onParsed, }); final CryptoCurrency coin; - final TextEditingController dateController; - final TextEditingController blockHeightController; - final FocusNode blockHeightFocusNode; + final StartHeightPickerController heightController; final void Function(WalletUriData?) onParsed; - final Future Function() dateChooserFunction; - @override - ConsumerState createState() => _UriRestoreOptionState(); + State createState() => _UriRestoreOptionState(); } -class _UriRestoreOptionState extends ConsumerState { - bool _blockFieldEmpty = true; +class _UriRestoreOptionState extends State { late final TextEditingController _uriController; @override void initState() { super.initState(); - _blockFieldEmpty = widget.blockHeightController.text.isEmpty; _uriController = TextEditingController(); } @@ -1199,18 +831,16 @@ class _UriRestoreOptionState extends ConsumerState { void _onUriChanged(String value) { WalletUriData? parsed; try { - parsed = WalletUriData.fromUriString(value); + parsed = WalletUriData.fromUriString(value.trim()); } catch (_) { parsed = null; } - - // If the URI contains a height, switch to block height mode and populate. + // If the URI carries a height, push it into the shared controller. if (parsed?.height != null) { - ref.read(_pIsUsingDate.notifier).state = false; - widget.blockHeightController.text = parsed!.height.toString(); + widget.heightController.setBlockHeight(parsed!.height!); } - widget.onParsed(parsed); + setState(() {}); // redraw clear button } @override @@ -1237,122 +867,35 @@ 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, ), ), 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), - ), - ), + StartHeightPicker( + coin: widget.coin, + controller: widget.heightController, ), ], ); diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart new file mode 100644 index 000000000..782fea7a2 --- /dev/null +++ b/lib/widgets/start_height_picker.dart @@ -0,0 +1,333 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/constants.dart'; +import '../utilities/format.dart'; +import '../utilities/logger.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import '../wl_gen/interfaces/cs_monero_interface.dart'; +import '../wl_gen/interfaces/cs_salvium_interface.dart'; +import '../wl_gen/interfaces/cs_wownero_interface.dart'; +import 'custom_buttons/blue_text_button.dart'; +import 'date_picker/date_picker.dart'; +import 'icon_widgets/x_icon.dart'; +import '../pages/add_wallet_views/restore_wallet_view/restore_options_view/sub_widgets/restore_from_date_picker.dart'; +import 'rounded_white_container.dart'; +import 'stack_text_field.dart'; +import 'textfield_icon_button.dart'; + +/// Controller that exposes the current height selection state to a parent +/// widget. Create one instance per [StartHeightPicker] and pass it in; listen +/// to it (e.g. with [ListenableBuilder]) to react to changes. +class StartHeightPickerController extends ChangeNotifier { + bool _isUsingDate = true; + int _height = 0; + bool _hasBlockHeight = false; + + /// The currently computed block height (0 when using date with no date + /// chosen, or when no block-height text has been entered). + int get height => _height; + + /// Whether the picker is in date mode. + bool get isUsingDate => _isUsingDate; + + /// Whether the user has entered a block height value in block-height mode. + bool get hasBlockHeight => _hasBlockHeight; + + /// Whether the current state satisfies the minimum requirement to proceed + /// (either date mode is active, or a block height has been typed in). + bool get canProceed => _isUsingDate || _hasBlockHeight; + + // Called by StartHeightPicker whenever its internal state changes. + void _update({ + required bool isUsingDate, + required int height, + required bool hasBlockHeight, + }) { + _isUsingDate = isUsingDate; + _height = height; + _hasBlockHeight = hasBlockHeight; + notifyListeners(); + } + + /// Called externally (e.g. when a URI containing a height is parsed) to + /// programmatically switch the picker to block-height mode and fill in a + /// value. The [StartHeightPicker] widget listens to this controller and + /// will update its own UI state accordingly. + void setBlockHeight(int height) { + _requestedHeight = height; + _update( + isUsingDate: false, + height: height, + hasBlockHeight: height > 0, + ); + } + + /// Non-null while a height request from [setBlockHeight] has not yet been + /// consumed by the widget. + int? _requestedHeight; +} + +/// A self-contained widget that lets the user choose either a calendar date or +/// a raw block height as the starting point for a wallet scan or restore. +/// +/// All internal state is managed here; the parent receives updates through +/// [StartHeightPickerController]. +class StartHeightPicker extends StatefulWidget { + const StartHeightPicker({ + super.key, + required this.coin, + required this.controller, + }); + + final CryptoCurrency coin; + final StartHeightPickerController controller; + + @override + State createState() => _StartHeightPickerState(); +} + +class _StartHeightPickerState extends State { + late final TextEditingController _dateController; + late final TextEditingController _blockHeightController; + late final FocusNode _blockHeightFocusNode; + + bool _isUsingDate = true; + DateTime? _restoreFromDate; + bool _blockFieldEmpty = true; + + @override + void initState() { + super.initState(); + _dateController = TextEditingController(); + _blockHeightController = TextEditingController(); + _blockHeightFocusNode = FocusNode(); + widget.controller.addListener(_onControllerChanged); + // Notify the controller after the first frame so that any ListenableBuilder + // watching it doesn't rebuild during its own build phase. + WidgetsBinding.instance.addPostFrameCallback((_) => _notifyController()); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _dateController.dispose(); + _blockHeightController.dispose(); + _blockHeightFocusNode.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + final req = widget.controller._requestedHeight; + if (req != null) { + widget.controller._requestedHeight = null; // consume + setState(() { + _isUsingDate = false; + _blockHeightController.text = req.toString(); + _blockFieldEmpty = req == 0; + }); + _notifyController(); + } + } + + void _notifyController() { + widget.controller._update( + isUsingDate: _isUsingDate, + height: _currentHeight, + hasBlockHeight: !_blockFieldEmpty, + ); + } + + int _getBlockHeightFromDate(DateTime? date) { + try { + int height = 0; + if (date != null) { + if (widget.coin is Monero) { + height = csMonero.getHeightByDate(date); + } + if (widget.coin is Wownero) { + height = csWownero.getHeightByDate(date); + } + if (widget.coin is Salvium) { + height = csSalvium.getHeightByDate( + DateTime.now().subtract(const Duration(days: 7)), + ); + } + if (height < 0) { + height = 0; + } + + if (widget.coin is Epiccash) { + final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; + const int epicCashFirstBlock = 1565370278; + const double overestimateSecondsPerBlock = 61; + final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; + final int approximateHeight = + chosenSeconds ~/ overestimateSecondsPerBlock; + + height = approximateHeight; + if (height < 0) { + height = 0; + } + } + } else { + height = 0; + } + return height; + } catch (e) { + Logging.instance.log( + Level.info, + "Error getting block height from date: $e", + ); + return 0; + } + } + + int get _currentHeight { + if (_isUsingDate) { + return _getBlockHeightFromDate(_restoreFromDate); + } else { + return int.tryParse(_blockHeightController.text) ?? 0; + } + } + + Future _chooseDate() async { + if (!Util.isDesktop && FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + if (mounted) { + final date = await showSWDatePicker(context); + if (date != null) { + setState(() { + _restoreFromDate = date; + _dateController.text = Format.formatDate(date); + }); + _notifyController(); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isUsingDate ? "Choose start date" : "Block height", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ) + : STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: _isUsingDate ? "Use block height" : "Use date", + onTap: () { + setState(() { + _isUsingDate = !_isUsingDate; + }); + _notifyController(); + }, + ), + ], + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + _isUsingDate + ? RestoreFromDatePicker( + onTap: _chooseDate, + controller: _dateController, + ) + : ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + focusNode: _blockHeightFocusNode, + controller: _blockHeightController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + style: Util.isDesktop + ? STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2) + : STextStyles.field(context), + onChanged: (value) { + setState(() { + _blockFieldEmpty = value.isEmpty; + }); + _notifyController(); + }, + decoration: + standardInputDecoration( + "Start scanning from...", + _blockHeightFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: TextFieldIconButton( + child: Semantics( + label: + "Clear Block Height Field Button. Clears the block height field", + excludeSemantics: true, + child: !_blockFieldEmpty + ? XIcon( + width: Util.isDesktop ? 24 : 16, + height: Util.isDesktop ? 24 : 16, + ) + : const SizedBox.shrink(), + ), + onTap: () { + _blockHeightController.text = ""; + setState(() { + _blockFieldEmpty = true; + }); + _notifyController(); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + child: Center( + child: Text( + _isUsingDate + ? "Choose the date you made the wallet (approximate is fine)" + : "Enter the initial block height of the wallet", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ) + : STextStyles.smallMed12(context).copyWith(fontSize: 10), + ), + ), + ), + ], + ); + } +} From 246b6f3c6f8399ca1a5c397c2323a86f58076c44 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 5 Mar 2026 20:28:58 -0600 Subject: [PATCH 14/19] fix: deduplicate _parseWalletUri; use dash-normalizing version The prior merge commit introduced two copies of _parseWalletUri: one without dash normalization (from feat/restore-cryptonote) and one with (from feat/monero-uri). Keep only the correct version -- the one that normalises dashes in URI schemes before parsing -- matching the implementation already present on the vacay branch so the two branches merge cleanly. --- lib/utilities/address_utils.dart | 44 +++++--------------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index c90323a25..c3cb78eec 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. @@ -312,43 +319,6 @@ class AddressUtils { return epicAddress; } - /// Parses a wallet URI (e.g. monero_wallet:...) and returns a Map. - /// - /// Returns null on failure to parse. - static Map? _parseWalletUri(String uri) { - final String scheme; - final Map parsedData = {}; - - final rawScheme = uri.split(":")[0]; - final normalizedScheme = rawScheme.replaceAll("-", "_"); - if (normalizedScheme != rawScheme) { - uri = normalizedScheme + uri.substring(rawScheme.length); - } - - if (uri.split(":")[0].contains("_")) { - // RFC 3986 does not allow underscores in the scheme, so strip one for - // compatibility with Uri.parse. - final String compatibleUri = uri.replaceFirst("_", ""); - scheme = uri.split(":")[0]; - parsedData.addAll(_parseUri(compatibleUri)); - } else { - parsedData.addAll(_parseUri(uri)); - scheme = parsedData['scheme'] as String? ?? ''; - } - - final possibleCoins = AppConfig.coins.where( - (e) => "${e.uriScheme}_wallet".contains(scheme), - ); - - if (possibleCoins.length != 1) { - return null; - } - - parsedData["coin"] = possibleCoins.first; - - return parsedData; - } - /// Formats an address string to remove any unnecessary prefixes or suffixes. String formatAddressMwc(String mimblewimblecoinAddress) { // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an mwcmqs address) From 054bb05a90927a997bb140ccd8b9cdd98801adee Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 6 Mar 2026 09:48:28 -0600 Subject: [PATCH 15/19] feat: add initialMnemonic param to RestoreWalletView --- .../restore_wallet_view/restore_wallet_view.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index a1ea19c40..588f72738 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -80,6 +80,7 @@ class RestoreWalletView extends ConsumerStatefulWidget { required this.seedWordsLength, required this.mnemonicPassphrase, required this.restoreBlockHeight, + this.initialMnemonic, this.clipboard = const ClipboardWrapper(), }); @@ -90,6 +91,7 @@ class RestoreWalletView extends ConsumerStatefulWidget { final String mnemonicPassphrase; final int seedWordsLength; final int restoreBlockHeight; + final String? initialMnemonic; final ClipboardInterface clipboard; @@ -163,6 +165,12 @@ class _RestoreWalletViewState extends ConsumerState { // _focusNodes.add(FocusNode()); } + if (widget.initialMnemonic != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _clearAndPopulateMnemonic(widget.initialMnemonic!.split(' ')); + }); + } + super.initState(); } From 758ac2e27e6c6811f104b6bf53961b6249d490df Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 6 Mar 2026 09:53:29 -0600 Subject: [PATCH 16/19] feat: add initial params and keys-restore to RestoreViewOnlyWalletView --- .../restore_view_only_wallet_view.dart | 179 +++++++++++------- 1 file changed, 109 insertions(+), 70 deletions(-) diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart index 4dd084f8c..ffa5c74c4 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart @@ -48,6 +48,9 @@ class RestoreViewOnlyWalletView extends ConsumerStatefulWidget { required this.walletName, required this.coin, required this.restoreBlockHeight, + this.initialAddress, + this.initialViewKey, + this.initialSpendKey, this.clipboard = const ClipboardWrapper(), }); @@ -56,6 +59,9 @@ class RestoreViewOnlyWalletView extends ConsumerStatefulWidget { final String walletName; final CryptoCurrency coin; final int restoreBlockHeight; + final String? initialAddress; + final String? initialViewKey; + final String? initialSpendKey; final ClipboardInterface clipboard; @override @@ -102,21 +108,23 @@ class _RestoreViewOnlyWalletViewState } Future _attemptRestore() async { - final Map otherDataJson = { - WalletInfoKeys.isViewOnlyKey: true, - }; + final bool isKeysRestore = widget.initialSpendKey != null; - ViewOnlyWalletType viewOnlyWalletType = _walletType; - if (widget.coin is Bip39HDCurrency) { - // already set above - } else if (widget.coin is CryptonoteCurrency) { - viewOnlyWalletType = ViewOnlyWalletType.cryptonote; + final Map otherDataJson; + if (isKeysRestore) { + otherDataJson = {WalletInfoKeys.isRestoredFromKeysKey: true}; } else { - throw Exception( - "Unsupported view only wallet currency type found: ${widget.coin.runtimeType}", - ); + otherDataJson = {WalletInfoKeys.isViewOnlyKey: true}; + + if (widget.coin is! Bip39HDCurrency && + widget.coin is! CryptonoteCurrency) { + throw Exception( + "Unsupported view only wallet currency type found:" + " ${widget.coin.runtimeType}", + ); + } + otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = _walletType.index; } - otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = _walletType.index; if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable(); @@ -126,7 +134,11 @@ class _RestoreViewOnlyWalletViewState name: widget.walletName, restoreHeight: widget.restoreBlockHeight, otherDataJsonString: jsonEncode(otherDataJson), - overrideAddressType: viewOnlyWalletType == .spark ? .spark : null, + overrideAddressType: isKeysRestore + ? null + : _walletType == .spark + ? .spark + : null, ); bool isRestoring = true; @@ -153,53 +165,6 @@ class _RestoreViewOnlyWalletViewState ); } - final ViewOnlyWalletData viewOnlyData; - switch (viewOnlyWalletType) { - case ViewOnlyWalletType.cryptonote: - if (addressController.text.isEmpty || - viewKeyController.text.isEmpty) { - throw Exception("Missing address and/or private view key fields"); - } - viewOnlyData = CryptonoteViewOnlyWalletData( - walletId: info.walletId, - address: addressController.text, - privateViewKey: viewKeyController.text, - ); - break; - - case ViewOnlyWalletType.addressOnly: - if (addressController.text.isEmpty) { - throw Exception("Address is empty"); - } - viewOnlyData = AddressViewOnlyWalletData( - walletId: info.walletId, - address: addressController.text, - ); - break; - - case ViewOnlyWalletType.xPub: - viewOnlyData = ExtendedKeysViewOnlyWalletData( - walletId: info.walletId, - xPubs: [ - XPub( - path: _currentDropDownValue, - encoded: viewKeyController.text, - ), - ], - ); - break; - - case ViewOnlyWalletType.spark: - if (sparkViewKeyController.text.isEmpty) { - throw Exception("Spark View Key is empty"); - } - viewOnlyData = SparkViewOnlyWalletData( - walletId: info.walletId, - viewKey: sparkViewKeyController.text, - ); - break; - } - var node = ref .read(nodeServiceChangeNotifierProvider) .getPrimaryNodeFor(currency: widget.coin); @@ -212,14 +177,80 @@ class _RestoreViewOnlyWalletViewState } try { - final wallet = await Wallet.create( - walletInfo: info, - mainDB: ref.read(mainDBProvider), - secureStorageInterface: ref.read(secureStoreProvider), - nodeService: ref.read(nodeServiceChangeNotifierProvider), - prefs: ref.read(prefsChangeNotifierProvider), - viewOnlyData: viewOnlyData, - ); + final Wallet wallet; + if (isKeysRestore) { + final keysRestoreData = jsonEncode({ + "address": addressController.text, + "viewKey": viewKeyController.text, + "spendKey": widget.initialSpendKey!, + }); + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + keysRestoreData: keysRestoreData, + ); + } else { + final ViewOnlyWalletData viewOnlyData; + switch (_walletType) { + case ViewOnlyWalletType.cryptonote: + if (addressController.text.isEmpty || + viewKeyController.text.isEmpty) { + throw Exception( + "Missing address and/or private view key fields", + ); + } + viewOnlyData = CryptonoteViewOnlyWalletData( + walletId: info.walletId, + address: addressController.text, + privateViewKey: viewKeyController.text, + ); + break; + + case ViewOnlyWalletType.addressOnly: + if (addressController.text.isEmpty) { + throw Exception("Address is empty"); + } + viewOnlyData = AddressViewOnlyWalletData( + walletId: info.walletId, + address: addressController.text, + ); + break; + + case ViewOnlyWalletType.xPub: + viewOnlyData = ExtendedKeysViewOnlyWalletData( + walletId: info.walletId, + xPubs: [ + XPub( + path: _currentDropDownValue, + encoded: viewKeyController.text, + ), + ], + ); + break; + + case ViewOnlyWalletType.spark: + if (sparkViewKeyController.text.isEmpty) { + throw Exception("Spark View Key is empty"); + } + viewOnlyData = SparkViewOnlyWalletData( + walletId: info.walletId, + viewKey: sparkViewKeyController.text, + ); + break; + } + + wallet = await Wallet.create( + walletInfo: info, + mainDB: ref.read(mainDBProvider), + secureStorageInterface: ref.read(secureStoreProvider), + nodeService: ref.read(nodeServiceChangeNotifierProvider), + prefs: ref.read(prefsChangeNotifierProvider), + viewOnlyData: viewOnlyData, + ); + } // TODO: extract interface with isRestore param switch (wallet) { @@ -314,8 +345,12 @@ class _RestoreViewOnlyWalletViewState @override void initState() { super.initState(); - addressController = TextEditingController(); - viewKeyController = TextEditingController(); + addressController = TextEditingController( + text: widget.initialAddress ?? '', + ); + viewKeyController = TextEditingController( + text: widget.initialViewKey ?? '', + ); sparkViewKeyController = TextEditingController(); if (widget.coin is Bip39HDCurrency) { @@ -326,6 +361,10 @@ class _RestoreViewOnlyWalletViewState } else if (widget.coin is CryptonoteCurrency) { _walletType = ViewOnlyWalletType.cryptonote; } + + if (widget.initialAddress != null || widget.initialViewKey != null) { + _enableRestoreButton = true; + } } @override From b4697d5dbfce3bc602b9fb58f27e27f5ee9829d9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 6 Mar 2026 09:53:57 -0600 Subject: [PATCH 17/19] refactor: replace inline _doUriRestore with navigation to existing views --- .../restore_options_view.dart | 264 +++--------------- lib/route_generator.dart | 42 +++ 2 files changed, 88 insertions(+), 218 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..10fb3abb7 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 @@ -9,21 +9,14 @@ */ 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'; @@ -34,9 +27,6 @@ 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'; @@ -49,15 +39,10 @@ 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'; @@ -155,197 +140,39 @@ class _RestoreOptionsViewState extends ConsumerState { ); 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)); - }, + final data = _uriData!; + final restoreHeight = data.height ?? height; + if (data.seed != null) { + final words = data.seed!.split(' '); + await Navigator.of(context).pushNamed( + RestoreWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + seedWordsLength: words.length, + restoreBlockHeight: restoreHeight, + mnemonicPassphrase: '', + initialMnemonic: data.seed!, + ), ); - }, - ), - ); - } - - 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 Navigator.of(context).pushNamed( + RestoreViewOnlyWalletView.routeName, + arguments: ( + walletName: walletName, + coin: coin, + restoreBlockHeight: restoreHeight, + initialAddress: data.address, + initialViewKey: data.viewKey, + initialSpendKey: data.spendKey, + ), ); } - - 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, - ), - ); + break; } } } finally { - if (!Platform.isLinux && !isDesktop) await WakelockPlus.disable(); + _nextLock = false; } } @@ -868,26 +695,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/route_generator.dart b/lib/route_generator.dart index 2a42a8af7..6231d39f4 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1907,6 +1907,27 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); + } else if (args + is ({ + String walletName, + CryptoCurrency coin, + int seedWordsLength, + int restoreBlockHeight, + String mnemonicPassphrase, + String initialMnemonic, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => RestoreWalletView( + walletName: args.walletName, + coin: args.coin, + seedWordsLength: args.seedWordsLength, + restoreBlockHeight: args.restoreBlockHeight, + mnemonicPassphrase: args.mnemonicPassphrase, + initialMnemonic: args.initialMnemonic, + ), + settings: RouteSettings(name: settings.name), + ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); @@ -1926,6 +1947,27 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); + } else if (args + is ({ + String walletName, + CryptoCurrency coin, + int restoreBlockHeight, + String? initialAddress, + String? initialViewKey, + String? initialSpendKey, + })) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => RestoreViewOnlyWalletView( + walletName: args.walletName, + coin: args.coin, + restoreBlockHeight: args.restoreBlockHeight, + initialAddress: args.initialAddress, + initialViewKey: args.initialViewKey, + initialSpendKey: args.initialSpendKey, + ), + settings: RouteSettings(name: settings.name), + ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); From b3d9f916c1ee41916b7181419c3bce4812af1336 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 11:25:25 -0500 Subject: [PATCH 18/19] fix: use selected date for Salvium block-height lookup --- lib/widgets/start_height_picker.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/widgets/start_height_picker.dart b/lib/widgets/start_height_picker.dart index 782fea7a2..1fb00a426 100644 --- a/lib/widgets/start_height_picker.dart +++ b/lib/widgets/start_height_picker.dart @@ -163,9 +163,7 @@ class _StartHeightPickerState extends State { height = csWownero.getHeightByDate(date); } if (widget.coin is Salvium) { - height = csSalvium.getHeightByDate( - DateTime.now().subtract(const Duration(days: 7)), - ); + height = csSalvium.getHeightByDate(date); } if (height < 0) { height = 0; From 230b2477bc0863c696473a33cdd9a1fa0213f824 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 11:27:50 -0500 Subject: [PATCH 19/19] chore: dart format --- .../wallet_settings_view.dart | 7 ++--- .../delete_wallet_warning_view.dart | 28 ++++++++----------- lib/utilities/address_utils.dart | 22 ++++++++++----- lib/widgets/start_height_picker.dart | 6 +--- 4 files changed, 31 insertions(+), 32 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 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 c3cb78eec..7d1f63cd8 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 1fb00a426..2b659d02b 100644 --- a/lib/widgets/start_height_picker.dart +++ b/lib/widgets/start_height_picker.dart @@ -70,11 +70,7 @@ class StartHeightPickerController extends ChangeNotifier { /// will update its own UI state 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 height request from [setBlockHeight] has not yet been