diff --git a/assets/translations/en.json b/assets/translations/en.json index 02f1986560..80c2e8a79e 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -115,7 +115,7 @@ "walletCreationTitle": "Create wallet", "walletImportTitle": "Import wallet", "walletImportByFileTitle": "Importing seed phrase file", - "invalidWalletNameError": "Invalid wallet name, please remove special chars", + "invalidWalletNameError": "Invalid wallet name. Allowed: letters, numbers, spaces, underscores (_), hyphens (-)", "invalidWalletFileNameError": "Invalid filename, please rename it to remove special chars", "walletImportCreatePasswordTitle": "Create a password for \"{}\" wallet", "walletImportByFileDescription": "Create a password of your seed phrase file to decrypt it. This password will be used to log in to your wallet", @@ -127,9 +127,12 @@ "walletCreationUploadFile": "Upload seed phrase file", "walletCreationEmptySeedError": "Seed phrase should not be empty", "walletCreationExistNameError": "Wallet name exists", - "walletCreationNameLengthError": "Name length should be between 1 and 40", + "walletCreationNameLengthError": "Name must be 1–40 characters with no leading or trailing spaces", "walletCreationFormatPasswordError": "Password must contain at least 8 characters, with at least one digit, one lower-case, one upper-case and one special symbol. The password can't contain the same character 3 times in a row. The password can't contain the word 'password'", "walletCreationConfirmPasswordError": "Your passwords do not match. Please try again.", + "walletCreationNameCharactersError": "Name can contain letters, numbers, spaces, underscores (_), and hyphens (-)", + "renameWalletDescription": "Wallet name is invalid. Please enter a new name.", + "renameWalletConfirm": "Rename", "incorrectPassword": "Incorrect password", "oneClickLogin": "Quick Login", "quickLoginTooltip": "Tip: Save your password to your device's password manager for true one-click login.", diff --git a/lib/blocs/wallets_repository.dart b/lib/blocs/wallets_repository.dart index 693f2a4860..187f3de976 100644 --- a/lib/blocs/wallets_repository.dart +++ b/lib/blocs/wallets_repository.dart @@ -30,7 +30,10 @@ class WalletsRepository { final FileLoader _fileLoader; List? _cachedWallets; + List? _cachedLegacyWallets; List? get wallets => _cachedWallets; + bool get isCacheLoaded => + _cachedWallets != null && _cachedLegacyWallets != null; Future> getWallets() async { final legacyWallets = await _getLegacyWallets(); @@ -44,6 +47,7 @@ class WalletsRepository { !wallet.name.toLowerCase().startsWith(trezorWalletNamePrefix), ) .toList(); + _cachedLegacyWallets = legacyWallets; return [..._cachedWallets!, ...legacyWallets]; } @@ -99,33 +103,41 @@ class WalletsRepository { String? validateWalletName(String name) { // Disallow special characters except letters, digits, space, underscore and hyphen - if (RegExp(r'[^\w\- ]').hasMatch(name)) { + if (RegExp(r'[^\p{L}\p{M}\p{N}\s\-_]', unicode: true).hasMatch(name)) { return LocaleKeys.invalidWalletNameError.tr(); } - // This shouldn't happen, but just in case. - if (_cachedWallets == null) { - getWallets().ignore(); - return null; - } final trimmedName = name.trim(); - // Check if the trimmed name is empty (prevents space-only names) - if (trimmedName.isEmpty) { + // Reject leading/trailing spaces explicitly to avoid confusion/duplicates + if (trimmedName != name) { return LocaleKeys.walletCreationNameLengthError.tr(); } - // Check if trimmed name exceeds length limit - if (trimmedName.length > 40) { + // Check empty and length limits on trimmed input + if (trimmedName.isEmpty || trimmedName.length > 40) { return LocaleKeys.walletCreationNameLengthError.tr(); } - // Check for duplicates using the exact input name (not trimmed) - // This preserves backward compatibility with existing wallets that might have spaces - if (_cachedWallets!.firstWhereOrNull((w) => w.name == name) != null) { - return LocaleKeys.walletCreationExistNameError.tr(); - } + return null; + } + /// Async uniqueness check: verifies that no existing wallet (SDK or legacy) + /// has the same trimmed name. Returns a localized error string if taken, + /// or null if available or if wallets can't be loaded. + Future validateWalletNameUniqueness(String name) async { + final String trimmedName = name.trim(); + try { + final List allWallets = await getWallets(); + final bool taken = + allWallets.firstWhereOrNull((w) => w.name.trim() == trimmedName) != + null; + if (taken) { + return LocaleKeys.walletCreationExistNameError.tr(); + } + } catch (_) { + // Non-blocking on failure to fetch wallets; treat as no conflict found. + } return null; } @@ -141,19 +153,23 @@ class WalletsRepository { @Deprecated('Use the KomodoDefiSdk.auth.getMnemonicEncrypted method instead.') Future downloadEncryptedWallet(Wallet wallet, String password) async { try { + Wallet workingWallet = wallet.copy(); if (wallet.config.seedPhrase.isEmpty) { final mnemonic = await _kdfSdk.auth.getMnemonicPlainText(password); - wallet.config.seedPhrase = await _encryptionTool.encryptData( + final String encryptedSeed = await _encryptionTool.encryptData( password, mnemonic.plaintextMnemonic ?? '', ); + workingWallet = workingWallet.copyWith( + config: workingWallet.config.copyWith(seedPhrase: encryptedSeed), + ); } - final String data = jsonEncode(wallet.config); + final String data = jsonEncode(workingWallet.config); final String encryptedData = await _encryptionTool.encryptData( password, data, ); - final String sanitizedFileName = _sanitizeFileName(wallet.name); + final String sanitizedFileName = _sanitizeFileName(workingWallet.name); await _fileLoader.save( fileName: sanitizedFileName, data: encryptedData, @@ -167,4 +183,41 @@ class WalletsRepository { String _sanitizeFileName(String fileName) { return fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); } + + Future renameLegacyWallet({ + required String walletId, + required String newName, + }) async { + final String trimmed = newName.trim(); + // Persist to legacy storage + final List> rawLegacyWallets = + (await _legacyWalletStorage.read(allWalletsStorageKey) as List?) + ?.cast>() ?? + []; + bool updated = false; + for (int i = 0; i < rawLegacyWallets.length; i++) { + final Map data = rawLegacyWallets[i]; + if ((data['id'] as String? ?? '') == walletId) { + data['name'] = trimmed; + rawLegacyWallets[i] = data; + updated = true; + break; + } + } + if (updated) { + await _legacyWalletStorage.write(allWalletsStorageKey, rawLegacyWallets); + } + + // Update in-memory legacy cache if available + if (_cachedLegacyWallets != null) { + final index = _cachedLegacyWallets!.indexWhere( + (element) => element.id == walletId, + ); + if (index != -1) { + _cachedLegacyWallets![index] = _cachedLegacyWallets![index].copyWith( + name: trimmed, + ); + } + } + } } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 5dc16c40b1..08380eabd1 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -130,10 +130,11 @@ abstract class LocaleKeys { static const walletCreationEmptySeedError = 'walletCreationEmptySeedError'; static const walletCreationExistNameError = 'walletCreationExistNameError'; static const walletCreationNameLengthError = 'walletCreationNameLengthError'; - static const walletCreationFormatPasswordError = - 'walletCreationFormatPasswordError'; - static const walletCreationConfirmPasswordError = - 'walletCreationConfirmPasswordError'; + static const walletCreationFormatPasswordError = 'walletCreationFormatPasswordError'; + static const walletCreationConfirmPasswordError = 'walletCreationConfirmPasswordError'; + static const walletCreationNameCharactersError = 'walletCreationNameCharactersError'; + static const renameWalletDescription = 'renameWalletDescription'; + static const renameWalletConfirm = 'renameWalletConfirm'; static const incorrectPassword = 'incorrectPassword'; static const oneClickLogin = 'oneClickLogin'; static const quickLoginTooltip = 'quickLoginTooltip'; diff --git a/lib/shared/screenshot/screenshot_sensitivity.dart b/lib/shared/screenshot/screenshot_sensitivity.dart index d4b7fb9f0f..a823bbb27e 100644 --- a/lib/shared/screenshot/screenshot_sensitivity.dart +++ b/lib/shared/screenshot/screenshot_sensitivity.dart @@ -9,32 +9,48 @@ class ScreenshotSensitivityController extends ChangeNotifier { void enter() { _depth += 1; - notifyListeners(); + _safeNotifyListeners(); } void exit() { if (_depth > 0) { _depth -= 1; - notifyListeners(); + _safeNotifyListeners(); } } + + /// Safely notify listeners, avoiding calls during widget tree locked phases + /// and calling build during a build or dismount. + void _safeNotifyListeners() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasListeners) { + notifyListeners(); + } + }); + } } /// Inherited notifier providing access to the ScreenshotSensitivityController. -class ScreenshotSensitivity extends InheritedNotifier { +class ScreenshotSensitivity + extends InheritedNotifier { const ScreenshotSensitivity({ super.key, required ScreenshotSensitivityController controller, - required Widget child, - }) : super(notifier: controller, child: child); + required super.child, + }) : super(notifier: controller); static ScreenshotSensitivityController? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType()?.notifier; + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; } static ScreenshotSensitivityController of(BuildContext context) { final controller = maybeOf(context); - assert(controller != null, 'ScreenshotSensitivity not found in widget tree'); + assert( + controller != null, + 'ScreenshotSensitivity not found in widget tree', + ); return controller!; } } @@ -51,21 +67,31 @@ class ScreenshotSensitive extends StatefulWidget { class _ScreenshotSensitiveState extends State { ScreenshotSensitivityController? _controller; + bool _hasCalledEnter = false; @override void didChangeDependencies() { super.didChangeDependencies(); final controller = ScreenshotSensitivity.maybeOf(context); if (!identical(controller, _controller)) { - _controller?.exit(); + // Exit the old controller if we were using it + if (_hasCalledEnter) { + _controller?.exit(); + } _controller = controller; + _hasCalledEnter = false; + // Enter the new controller - this is safe now due to deferred notification _controller?.enter(); + _hasCalledEnter = true; } } @override void dispose() { - _controller?.exit(); + // Exit the controller - this is safe now due to deferred notification + if (_hasCalledEnter) { + _controller?.exit(); + } super.dispose(); } @@ -77,4 +103,3 @@ extension ScreenshotSensitivityContextExt on BuildContext { bool get isScreenshotSensitive => ScreenshotSensitivity.maybeOf(this)?.isSensitive ?? false; } - diff --git a/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart index a0470198b8..acd6d88f73 100644 --- a/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart +++ b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart @@ -23,6 +23,7 @@ import 'package:web_dex/views/wallets_manager/widgets/wallet_creation.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_deleting.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_import_wrapper.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_login.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_rename_dialog.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallets_list.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallets_manager_controls.dart'; @@ -251,11 +252,31 @@ class _IguanaWalletsManagerState extends State { required String password, WalletType? walletType, required bool rememberMe, - }) { + }) async { setState(() { _isLoading = true; _rememberMe = rememberMe; }); + + // Async uniqueness check prior to dispatch + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness(name); + if (uniquenessError != null) { + if (mounted) setState(() => _isLoading = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + return; + } final Wallet newWallet = Wallet.fromName( name: name, walletType: walletType ?? WalletType.iguana, @@ -271,17 +292,39 @@ class _IguanaWalletsManagerState extends State { required String password, required WalletConfig walletConfig, required bool rememberMe, - }) { + }) async { setState(() { _isLoading = true; _rememberMe = rememberMe; }); + + final authBloc = context.read(); + + // Async uniqueness check prior to dispatch + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness(name); + if (uniquenessError != null) { + if (mounted) setState(() => _isLoading = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + return; + } final Wallet newWallet = Wallet.fromConfig( name: name, config: walletConfig, ); - context.read().add( + authBloc.add( AuthRestoreRequested( wallet: newWallet, password: password, @@ -295,11 +338,50 @@ class _IguanaWalletsManagerState extends State { Wallet wallet, bool rememberMe, ) async { + // Use a local variable to avoid mutating the original wallet reference + Wallet walletToUse = wallet.copy(); setState(() { _isLoading = true; _rememberMe = rememberMe; }); + final walletsRepository = RepositoryProvider.of(context); + if (wallet.isLegacyWallet) { + final String? error = walletsRepository.validateWalletName(wallet.name); + if (error != null) { + final newName = await walletRenameDialog( + context, + initialName: wallet.name, + ); + if (newName == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + // Re-validate after dialog to prevent TOCTOU conflicts + final postError = walletsRepository.validateWalletName(newName); + if (postError != null) { + if (mounted) setState(() => _isLoading = false); + return; + } + // Persist legacy rename and update local instance + await walletsRepository.renameLegacyWallet( + walletId: wallet.id, + newName: newName, + ); + final String trimmed = newName.trim(); + final Wallet updatedWallet = wallet.copyWith(name: trimmed); + // Update selected wallet for UI consistency without mutating the original instance + if (mounted) { + setState(() { + _selectedWallet = updatedWallet; + }); + } + walletToUse = updatedWallet; + } + } + + if (!mounted) return; + final AnalyticsBloc analyticsBloc = context.read(); final analyticsEvent = walletsManagerEventsFactory.createEvent( widget.eventType, @@ -308,7 +390,7 @@ class _IguanaWalletsManagerState extends State { analyticsBloc.logEvent(analyticsEvent); context.read().add( - AuthSignInRequested(wallet: wallet, password: password), + AuthSignInRequested(wallet: walletToUse, password: password), ); if (mounted) { diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart index 344ef04f4e..3d732d1bea 100644 --- a/lib/views/wallets_manager/widgets/wallet_creation.dart +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -93,52 +93,54 @@ class _WalletCreationState extends State { } }, child: AutofillGroup( - child: ScreenshotSensitive(child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.action == WalletsManagerAction.create - ? LocaleKeys.walletCreationTitle.tr() - : LocaleKeys.walletImportTitle.tr(), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 18), - ), - const SizedBox(height: 24), - _buildFields(), - const SizedBox(height: 22), - EulaTosCheckboxes( - key: const Key('create-wallet-eula-checks'), - isChecked: _eulaAndTosChecked, - onCheck: (isChecked) { - setState(() { - _eulaAndTosChecked = isChecked; - }); - }, - ), - const SizedBox(height: 32), - UiPrimaryButton( - key: const Key('confirm-password-button'), - height: 50, - text: _inProgress - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.create.tr(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + child: ScreenshotSensitive( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.action == WalletsManagerAction.create + ? LocaleKeys.walletCreationTitle.tr() + : LocaleKeys.walletImportTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 18), ), - onPressed: _isCreateButtonEnabled ? _onCreate : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: widget.onCancel, - text: LocaleKeys.cancel.tr(), - ), - ], + const SizedBox(height: 24), + _buildFields(), + const SizedBox(height: 22), + EulaTosCheckboxes( + key: const Key('create-wallet-eula-checks'), + isChecked: _eulaAndTosChecked, + onCheck: (isChecked) { + setState(() { + _eulaAndTosChecked = isChecked; + }); + }, + ), + const SizedBox(height: 32), + UiPrimaryButton( + key: const Key('confirm-password-button'), + height: 50, + text: _inProgress + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.create.tr(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: _isCreateButtonEnabled ? _onCreate : null, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.cancel.tr(), + ), + ], + ), ), - )), + ), ), ); } @@ -203,10 +205,31 @@ class _WalletCreationState extends State { ); } - void _onCreate() { + void _onCreate() async { if (!_eulaAndTosChecked) return; if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _inProgress = true); + // Async uniqueness check before proceeding + final uniquenessError = await _walletsRepository + .validateWalletNameUniqueness(_nameController.text); + if (uniquenessError != null) { + if (mounted) { + setState(() => _inProgress = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + return; + } WidgetsBinding.instance.addPostFrameCallback((timeStamp) { // Complete autofill session so password managers can save new credentials diff --git a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart index b8c7349bfd..458c733751 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:collection/collection.dart'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -9,15 +9,16 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/ui/ui_gradient_icon.dart'; import 'package:web_dex/shared/utils/encryption_tool.dart'; import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; -import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/views/wallets_manager/widgets/custom_seed_checkbox.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; -import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_rename_dialog.dart'; class WalletFileData { const WalletFileData({required this.content, required this.name}); @@ -58,9 +59,6 @@ class _WalletImportByFileState extends State { bool _rememberMe = false; bool _allowCustomSeed = false; - // Whether the selected file name contains characters that are not allowed - late final bool _hasInvalidFileName; - String? _filePasswordError; String? _commonError; @@ -68,153 +66,136 @@ class _WalletImportByFileState extends State { return _filePasswordError == null; } - bool get _isButtonEnabled => _eulaAndTosChecked && !_hasInvalidFileName; - - @override - void initState() { - super.initState(); - - // Detect illegal characters in the filename (anything other than letters, numbers, underscore, hyphen, dot and space) - _hasInvalidFileName = _containsIllegalChars(widget.fileData.name); - - if (_hasInvalidFileName) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() { - _commonError = LocaleKeys.invalidWalletFileNameError.tr(); - }); - _formKey.currentState?.validate(); - }); - } - } - - bool _containsIllegalChars(String fileName) { - // Allow alphanumerics, underscore, hyphen, dot and space in the filename - return RegExp(r'[^\w.\- ]').hasMatch(fileName); - } + // Intentionally do not check wallet name here, because it is done on button + // click and a dialog is shown to rename the wallet if there are issues. + bool get _isButtonEnabled => _eulaAndTosChecked; @override Widget build(BuildContext context) { - return ScreenshotSensitive(child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - LocaleKeys.walletImportByFileTitle.tr(), - style: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 24), - ), - const SizedBox(height: 20), - Text( - LocaleKeys.walletImportByFileDescription.tr(), - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 20), - AutofillGroup( - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UiTextFormField( - key: const Key('file-password-field'), - controller: _filePasswordController, - autofocus: true, - textInputAction: TextInputAction.next, - autocorrect: false, - enableInteractiveSelection: true, - obscureText: _isObscured, - maxLength: passwordMaxLength, - counterText: '', - autofillHints: const [AutofillHints.password], - validator: (_) { - return _filePasswordError; - }, - errorMaxLines: 6, - hintText: LocaleKeys.walletCreationPasswordHint.tr(), - suffixIcon: PasswordVisibilityControl( - onVisibilityChange: (bool isPasswordObscured) { - setState(() { - _isObscured = isPasswordObscured; - }); + return ScreenshotSensitive( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.walletImportByFileTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge!.copyWith(fontSize: 24), + ), + const SizedBox(height: 20), + Text( + LocaleKeys.walletImportByFileDescription.tr(), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 20), + AutofillGroup( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiTextFormField( + key: const Key('file-password-field'), + controller: _filePasswordController, + autofocus: true, + textInputAction: TextInputAction.next, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isObscured, + maxLength: passwordMaxLength, + counterText: '', + autofillHints: const [AutofillHints.password], + validator: (_) { + return _filePasswordError; }, + errorMaxLines: 6, + hintText: LocaleKeys.walletCreationPasswordHint.tr(), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isPasswordObscured) { + setState(() { + _isObscured = isPasswordObscured; + }); + }, + ), ), - ), - const SizedBox(height: 30), - Row( - children: [ - const UiGradientIcon(icon: Icons.folder, size: 32), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.fileData.name, - maxLines: 3, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 30), + Row( + children: [ + const UiGradientIcon(icon: Icons.folder, size: 32), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.fileData.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), ), - ), - ], - ), - if (_commonError != null) - Align( - alignment: const Alignment(-1, 0), - child: SelectableText( - _commonError ?? '', - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.error, + ], + ), + if (_commonError != null) + Align( + alignment: const Alignment(-1, 0), + child: SelectableText( + _commonError ?? '', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ), - ), - const SizedBox(height: 30), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { - setState(() => _isHdMode = value); - }, - ), - const SizedBox(height: 15), - if (!_isHdMode) - CustomSeedCheckbox( - value: _allowCustomSeed, + const SizedBox(height: 30), + HDWalletModeSwitch( + value: _isHdMode, onChanged: (value) { + setState(() => _isHdMode = value); + }, + ), + const SizedBox(height: 15), + if (!_isHdMode) + CustomSeedCheckbox( + value: _allowCustomSeed, + onChanged: (value) { + setState(() { + _allowCustomSeed = value; + }); + }, + ), + const SizedBox(height: 15), + EulaTosCheckboxes( + key: const Key('import-wallet-eula-checks'), + isChecked: _eulaAndTosChecked, + onCheck: (isChecked) { setState(() { - _allowCustomSeed = value; + _eulaAndTosChecked = isChecked; }); }, ), - const SizedBox(height: 15), - EulaTosCheckboxes( - key: const Key('import-wallet-eula-checks'), - isChecked: _eulaAndTosChecked, - onCheck: (isChecked) { - setState(() { - _eulaAndTosChecked = isChecked; - }); - }, - ), - const SizedBox(height: 20), - QuickLoginSwitch( - value: _rememberMe, - onChanged: (value) { - setState(() => _rememberMe = value); - }, - ), - const SizedBox(height: 30), - UiPrimaryButton( - key: const Key('confirm-password-button'), - height: 50, - text: LocaleKeys.import.tr(), - onPressed: _isButtonEnabled ? _onImport : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: widget.onCancel, - text: LocaleKeys.back.tr(), - ), - ], + const SizedBox(height: 20), + QuickLoginSwitch( + value: _rememberMe, + onChanged: (value) { + setState(() => _rememberMe = value); + }, + ), + const SizedBox(height: 30), + UiPrimaryButton( + key: const Key('confirm-password-button'), + height: 50, + text: LocaleKeys.import.tr(), + onPressed: _isButtonEnabled ? _onImport : null, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.back.tr(), + ), + ], + ), ), ), - ), - ], - )); + ], + ), + ); } @override @@ -229,10 +210,6 @@ class _WalletImportByFileState extends State { late final KomodoDefiSdk _sdk = context.read(); Future _onImport() async { - if (_hasInvalidFileName) { - // Early return if filename is invalid; button should already be disabled - return; - } final EncryptionTool encryptionTool = EncryptionTool(); final String? fileData = await encryptionTool.decryptData( _filePasswordController.text, @@ -272,16 +249,35 @@ class _WalletImportByFileState extends State { } walletConfig.seedPhrase = decryptedSeed; - final String name = widget.fileData.name.split('.').first; - // ignore: use_build_context_synchronously - final walletsBloc = RepositoryProvider.of(context); - final bool isNameExisted = - walletsBloc.wallets!.firstWhereOrNull((w) => w.name == name) != null; - if (isNameExisted) { - setState(() { - _commonError = LocaleKeys.walletCreationExistNameError.tr(); - }); - return; + String name = widget.fileData.name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final walletsRepository = RepositoryProvider.of( + context, + ); + + // Check both validation and uniqueness + String? validationError = walletsRepository.validateWalletName(name); + String? uniquenessError = await walletsRepository + .validateWalletNameUniqueness(name); + + // If either validation or uniqueness fails, prompt for renaming + if (validationError != null || uniquenessError != null) { + if (!mounted) return; + final newName = await walletRenameDialog(context, initialName: name); + if (newName == null) { + return; + } + // Re-validate to protect against TOCTOU (name taken while dialog open) + final postValidation = walletsRepository.validateWalletName(newName); + if (postValidation != null) { + return; + } + // Async uniqueness check before proceeding with renamed value + final postUniquenessError = await walletsRepository + .validateWalletNameUniqueness(newName); + if (postUniquenessError != null) { + return; + } + name = newName.trim(); } // Close autofill context after successfully validating password & before import TextInput.finishAutofillContext(shouldSave: false); diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index 3582072bdd..c7338ae7ea 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -81,14 +81,18 @@ class _WalletLogInState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) { - widget.wallet.config.type = + final WalletType derivedType = _isHdMode && _user != null && _user!.isBip39Seed == true ? WalletType.hdwallet : WalletType.iguana; + final Wallet walletToUse = widget.wallet.copyWith( + config: widget.wallet.config.copyWith(type: derivedType), + ); + widget.onLogin( _passwordController.text, - widget.wallet, + walletToUse, _isQuickLoginEnabled, ); }); @@ -104,70 +108,72 @@ class _WalletLogInState extends State { : state.authError?.message; return AutofillGroup( - child: ScreenshotSensitive(child: Column( - mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, - children: [ - Text( - LocaleKeys.walletLogInTitle.tr(), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 18), - ), - const SizedBox(height: 24), - UiTextFormField( - key: const Key('wallet-field'), - initialValue: widget.wallet.name, - readOnly: true, - autocorrect: false, - autofillHints: const [AutofillHints.username], - ), - const SizedBox(height: 16), - PasswordTextField( - onFieldSubmitted: state.isLoading ? null : _submitLogin, - controller: _passwordController, - errorText: errorMessage, - autofillHints: const [AutofillHints.password], - isQuickLoginEnabled: _isQuickLoginEnabled, - ), - const SizedBox(height: 32), - QuickLoginSwitch( - value: _isQuickLoginEnabled, - onChanged: (value) { - setState(() => _isQuickLoginEnabled = value); - }, - ), - const SizedBox(height: 16), - if (_user != null && _user!.isBip39Seed == true) ...[ - HDWalletModeSwitch( - value: _isHdMode, + child: ScreenshotSensitive( + child: Column( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + children: [ + Text( + LocaleKeys.walletLogInTitle.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 18), + ), + const SizedBox(height: 24), + UiTextFormField( + key: const Key('wallet-field'), + initialValue: widget.wallet.name, + readOnly: true, + autocorrect: false, + autofillHints: const [AutofillHints.username], + ), + const SizedBox(height: 16), + PasswordTextField( + onFieldSubmitted: state.isLoading ? null : _submitLogin, + controller: _passwordController, + errorText: errorMessage, + autofillHints: const [AutofillHints.password], + isQuickLoginEnabled: _isQuickLoginEnabled, + ), + const SizedBox(height: 32), + QuickLoginSwitch( + value: _isQuickLoginEnabled, onChanged: (value) { - setState(() => _isHdMode = value); + setState(() => _isQuickLoginEnabled = value); }, ), - const SizedBox(height: 24), - ], - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2.0), - child: UiPrimaryButton( - height: 50, - text: state.isLoading - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.logIn.tr(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + const SizedBox(height: 16), + if (_user != null && _user!.isBip39Seed == true) ...[ + HDWalletModeSwitch( + value: _isHdMode, + onChanged: (value) { + setState(() => _isHdMode = value); + }, + ), + const SizedBox(height: 24), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: UiPrimaryButton( + height: 50, + text: state.isLoading + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.logIn.tr(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: state.isLoading ? null : _submitLogin, ), - onPressed: state.isLoading ? null : _submitLogin, ), - ), - const SizedBox(height: 8), - UiUnderlineTextButton( - key: _backKeyButton, - onPressed: widget.onCancel, - text: LocaleKeys.cancel.tr(), - ), - ], - )), + const SizedBox(height: 8), + UiUnderlineTextButton( + key: _backKeyButton, + onPressed: widget.onCancel, + text: LocaleKeys.cancel.tr(), + ), + ], + ), + ), ); }, ); diff --git a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart new file mode 100644 index 0000000000..1b326a07f0 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart @@ -0,0 +1,116 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/wallets_repository.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; + +Future walletRenameDialog( + BuildContext context, { + required String initialName, +}) async { + final TextEditingController controller = TextEditingController( + text: initialName, + ); + final walletsRepository = RepositoryProvider.of(context); + + final result = await AppDialog.show( + context: context, + width: isMobile ? null : 360, + child: _WalletRenameContent( + controller: controller, + walletsRepository: walletsRepository, + ), + ); + + return result; +} + +class _WalletRenameContent extends StatefulWidget { + const _WalletRenameContent({ + required this.controller, + required this.walletsRepository, + }); + + final TextEditingController controller; + final WalletsRepository walletsRepository; + + @override + State<_WalletRenameContent> createState() => _WalletRenameContentState(); +} + +class _WalletRenameContentState extends State<_WalletRenameContent> { + String? error; + + @override + void initState() { + super.initState(); + // Validate initial name + error = widget.walletsRepository.validateWalletName(widget.controller.text); + } + + void _handleTextChange(String? text) { + setState(() { + error = widget.walletsRepository.validateWalletName(text ?? ''); + }); + } + + void _handleCancel() { + Navigator.of(context).pop(null); + } + + void _handleConfirm() { + final text = widget.controller.text.trim(); + if (text.isNotEmpty && error == null) { + Navigator.of(context).pop(text); + } + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.renameWalletDescription.tr(), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + UiTextFormField( + controller: widget.controller, + autofocus: true, + autocorrect: false, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + errorText: error, + onChanged: _handleTextChange, + onFieldSubmitted: (_) => _handleConfirm(), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: _handleCancel, + ), + ), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + text: LocaleKeys.renameWalletConfirm.tr(), + onPressed: error != null ? null : _handleConfirm, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart index 79e9388b8b..42939ed59d 100644 --- a/lib/views/wallets_manager/widgets/wallet_simple_import.dart +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -96,52 +96,54 @@ class _WalletImportWrapperState extends State { } }, child: AutofillGroup( - child: ScreenshotSensitive(child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SelectableText( - _step == WalletSimpleImportSteps.nameAndSeed - ? LocaleKeys.walletImportTitle.tr() - : LocaleKeys.walletImportCreatePasswordTitle.tr( - args: [_nameController.text.trim()], + child: ScreenshotSensitive( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.walletImportTitle.tr() + : LocaleKeys.walletImportCreatePasswordTitle.tr( + args: [_nameController.text.trim()], + ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: 20), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFields(), + const SizedBox(height: 20), + UiPrimaryButton( + key: const Key('confirm-seed-button'), + text: _inProgress + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.import.tr(), + height: 50, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: _isButtonEnabled ? _onImport : null, ), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 20), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildFields(), - const SizedBox(height: 20), - UiPrimaryButton( - key: const Key('confirm-seed-button'), - text: _inProgress - ? '${LocaleKeys.pleaseWait.tr()}...' - : LocaleKeys.import.tr(), - height: 50, - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: _onCancel, + text: _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.cancel.tr() + : LocaleKeys.back.tr(), ), - onPressed: _isButtonEnabled ? _onImport : null, - ), - const SizedBox(height: 20), - UiUnderlineTextButton( - onPressed: _onCancel, - text: _step == WalletSimpleImportSteps.nameAndSeed - ? LocaleKeys.cancel.tr() - : LocaleKeys.back.tr(), - ), - ], + ], + ), ), - ), - ], - )), + ], + ), + ), ), ); } @@ -325,7 +327,7 @@ class _WalletImportWrapperState extends State { widget.onCancel(); } - void _onImport() { + void _onImport() async { if (!(_formKey.currentState?.validate() ?? false)) { return; } @@ -346,6 +348,30 @@ class _WalletImportWrapperState extends State { setState(() => _inProgress = true); + // Async uniqueness check before proceeding + final repo = context.read(); + final uniquenessError = await repo.validateWalletNameUniqueness( + _nameController.text, + ); + if (uniquenessError != null) { + if (mounted) { + setState(() => _inProgress = false); + final theme = Theme.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + uniquenessError, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { widget.onImport( name: _nameController.text.trim(), diff --git a/pubspec.lock b/pubspec.lock index ca2aeb0638..93453a95c1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" dbus: dependency: transitive description: @@ -638,7 +646,7 @@ packages: source: hosted version: "4.1.2" integration_test: - dependency: "direct dev" + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2f25cf6556..5ba342fe11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,9 @@ dependencies: video_player: ^2.9.5 # flutter.dev logging: 1.3.0 + integration_test: # SDK (moved from dev_dependencies to ensure Android release build includes plugin) + sdk: flutter + ## ---- google.com # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 @@ -128,6 +131,7 @@ dependencies: badges: 3.1.2 flutter_slidable: 4.0.0 + cupertino_icons: ^1.0.8 # Embedded web view # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/3 @@ -186,8 +190,6 @@ dependencies: flutter_window_close: ^1.2.0 dev_dependencies: - integration_test: # SDK - sdk: flutter test: ^1.24.1 # dart.dev # The "flutter_lints" package below contains a set of recommended lints to diff --git a/sdk b/sdk index 1cd61c95d3..ab65b107a0 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 1cd61c95d3c9189ec44e06fea1e9c559b6110269 +Subproject commit ab65b107a00372f1491e362c9d4b4dabe89fb3e8