diff --git a/lib/app_config/package_information.dart b/lib/app_config/package_information.dart index ce8684ae95..57c413c686 100644 --- a/lib/app_config/package_information.dart +++ b/lib/app_config/package_information.dart @@ -6,14 +6,22 @@ class PackageInformation { String? packageVersion; String? packageName; String? commitHash; + String? buildDate; - static const String _kCommitHash = - String.fromEnvironment('COMMIT_HASH', defaultValue: 'unknown'); + static const String _kCommitHash = String.fromEnvironment( + 'COMMIT_HASH', + defaultValue: 'unknown', + ); + static const String _kBuildDate = String.fromEnvironment( + 'BUILD_DATE', + defaultValue: 'unknown', + ); Future init() async { final PackageInfo packageInfo = await PackageInfo.fromPlatform(); packageVersion = packageInfo.version; packageName = packageInfo.packageName; commitHash = _kCommitHash; + buildDate = _kBuildDate; } } diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 12e6a92fe9..29390bd91b 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -103,7 +103,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { // Explicitly disconnect SSE on sign-out _log.info('User signed out, disconnecting SSE...'); _kdfSdk.streaming.disconnect(); - + await _authChangesSubscription?.cancel(); emit(AuthBlocState.initial()); } @@ -145,11 +145,11 @@ class AuthBloc extends Bloc with TrezorAuthMixin { _log.info('Successfully logged in to wallet'); emit(AuthBlocState.loggedIn(currentUser)); - + // Explicitly connect SSE after successful login _log.info('User authenticated, connecting SSE for streaming...'); _kdfSdk.streaming.connectIfNeeded(); - + _listenToAuthStateChanges(); } catch (e, s) { if (e is AuthException) { @@ -222,6 +222,8 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Registered a new wallet, setting up metadata and logging in...', ); await _kdfSdk.setWalletType(event.wallet.config.type); + await _kdfSdk.setWalletProvenance(WalletProvenance.generated); + await _kdfSdk.setWalletCreatedAt(DateTime.now()); await _kdfSdk.confirmSeedBackup(hasBackup: false); // Filter out geo-blocked assets from default coins before adding to wallet final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); @@ -314,6 +316,8 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Setting up wallet metadata and logging in...', ); await _kdfSdk.setWalletType(workingWallet.config.type); + await _kdfSdk.setWalletProvenance(WalletProvenance.imported); + await _kdfSdk.setWalletCreatedAt(DateTime.now()); await _kdfSdk.confirmSeedBackup( hasBackup: workingWallet.config.hasBackup, ); @@ -464,7 +468,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ? AuthorizeMode.logIn : AuthorizeMode.noLogin; add(AuthModeChanged(mode: event, currentUser: user)); - + // Tie SSE connection lifecycle to authentication state if (user != null) { // User authenticated - connect SSE for balance/tx history streaming diff --git a/lib/bloc/auth_bloc/trezor_auth_mixin.dart b/lib/bloc/auth_bloc/trezor_auth_mixin.dart index f65a16ca6f..c8c0ab152b 100644 --- a/lib/bloc/auth_bloc/trezor_auth_mixin.dart +++ b/lib/bloc/auth_bloc/trezor_auth_mixin.dart @@ -65,13 +65,15 @@ mixin TrezorAuthMixin on Bloc { switch (authState.status) { case AuthenticationStatus.initializing: return AuthBlocState.trezorInitializing( - message: authState.message ?? LocaleKeys.trezorInitializingMessage.tr(), + message: + authState.message ?? LocaleKeys.trezorInitializingMessage.tr(), taskId: authState.taskId, ); case AuthenticationStatus.waitingForDevice: return AuthBlocState.trezorInitializing( message: - authState.message ?? LocaleKeys.trezorWaitingForDeviceMessage.tr(), + authState.message ?? + LocaleKeys.trezorWaitingForDeviceMessage.tr(), taskId: authState.taskId, ); case AuthenticationStatus.waitingForDeviceConfirmation: @@ -83,12 +85,15 @@ mixin TrezorAuthMixin on Bloc { ); case AuthenticationStatus.pinRequired: return AuthBlocState.trezorPinRequired( - message: authState.message ?? LocaleKeys.trezorPinRequiredMessage.tr(), + message: + authState.message ?? LocaleKeys.trezorPinRequiredMessage.tr(), taskId: authState.taskId!, ); case AuthenticationStatus.passphraseRequired: return AuthBlocState.trezorPassphraseRequired( - message: authState.message ?? LocaleKeys.trezorPassphraseRequiredMessage.tr(), + message: + authState.message ?? + LocaleKeys.trezorPassphraseRequiredMessage.tr(), taskId: authState.taskId!, ); case AuthenticationStatus.authenticating: @@ -96,11 +101,9 @@ mixin TrezorAuthMixin on Bloc { case AuthenticationStatus.completed: return _setupTrezorWallet(authState); case AuthenticationStatus.error: + final mappedError = _mapTrezorErrorMessage(authState.error); return AuthBlocState.error( - AuthException( - authState.error ?? LocaleKeys.trezorAuthFailedMessage.tr(), - type: AuthExceptionType.generalAuthError, - ), + AuthException(mappedError, type: AuthExceptionType.generalAuthError), ); case AuthenticationStatus.cancelled: return AuthBlocState.error( @@ -112,6 +115,23 @@ mixin TrezorAuthMixin on Bloc { } } + String _mapTrezorErrorMessage(String? errorMessage) { + if (errorMessage == null || errorMessage.trim().isEmpty) { + return LocaleKeys.trezorAuthFailedMessage.tr(); + } + + final normalized = errorMessage.toLowerCase(); + if (normalized.contains('cancel')) { + return LocaleKeys.trezorAuthCancelledMessage.tr(); + } + if (normalized.contains('invalid pin') || + (normalized.contains('pin') && normalized.contains('invalid'))) { + return LocaleKeys.trezorErrorInvalidPin.tr(); + } + + return errorMessage; + } + /// Sets up the Trezor wallet after successful authentication. /// This includes setting the wallet type, confirming seed backup, /// and adding the default activated coins. diff --git a/lib/bloc/settings/settings_bloc.dart b/lib/bloc/settings/settings_bloc.dart index 93a2f6f4f4..6a8811d30b 100644 --- a/lib/bloc/settings/settings_bloc.dart +++ b/lib/bloc/settings/settings_bloc.dart @@ -11,11 +11,11 @@ import 'package:web_dex/shared/utils/utils.dart'; class SettingsBloc extends Bloc { SettingsBloc(StoredSettings stored, SettingsRepository repository) - : _settingsRepo = repository, - super(SettingsState.fromStored(stored)) { + : _settingsRepo = repository, + super(SettingsState.fromStored(stored)) { _storedSettings = stored; theme.mode = state.themeMode; - + // Initialize diagnostic logging with the stored setting KdfLoggingConfig.verboseLogging = stored.diagnosticLoggingEnabled; KdfApiClient.enableDebugLogging = stored.diagnosticLoggingEnabled; @@ -27,6 +27,7 @@ class SettingsBloc extends Bloc { on(_onWeakPasswordsAllowedChanged); on(_onHideZeroBalanceAssetsChanged); on(_onDiagnosticLoggingChanged); + on(_onHideBalancesChanged); } late StoredSettings _storedSettings; @@ -51,7 +52,9 @@ class SettingsBloc extends Bloc { MarketMakerBotSettingsChanged event, Emitter emitter, ) async { - _storedSettings = _storedSettings.copyWith(marketMakerBotSettings: event.settings); + _storedSettings = _storedSettings.copyWith( + marketMakerBotSettings: event.settings, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(marketMakerBotSettings: event.settings)); } @@ -60,7 +63,9 @@ class SettingsBloc extends Bloc { TestCoinsEnabledChanged event, Emitter emitter, ) async { - _storedSettings = _storedSettings.copyWith(testCoinsEnabled: event.testCoinsEnabled); + _storedSettings = _storedSettings.copyWith( + testCoinsEnabled: event.testCoinsEnabled, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(testCoinsEnabled: event.testCoinsEnabled)); } @@ -70,7 +75,8 @@ class SettingsBloc extends Bloc { Emitter emitter, ) async { _storedSettings = _storedSettings.copyWith( - weakPasswordsAllowed: event.weakPasswordsAllowed); + weakPasswordsAllowed: event.weakPasswordsAllowed, + ); await _settingsRepo.updateSettings(_storedSettings); emitter(state.copyWith(weakPasswordsAllowed: event.weakPasswordsAllowed)); } @@ -94,11 +100,24 @@ class SettingsBloc extends Bloc { KdfLoggingConfig.verboseLogging = event.diagnosticLoggingEnabled; KdfApiClient.enableDebugLogging = event.diagnosticLoggingEnabled; KomodoDefiFramework.enableDebugLogging = event.diagnosticLoggingEnabled; - + _storedSettings = _storedSettings.copyWith( diagnosticLoggingEnabled: event.diagnosticLoggingEnabled, ); await _settingsRepo.updateSettings(_storedSettings); - emitter(state.copyWith(diagnosticLoggingEnabled: event.diagnosticLoggingEnabled)); + emitter( + state.copyWith(diagnosticLoggingEnabled: event.diagnosticLoggingEnabled), + ); + } + + Future _onHideBalancesChanged( + HideBalancesChanged event, + Emitter emitter, + ) async { + _storedSettings = _storedSettings.copyWith( + hideBalances: event.hideBalances, + ); + await _settingsRepo.updateSettings(_storedSettings); + emitter(state.copyWith(hideBalances: event.hideBalances)); } } diff --git a/lib/bloc/settings/settings_event.dart b/lib/bloc/settings/settings_event.dart index 9f52cfabdc..a18f8046e4 100644 --- a/lib/bloc/settings/settings_event.dart +++ b/lib/bloc/settings/settings_event.dart @@ -48,3 +48,11 @@ class DiagnosticLoggingChanged extends SettingsEvent { @override List get props => [diagnosticLoggingEnabled]; } + +class HideBalancesChanged extends SettingsEvent { + const HideBalancesChanged({required this.hideBalances}); + final bool hideBalances; + + @override + List get props => [hideBalances]; +} diff --git a/lib/bloc/settings/settings_state.dart b/lib/bloc/settings/settings_state.dart index cc15ea2a05..60f258570c 100644 --- a/lib/bloc/settings/settings_state.dart +++ b/lib/bloc/settings/settings_state.dart @@ -11,6 +11,7 @@ class SettingsState extends Equatable { required this.weakPasswordsAllowed, required this.hideZeroBalanceAssets, required this.diagnosticLoggingEnabled, + required this.hideBalances, }); factory SettingsState.fromStored(StoredSettings stored) { @@ -21,6 +22,7 @@ class SettingsState extends Equatable { weakPasswordsAllowed: stored.weakPasswordsAllowed, hideZeroBalanceAssets: stored.hideZeroBalanceAssets, diagnosticLoggingEnabled: stored.diagnosticLoggingEnabled, + hideBalances: stored.hideBalances, ); } @@ -30,16 +32,18 @@ class SettingsState extends Equatable { final bool weakPasswordsAllowed; final bool hideZeroBalanceAssets; final bool diagnosticLoggingEnabled; + final bool hideBalances; @override List get props => [ - themeMode, - mmBotSettings, - testCoinsEnabled, - weakPasswordsAllowed, - hideZeroBalanceAssets, - diagnosticLoggingEnabled, - ]; + themeMode, + mmBotSettings, + testCoinsEnabled, + weakPasswordsAllowed, + hideZeroBalanceAssets, + diagnosticLoggingEnabled, + hideBalances, + ]; SettingsState copyWith({ ThemeMode? mode, @@ -48,6 +52,7 @@ class SettingsState extends Equatable { bool? weakPasswordsAllowed, bool? hideZeroBalanceAssets, bool? diagnosticLoggingEnabled, + bool? hideBalances, }) { return SettingsState( themeMode: mode ?? themeMode, @@ -58,6 +63,7 @@ class SettingsState extends Equatable { hideZeroBalanceAssets ?? this.hideZeroBalanceAssets, diagnosticLoggingEnabled: diagnosticLoggingEnabled ?? this.diagnosticLoggingEnabled, + hideBalances: hideBalances ?? this.hideBalances, ); } } diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart index 62abe28808..51c287ecfe 100644 --- a/lib/bloc/version_info/version_info_bloc.dart +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -42,6 +42,7 @@ class VersionInfoBloc extends Bloc { final commitHash = packageInformation.commitHash != null ? _tryParseCommitHash(packageInformation.commitHash!) : null; + final buildDate = packageInformation.buildDate; _logger.info( 'Basic app info retrieved - Version: $appVersion, ' @@ -51,6 +52,7 @@ class VersionInfoBloc extends Bloc { var currentInfo = VersionInfoLoaded( appVersion: appVersion, commitHash: commitHash, + buildDate: buildDate, ); emit(currentInfo); diff --git a/lib/bloc/version_info/version_info_state.dart b/lib/bloc/version_info/version_info_state.dart index adfe964e7c..a8643bd26c 100644 --- a/lib/bloc/version_info/version_info_state.dart +++ b/lib/bloc/version_info/version_info_state.dart @@ -22,6 +22,7 @@ class VersionInfoLoaded extends VersionInfoState { const VersionInfoLoaded({ required this.appVersion, required this.commitHash, + this.buildDate, this.apiCommitHash, this.currentCoinsCommit, this.latestCoinsCommit, @@ -29,6 +30,7 @@ class VersionInfoLoaded extends VersionInfoState { final String? appVersion; final String? commitHash; + final String? buildDate; final String? apiCommitHash; final String? currentCoinsCommit; final String? latestCoinsCommit; @@ -36,6 +38,7 @@ class VersionInfoLoaded extends VersionInfoState { VersionInfoLoaded copyWith({ ValueGetter? appVersion, ValueGetter? commitHash, + ValueGetter? buildDate, ValueGetter? apiCommitHash, ValueGetter? currentCoinsCommit, ValueGetter? latestCoinsCommit, @@ -43,6 +46,7 @@ class VersionInfoLoaded extends VersionInfoState { return VersionInfoLoaded( appVersion: appVersion?.call() ?? this.appVersion, commitHash: commitHash?.call() ?? this.commitHash, + buildDate: buildDate?.call() ?? this.buildDate, apiCommitHash: apiCommitHash?.call() ?? this.apiCommitHash, currentCoinsCommit: currentCoinsCommit?.call() ?? this.currentCoinsCommit, latestCoinsCommit: latestCoinsCommit?.call() ?? this.latestCoinsCommit, @@ -53,6 +57,7 @@ class VersionInfoLoaded extends VersionInfoState { List get props => [ appVersion, commitHash, + buildDate, apiCommitHash, currentCoinsCommit, latestCoinsCommit, diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 27dfa875ad..82df3ca1e3 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -156,4 +156,25 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { Future setWalletType(WalletType type) async { await auth.setOrRemoveActiveUserKeyValue('type', type.name); } + + /// Sets the wallet provenance for the current user. + /// + /// Stored values are used by wallet selection UIs to show quick metadata + /// tags (generated/imported). + Future setWalletProvenance(WalletProvenance provenance) async { + await auth.setOrRemoveActiveUserKeyValue( + 'wallet_provenance', + provenance.name, + ); + } + + /// Sets the wallet creation timestamp for the current user. + /// + /// Stored as milliseconds since epoch. + Future setWalletCreatedAt(DateTime createdAt) async { + await auth.setOrRemoveActiveUserKeyValue( + 'wallet_created_at', + createdAt.millisecondsSinceEpoch, + ); + } } diff --git a/lib/model/settings/market_maker_bot_settings.dart b/lib/model/settings/market_maker_bot_settings.dart index 8df41fa385..2bce4f95ea 100644 --- a/lib/model/settings/market_maker_bot_settings.dart +++ b/lib/model/settings/market_maker_bot_settings.dart @@ -9,6 +9,7 @@ class MarketMakerBotSettings extends Equatable { const MarketMakerBotSettings({ required this.isMMBotEnabled, + required this.saveOrdersBetweenLaunches, required this.botRefreshRate, required this.tradeCoinPairConfigs, this.messageServiceConfig, @@ -21,6 +22,7 @@ class MarketMakerBotSettings extends Equatable { factory MarketMakerBotSettings.initial() { return MarketMakerBotSettings( isMMBotEnabled: false, + saveOrdersBetweenLaunches: true, botRefreshRate: 60, tradeCoinPairConfigs: const [], messageServiceConfig: null, @@ -79,6 +81,8 @@ class MarketMakerBotSettings extends Equatable { return MarketMakerBotSettings( isMMBotEnabled: enabled ?? false, + saveOrdersBetweenLaunches: + json['save_orders_between_launches'] as bool? ?? true, botRefreshRate: refresh, tradeCoinPairConfigs: configs, messageServiceConfig: messageCfg, @@ -88,6 +92,9 @@ class MarketMakerBotSettings extends Equatable { /// Whether the Market Maker Bot is enabled (menu item is shown or not). final bool isMMBotEnabled; + /// Whether maker order configs should be retained between app launches. + final bool saveOrdersBetweenLaunches; + /// The refresh rate of the bot in seconds. final int botRefreshRate; @@ -102,6 +109,7 @@ class MarketMakerBotSettings extends Equatable { Map toJson() { return { 'is_market_maker_bot_enabled': isMMBotEnabled, + 'save_orders_between_launches': saveOrdersBetweenLaunches, 'bot_refresh_rate': botRefreshRate, 'trade_coin_pair_configs': tradeCoinPairConfigs .map((e) => e.toJson()) @@ -115,6 +123,7 @@ class MarketMakerBotSettings extends Equatable { Map toLegacyJson() { return { 'is_market_maker_bot_enabled': isMMBotEnabled, + 'save_orders_between_launches': saveOrdersBetweenLaunches, // Old builds included a price_url; provide the previous default 'price_url': 'https://defistats.gleec.com/api/v3/prices/tickers_v2?expire_at=600', @@ -129,12 +138,15 @@ class MarketMakerBotSettings extends Equatable { MarketMakerBotSettings copyWith({ bool? isMMBotEnabled, + bool? saveOrdersBetweenLaunches, int? botRefreshRate, List? tradeCoinPairConfigs, MessageServiceConfig? messageServiceConfig, }) { return MarketMakerBotSettings( isMMBotEnabled: isMMBotEnabled ?? this.isMMBotEnabled, + saveOrdersBetweenLaunches: + saveOrdersBetweenLaunches ?? this.saveOrdersBetweenLaunches, botRefreshRate: botRefreshRate ?? this.botRefreshRate, tradeCoinPairConfigs: tradeCoinPairConfigs ?? this.tradeCoinPairConfigs, messageServiceConfig: messageServiceConfig ?? this.messageServiceConfig, @@ -144,6 +156,7 @@ class MarketMakerBotSettings extends Equatable { @override List get props => [ isMMBotEnabled, + saveOrdersBetweenLaunches, botRefreshRate, tradeCoinPairConfigs, messageServiceConfig, diff --git a/lib/model/stored_settings.dart b/lib/model/stored_settings.dart index c10085ebdd..c14c1f3ac8 100644 --- a/lib/model/stored_settings.dart +++ b/lib/model/stored_settings.dart @@ -12,6 +12,7 @@ class StoredSettings { required this.weakPasswordsAllowed, required this.hideZeroBalanceAssets, required this.diagnosticLoggingEnabled, + required this.hideBalances, }); final ThemeMode mode; @@ -21,6 +22,7 @@ class StoredSettings { final bool weakPasswordsAllowed; final bool hideZeroBalanceAssets; final bool diagnosticLoggingEnabled; + final bool hideBalances; static StoredSettings initial() { return StoredSettings( @@ -31,6 +33,7 @@ class StoredSettings { weakPasswordsAllowed: false, hideZeroBalanceAssets: false, diagnosticLoggingEnabled: false, + hideBalances: false, ); } @@ -47,6 +50,7 @@ class StoredSettings { weakPasswordsAllowed: json['weakPasswordsAllowed'] ?? false, hideZeroBalanceAssets: json['hideZeroBalanceAssets'] ?? false, diagnosticLoggingEnabled: json['diagnosticLoggingEnabled'] ?? false, + hideBalances: json['hideBalances'] ?? false, ); } @@ -59,6 +63,7 @@ class StoredSettings { 'weakPasswordsAllowed': weakPasswordsAllowed, 'hideZeroBalanceAssets': hideZeroBalanceAssets, 'diagnosticLoggingEnabled': diagnosticLoggingEnabled, + 'hideBalances': hideBalances, }; } @@ -72,6 +77,7 @@ class StoredSettings { 'testCoinsEnabled': testCoinsEnabled, 'weakPasswordsAllowed': weakPasswordsAllowed, 'hideZeroBalanceAssets': hideZeroBalanceAssets, + 'hideBalances': hideBalances, }; } @@ -83,6 +89,7 @@ class StoredSettings { bool? weakPasswordsAllowed, bool? hideZeroBalanceAssets, bool? diagnosticLoggingEnabled, + bool? hideBalances, }) { return StoredSettings( mode: mode ?? this.mode, @@ -95,6 +102,7 @@ class StoredSettings { hideZeroBalanceAssets ?? this.hideZeroBalanceAssets, diagnosticLoggingEnabled: diagnosticLoggingEnabled ?? this.diagnosticLoggingEnabled, + hideBalances: hideBalances ?? this.hideBalances, ); } } diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index df3ad20551..3c5fc7c70d 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -36,6 +36,8 @@ class Wallet { hasBackup: hasBackup, type: walletType, seedPhrase: '', + provenance: WalletProvenance.generated, + createdAt: DateTime.now(), ), ); } @@ -85,6 +87,8 @@ class WalletConfig { this.pubKey, this.type = WalletType.iguana, this.isLegacyWallet = false, + this.provenance = WalletProvenance.unknown, + this.createdAt, }); factory WalletConfig.fromJson(Map json) { @@ -98,6 +102,12 @@ class WalletConfig { json['activated_coins'] as List? ?? [], ).toList(), hasBackup: json['has_backup'] as bool? ?? false, + provenance: WalletProvenance.fromJson( + json['wallet_provenance'] as String? ?? json['provenance'] as String?, + ), + createdAt: _parseCreatedAt( + json['wallet_created_at'] ?? json['created_at'], + ), ); } @@ -107,6 +117,8 @@ class WalletConfig { bool hasBackup; WalletType type; bool isLegacyWallet; + WalletProvenance provenance; + DateTime? createdAt; Map toJson() { return { @@ -115,6 +127,8 @@ class WalletConfig { 'pub_key': pubKey, 'activated_coins': activatedCoins, 'has_backup': hasBackup, + 'provenance': provenance.name, + 'created_at': createdAt?.millisecondsSinceEpoch, }; } @@ -128,6 +142,8 @@ class WalletConfig { // Preserve legacy flag when copying config; losing this flag breaks // legacy login flow and can hide the wallet from lists. isLegacyWallet: isLegacyWallet, + provenance: provenance, + createdAt: createdAt, ); } @@ -138,6 +154,8 @@ class WalletConfig { bool? hasBackup, WalletType? type, bool? isLegacyWallet, + WalletProvenance? provenance, + DateTime? createdAt, }) { return WalletConfig( seedPhrase: seedPhrase ?? this.seedPhrase, @@ -146,8 +164,23 @@ class WalletConfig { hasBackup: hasBackup ?? this.hasBackup, type: type ?? this.type, isLegacyWallet: isLegacyWallet ?? this.isLegacyWallet, + provenance: provenance ?? this.provenance, + createdAt: createdAt ?? this.createdAt, ); } + + static DateTime? _parseCreatedAt(dynamic value) { + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value); + } + if (value is String) { + final asInt = int.tryParse(value); + if (asInt != null) { + return DateTime.fromMillisecondsSinceEpoch(asInt); + } + } + return null; + } } enum WalletType { @@ -173,11 +206,39 @@ enum WalletType { } } +enum WalletProvenance { + generated, + imported, + unknown; + + factory WalletProvenance.fromJson(String? value) { + switch (value) { + case 'generated': + return WalletProvenance.generated; + case 'imported': + case 'restored': + case 'migrated': + return WalletProvenance.imported; + default: + return WalletProvenance.unknown; + } + } +} + extension KdfUserWalletExtension on KdfUser { Wallet get wallet { final walletType = WalletType.fromJson( metadata['type'] as String? ?? 'iguana', ); + final provenance = WalletProvenance.fromJson( + metadata['wallet_provenance'] as String?, + ); + final createdAtRaw = metadata['wallet_created_at']; + final createdAt = createdAtRaw is int + ? DateTime.fromMillisecondsSinceEpoch(createdAtRaw) + : createdAtRaw is String && int.tryParse(createdAtRaw) != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(createdAtRaw)) + : null; return Wallet( id: walletId.name, name: walletId.name, @@ -188,6 +249,8 @@ extension KdfUserWalletExtension on KdfUser { metadata.valueOrNull>('activated_coins') ?? [], hasBackup: metadata['has_backup'] as bool? ?? false, type: walletType, + provenance: provenance, + createdAt: createdAt, ), ); } diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 80be34869e..18c6b0bce9 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -32,6 +32,7 @@ class ArrrActivationService { /// Track ongoing activation flows per asset to prevent duplicate runs final Map> _ongoingActivations = {}; + final Set _cancelledActivations = {}; /// Subscription to auth state changes StreamSubscription? _authSubscription; @@ -76,6 +77,8 @@ class ArrrActivationService { Asset asset, { ZhtlcUserConfig? initialConfig, }) async { + _cancelledActivations.remove(asset.id); + var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); if (config == null) { @@ -154,6 +157,10 @@ class ArrrActivationService { try { final result = await retry( () async { + if (_isActivationCancelled(asset.id)) { + throw _ActivationCancelledException(); + } + attempt += 1; _log.info( 'Starting ARRR activation attempt $attempt for ${asset.id.id}', @@ -165,6 +172,9 @@ class ArrrActivationService { await for (final activationProgress in _sdk.assets.activateAsset( asset, )) { + if (_isActivationCancelled(asset.id)) { + throw _ActivationCancelledException(); + } await _cacheActivationProgress(asset.id, activationProgress); lastActivationProgress = activationProgress; } @@ -187,6 +197,7 @@ class ArrrActivationService { } final errorMessage = + lastActivationProgress?.sdkError?.fallbackMessage ?? lastActivationProgress?.errorMessage ?? 'Unknown activation error'; throw _RetryableZhtlcActivationException(errorMessage); @@ -196,6 +207,7 @@ class ArrrActivationService { initialDelay: const Duration(seconds: 5), maxDelay: const Duration(seconds: 30), ), + shouldRetry: (error) => error is _RetryableZhtlcActivationException, onRetry: (currentAttempt, error, delay) { _log.warning( 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' @@ -205,6 +217,10 @@ class ArrrActivationService { ); return result; + } on _ActivationCancelledException { + _log.info('ARRR activation cancelled by user for ${asset.id.id}'); + await _cacheActivationError(asset.id, 'Activation cancelled by user'); + return const ArrrActivationResultError('Activation cancelled by user'); } catch (e, stackTrace) { _log.severe( 'ARRR activation failed after $maxAttempts attempts for ${asset.id.id}', @@ -246,6 +262,9 @@ class ArrrActivationService { AssetId assetId, ActivationProgress progress, ) async { + if (_isActivationCancelled(assetId)) { + return; + } await _activationCacheMutex.protectWrite(() async { final current = _activationCache[assetId]; if (current is ArrrActivationStatusInProgress) { @@ -259,6 +278,9 @@ class ArrrActivationService { } Future _cacheActivationComplete(AssetId assetId) async { + if (_isActivationCancelled(assetId)) { + return; + } await _activationCacheMutex.protectWrite(() async { _activationCache[assetId] = ArrrActivationStatusCompleted( assetId: assetId, @@ -302,6 +324,13 @@ class ArrrActivationService { ); } + Future cancelActivation(AssetId assetId) async { + _log.info('Cancelling activation for ${assetId.id}'); + _cancelledActivations.add(assetId); + cancelConfiguration(assetId); + await _cacheActivationError(assetId, 'Activation cancelled by user'); + } + /// Submit configuration for a pending request /// Called by UI when user provides configuration Future submitConfiguration( @@ -424,6 +453,7 @@ class ArrrActivationService { /// Clean up all user-specific state when user signs out Future _cleanupOnSignOut() async { _log.info('User signed out - cleaning up active ZHTLC activations'); + _cancelledActivations.clear(); // Cancel all pending configuration requests final pendingAssets = _configCompleters.keys.toList(); @@ -456,10 +486,7 @@ class ArrrActivationService { /// 1. Cancel any ongoing activation tasks for the asset /// 2. Disable the coin if it's currently active /// 3. Store the new configuration - Future updateZhtlcConfig( - Asset asset, - ZhtlcUserConfig newConfig, - ) async { + Future updateZhtlcConfig(Asset asset, ZhtlcUserConfig newConfig) async { if (_isDisposing || _configRequestController.isClosed) { throw StateError('ArrrActivationService has been disposed'); } @@ -516,6 +543,7 @@ class ArrrActivationService { void dispose() { // Mark as disposing to prevent new operations _isDisposing = true; + _cancelledActivations.clear(); // Cancel auth subscription first _authSubscription?.cancel(); @@ -533,6 +561,10 @@ class ArrrActivationService { _configRequestController.close(); } } + + bool _isActivationCancelled(AssetId assetId) { + return _cancelledActivations.contains(assetId); + } } class _RetryableZhtlcActivationException implements Exception { @@ -544,6 +576,13 @@ class _RetryableZhtlcActivationException implements Exception { String toString() => 'RetryableZhtlcActivationException: $message'; } +class _ActivationCancelledException implements Exception { + const _ActivationCancelledException(); + + @override + String toString() => 'Activation cancelled by user'; +} + /// Configuration request model for UI handling class ZhtlcConfigurationRequest { const ZhtlcConfigurationRequest({ diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index a9813a080a..5c3486597d 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -68,7 +68,19 @@ final class AppBootstrapper { /// Initialize settings and register analytics Future _initializeSettings() async { - final stored = await SettingsRepository.loadStoredSettings(); + var stored = await SettingsRepository.loadStoredSettings(); + final mmSettings = stored.marketMakerBotSettings; + final shouldClearStoredMakerOrders = + !mmSettings.saveOrdersBetweenLaunches && + mmSettings.tradeCoinPairConfigs.isNotEmpty; + if (shouldClearStoredMakerOrders) { + final clearedMmSettings = mmSettings.copyWith( + tradeCoinPairConfigs: const [], + ); + stored = stored.copyWith(marketMakerBotSettings: clearedMmSettings); + await SettingsRepository().updateSettings(stored); + } + _storedSettings = stored; // Register the unified analytics repository with GetIt diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index 3250fea285..d83595f8e4 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -19,6 +19,7 @@ const String storedSettingsKeyV2 = 'komodo_wallet_settings_v2'; const String storedAnalyticsSettingsKey = 'analytics_settings'; const String storedMarketMakerSettingsKey = 'market_maker_settings'; const String lastLoggedInWalletKey = 'last_logged_in_wallet'; +const String hdWalletModePreferenceKey = 'wallet_hd_mode_preference'; // anchor: protocols support const String ercTxHistoryUrl = 'https://etherscan.gleec.com/api'; @@ -32,6 +33,7 @@ const int contactDetailsMaxLength = 100; // Maximum allowed length for passwords across the app // TODO: Mirror this limit in the SDK validation and any backend API constraints const int passwordMaxLength = 128; +const String maskedBalanceText = '****'; final RegExp discordUsernameRegex = RegExp(r'^[a-zA-Z0-9._]{2,32}$'); final RegExp telegramUsernameRegex = RegExp(r'^[a-zA-Z0-9_]{5,32}$'); final RegExp matrixIdRegex = RegExp( diff --git a/lib/shared/utils/hd_wallet_mode_preference.dart b/lib/shared/utils/hd_wallet_mode_preference.dart new file mode 100644 index 0000000000..b472adea3f --- /dev/null +++ b/lib/shared/utils/hd_wallet_mode_preference.dart @@ -0,0 +1,38 @@ +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart'; + +String _walletHdWalletModePreferenceKey(String walletId) { + return '$hdWalletModePreferenceKey:$walletId'; +} + +bool? _parseStoredPreference(dynamic value) { + if (value is bool) { + return value; + } + if (value is String) { + final normalized = value.toLowerCase(); + if (normalized == 'true') return true; + if (normalized == 'false') return false; + } + return null; +} + +Future readHdWalletModePreference(String walletId) async { + final storage = getStorage(); + final walletScopedValue = await storage.read( + _walletHdWalletModePreferenceKey(walletId), + ); + final parsedWalletScopedValue = _parseStoredPreference(walletScopedValue); + if (parsedWalletScopedValue != null) { + return parsedWalletScopedValue; + } + + // Fall back to the legacy global key so existing users keep their last + // selection until this wallet writes its own scoped preference. + final legacyValue = await storage.read(hdWalletModePreferenceKey); + return _parseStoredPreference(legacyValue); +} + +Future storeHdWalletModePreference(String walletId, bool value) async { + await getStorage().write(_walletHdWalletModePreferenceKey(walletId), value); +} diff --git a/lib/shared/widgets/coin_balance.dart b/lib/shared/widgets/coin_balance.dart index baa0952374..282ab448ab 100644 --- a/lib/shared/widgets/coin_balance.dart +++ b/lib/shared/widgets/coin_balance.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; @@ -16,41 +20,55 @@ class CoinBalance extends StatelessWidget { Widget build(BuildContext context) { final baseFont = Theme.of(context).textTheme.bodySmall; final balanceStyle = baseFont?.copyWith(fontWeight: FontWeight.w500); + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); - final balance = - context.sdk.balances.lastKnown(coin.id)?.spendable.toDouble() ?? 0.0; + final balanceStream = context.sdk.balances.watchBalance(coin.id); - final children = [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: AutoScrollText( - key: Key('coin-balance-asset-${coin.abbr.toLowerCase()}'), - text: doubleToString(balance), - style: balanceStyle, - textAlign: TextAlign.right, - ), - ), - Text(' ${Coin.normalizeAbbr(coin.abbr)}', style: balanceStyle), - ], - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 100), - child: CoinFiatBalance(coin, isAutoScrollEnabled: true), - ), - ]; + return StreamBuilder( + stream: balanceStream, + builder: (context, snapshot) { + final balance = snapshot.data?.spendable.toDouble(); + final balanceText = hideBalances + ? maskedBalanceText + : balance == null + ? '--' + : doubleToString(balance); - return isVertical - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ) - : Row( + final children = [ + Row( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: children, - ); + children: [ + Flexible( + child: AutoScrollText( + key: Key('coin-balance-asset-${coin.abbr.toLowerCase()}'), + text: balanceText, + style: balanceStyle, + textAlign: TextAlign.right, + ), + ), + Text(' ${Coin.normalizeAbbr(coin.abbr)}', style: balanceStyle), + ], + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: CoinFiatBalance(coin, isAutoScrollEnabled: true), + ), + ]; + + return isVertical + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: children, + ); + }, + ); } } diff --git a/lib/shared/widgets/coin_fiat_balance.dart b/lib/shared/widgets/coin_fiat_balance.dart index 6a966fdedb..16e52ede50 100644 --- a/lib/shared/widgets/coin_fiat_balance.dart +++ b/lib/shared/widgets/coin_fiat_balance.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -21,6 +25,9 @@ class CoinFiatBalance extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); final balanceStream = context.sdk.balances.watchBalance(coin.id); final TextStyle mergedStyle = const TextStyle( @@ -28,32 +35,55 @@ class CoinFiatBalance extends StatelessWidget { fontWeight: FontWeight.w500, ).merge(style); - return StreamBuilder( - stream: balanceStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - final usdBalance = coin.lastKnownUsdBalance(context.sdk); - if (usdBalance == null) { - return const SizedBox(); - } - - final formattedBalance = formatUsdValue(usdBalance); - final balanceStr = ' ($formattedBalance)'; - - if (isAutoScrollEnabled) { - return AutoScrollText( - text: balanceStr, - style: mergedStyle, - isSelectable: isSelectable, - ); - } - - return isSelectable - ? SelectableText(balanceStr, style: mergedStyle) - : Text(balanceStr, style: mergedStyle); + if (hideBalances) { + final balanceStr = ' ($maskedBalanceText)'; + return isAutoScrollEnabled + ? AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ) + : isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + } + + return BlocSelector( + selector: (state) => state.getPriceForAsset(coin.id)?.price?.toDouble(), + builder: (context, price) { + return StreamBuilder( + stream: balanceStream, + builder: (context, snapshot) { + final balance = snapshot.data?.spendable.toDouble(); + if (balance == null || price == null) { + const balanceStr = ' (--)'; + return isAutoScrollEnabled + ? AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ) + : isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + } + + final formattedBalance = formatUsdValue(price * balance); + final balanceStr = ' ($formattedBalance)'; + + if (isAutoScrollEnabled) { + return AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ); + } + + return isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + }, + ); }, ); } diff --git a/lib/shared/widgets/connect_wallet/connect_wallet_button.dart b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart index 45860ae02d..8272269650 100644 --- a/lib/shared/widgets/connect_wallet/connect_wallet_button.dart +++ b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart @@ -9,12 +9,12 @@ import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/common/screen.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; class ConnectWalletButton extends StatefulWidget { const ConnectWalletButton({ @@ -23,8 +23,8 @@ class ConnectWalletButton extends StatefulWidget { this.withText = true, this.withIcon = false, Size? buttonSize, - }) : buttonSize = buttonSize ?? const Size(double.infinity, 40), - super(key: key); + }) : buttonSize = buttonSize ?? const Size(double.infinity, 40), + super(key: key); final Size buttonSize; final bool withIcon; final bool withText; @@ -38,15 +38,6 @@ class _ConnectWalletButtonState extends State { static const String walletIconPath = '$assetsPath/nav_icons/desktop/dark/wallet.svg'; - PopupDispatcher? _popupDispatcher; - - @override - void dispose() { - _popupDispatcher?.close(); - _popupDispatcher = null; - super.dispose(); - } - @override Widget build(BuildContext context) { return widget.withText @@ -60,15 +51,17 @@ class _ConnectWalletButtonState extends State { child: SvgPicture.asset( walletIconPath, colorFilter: ColorFilter.mode( - theme.custom.defaultGradientButtonTextColor, - BlendMode.srcIn), + theme.custom.defaultGradientButtonTextColor, + BlendMode.srcIn, + ), width: 15, height: 15, ), ) : null, - text: LocaleKeys.connectSomething - .tr(args: [LocaleKeys.wallet.tr().toLowerCase()]), + text: LocaleKeys.connectSomething.tr( + args: [LocaleKeys.wallet.tr().toLowerCase()], + ), onPressed: onButtonPressed, ) : ElevatedButton( @@ -85,33 +78,32 @@ class _ConnectWalletButtonState extends State { child: SvgPicture.asset( walletIconPath, colorFilter: ColorFilter.mode( - theme.custom.defaultGradientButtonTextColor, BlendMode.srcIn), + theme.custom.defaultGradientButtonTextColor, + BlendMode.srcIn, + ), width: 20, ), ); } - void onButtonPressed() { - _popupDispatcher = _createPopupDispatcher(); - _popupDispatcher?.show(); - } - - PopupDispatcher _createPopupDispatcher() { + Future onButtonPressed() async { final TakerBloc takerBloc = context.read(); final BridgeBloc bridgeBloc = context.read(); + final BuildContext dialogContext = scaffoldKey.currentContext ?? context; - return PopupDispatcher( - borderColor: theme.custom.specificButtonBorderColor, - barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + await AppDialog.showWithCallback( + context: dialogContext, + barrierDismissible: false, width: 320, - context: scaffoldKey.currentContext ?? context, - popupContent: WalletsManagerWrapper( + useRootNavigator: true, + childBuilder: (closeDialog) => WalletsManagerWrapper( eventType: widget.eventType, + onCancel: closeDialog, onSuccess: (_) async { takerBloc.add(TakerReInit()); bridgeBloc.add(const BridgeReInit()); - await reInitTradingForms(context); - _popupDispatcher?.close(); + await reInitTradingForms(dialogContext); + closeDialog(); }, ), ); diff --git a/lib/shared/widgets/remember_wallet_service.dart b/lib/shared/widgets/remember_wallet_service.dart index 64a0864a60..39f0a86905 100644 --- a/lib/shared/widgets/remember_wallet_service.dart +++ b/lib/shared/widgets/remember_wallet_service.dart @@ -92,6 +92,7 @@ class RememberWalletService { await AppDialog.showWithCallback( context: context, width: 320, + barrierDismissible: false, // Keep default useRootNavigator (true) to avoid navigation stack corruption childBuilder: (closeDialog) => WalletsManagerWrapper( eventType: WalletsManagerEventType.header, diff --git a/lib/views/common/header/actions/account_switcher.dart b/lib/views/common/header/actions/account_switcher.dart index 466a99d75a..f7052d282a 100644 --- a/lib/views/common/header/actions/account_switcher.dart +++ b/lib/views/common/header/actions/account_switcher.dart @@ -1,74 +1,25 @@ import 'package:app_theme/app_theme.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; -import 'package:web_dex/shared/widgets/logout_popup.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; const double minWidth = 100; const double maxWidth = 350; -class AccountSwitcher extends StatefulWidget { +class AccountSwitcher extends StatelessWidget { const AccountSwitcher({Key? key}) : super(key: key); - @override - State createState() => _AccountSwitcherState(); -} - -class _AccountSwitcherState extends State { - late PopupDispatcher _logOutPopupManager; - bool _isOpen = false; - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _logOutPopupManager = PopupDispatcher( - context: scaffoldKey.currentContext ?? context, - popupContent: LogOutPopup( - onConfirm: () => _logOutPopupManager.close(), - onCancel: () => _logOutPopupManager.close(), - ), - ); - }); - super.initState(); - } - - @override - void dispose() { - _logOutPopupManager.close(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return ConnectWalletWrapper( - buttonSize: const Size(160, 30), + return const ConnectWalletWrapper( + buttonSize: Size(160, 30), withIcon: true, eventType: WalletsManagerEventType.header, - child: UiDropdown( - isOpen: _isOpen, - onSwitch: (isOpen) { - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() => _isOpen = isOpen); - }); - }, - switcher: const _AccountSwitcher(), - dropdown: _AccountDropdown( - onTap: () { - _logOutPopupManager.show(); - setState(() { - _isOpen = false; - }); - }, - ), - ), + child: _AccountSwitcher(), ); } } @@ -78,7 +29,6 @@ class _AccountSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final currentWallet = context.read().state.currentUser?.wallet; return Container( constraints: const BoxConstraints(minWidth: minWidth), padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), @@ -91,7 +41,7 @@ class _AccountSwitcher extends StatelessWidget { return Container( constraints: const BoxConstraints(maxWidth: maxWidth), child: Text( - currentWallet?.name ?? '', + state.currentUser?.walletId.name ?? '', style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, @@ -112,44 +62,6 @@ class _AccountSwitcher extends StatelessWidget { } } -class _AccountDropdown extends StatelessWidget { - final VoidCallback onTap; - const _AccountDropdown({required this.onTap}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: theme.custom.specificButtonBorderColor, - ), - ), - constraints: const BoxConstraints(minWidth: minWidth, maxWidth: maxWidth), - child: InkWell( - onTap: onTap, - child: Container( - height: 40, - padding: const EdgeInsets.fromLTRB(12, 0, 22, 0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - LocaleKeys.logOut.tr(), - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ); - } -} - class _AccountIcon extends StatelessWidget { const _AccountIcon(); @@ -158,8 +70,9 @@ class _AccountIcon extends StatelessWidget { return Container( padding: const EdgeInsets.all(2.0), decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.tertiary), + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.tertiary, + ), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: SvgPicture.asset( diff --git a/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart index 0b5959b35b..b28e8e7ed3 100644 --- a/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart +++ b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart @@ -13,10 +13,8 @@ import 'package:web_dex/model/hw_wallet/hw_wallet.dart'; import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; class HwDialogWalletSelect extends StatefulWidget { - const HwDialogWalletSelect({ - Key? key, - required this.onSelect, - }) : super(key: key); + const HwDialogWalletSelect({Key? key, required this.onSelect}) + : super(key: key); final void Function(WalletBrand) onSelect; @@ -45,6 +43,14 @@ class _HwDialogWalletSelectState extends State { style: trezorDialogSubtitle, textAlign: TextAlign.center, ), + const SizedBox(height: 8), + Text( + LocaleKeys.trezorWalletOnlyNotice.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), const SizedBox(height: 24), _HwWalletTile( disabled: !platformState.isTrezorSupported, @@ -66,16 +72,14 @@ class _HwDialogWalletSelectState extends State { margin: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.1), + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context) - .colorScheme - .error - .withValues(alpha: 0.3), + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.3), ), ), child: Text( diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart index 011bb96082..ee6078c202 100644 --- a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart @@ -15,26 +15,28 @@ class TrezorDialogSelectWallet extends StatelessWidget { @override Widget build(BuildContext context) { - return ScreenshotSensitive(child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - LocaleKeys.selectWalletType.tr(), - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 18), - _TrezorStandardWallet(onTap: () => onComplete('')), - const Padding( - padding: EdgeInsets.symmetric(vertical: 6.0), - child: UiDivider(), - ), - _TrezorHiddenWallet( - onSubmit: (String passphrase) => onComplete(passphrase), - ), - ], - )); + return ScreenshotSensitive( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.selectWalletType.tr(), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 18), + _TrezorStandardWallet(onTap: () => onComplete('')), + const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: UiDivider(), + ), + _TrezorHiddenWallet( + onSubmit: (String passphrase) => onComplete(passphrase), + ), + ], + ), + ); } } @@ -90,7 +92,7 @@ class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { description: LocaleKeys.passphraseRequired.tr(), icon: Icons.lock_outline, isIconShown: _isSendAllowed, - onTap: _onSubmit, + onTap: _isSendAllowed ? _onSubmit : null, ), const SizedBox(height: 12), ConstrainedBox( @@ -158,7 +160,7 @@ class _TrezorWalletItem extends StatelessWidget { final String title; final String description; final IconData icon; - final VoidCallback onTap; + final VoidCallback? onTap; final bool isIconShown; @override diff --git a/lib/views/common/main_menu/main_menu_bar_mobile.dart b/lib/views/common/main_menu/main_menu_bar_mobile.dart index 71bbaf4759..7152b514cb 100644 --- a/lib/views/common/main_menu/main_menu_bar_mobile.dart +++ b/lib/views/common/main_menu/main_menu_bar_mobile.dart @@ -25,6 +25,24 @@ class MainMenuBarMobile extends StatelessWidget { .watch() .state .isEnabled; + final bool isHardwareWallet = currentWallet?.isHW == true; + + String tradingTooltipMessage() { + if (isHardwareWallet) { + return LocaleKeys.trezorWalletOnlyTooltip.tr(); + } + if (!tradingEnabled) { + return LocaleKeys.tradingDisabledTooltip.tr(); + } + return ''; + } + + String walletOnlyTooltipMessage() { + return isHardwareWallet + ? LocaleKeys.trezorWalletOnlyTooltip.tr() + : ''; + } + return DecoratedBox( decoration: BoxDecoration( color: theme.currentGlobal.cardColor, @@ -50,17 +68,18 @@ class MainMenuBarMobile extends StatelessWidget { ), ), Expanded( - child: MainMenuBarMobileItem( - value: MainMenuValue.fiat, - enabled: currentWallet?.isHW != true, - isActive: selected == MainMenuValue.fiat, + child: Tooltip( + message: walletOnlyTooltipMessage(), + child: MainMenuBarMobileItem( + value: MainMenuValue.fiat, + enabled: currentWallet?.isHW != true, + isActive: selected == MainMenuValue.fiat, + ), ), ), Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: MainMenuBarMobileItem( value: MainMenuValue.dex, enabled: currentWallet?.isHW != true, @@ -70,9 +89,7 @@ class MainMenuBarMobile extends StatelessWidget { ), Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: MainMenuBarMobileItem( value: MainMenuValue.bridge, enabled: currentWallet?.isHW != true, @@ -83,9 +100,7 @@ class MainMenuBarMobile extends StatelessWidget { if (isMMBotEnabled) Expanded( child: Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: MainMenuBarMobileItem( enabled: currentWallet?.isHW != true, value: MainMenuValue.marketMakerBot, diff --git a/lib/views/common/main_menu/main_menu_desktop.dart b/lib/views/common/main_menu/main_menu_desktop.dart index ab5b4bba44..0fbe97b358 100644 --- a/lib/views/common/main_menu/main_menu_desktop.dart +++ b/lib/views/common/main_menu/main_menu_desktop.dart @@ -41,6 +41,23 @@ class _MainMenuDesktopState extends State { .isEnabled; final SettingsBloc settings = context.read(); final currentWallet = state.currentUser?.wallet; + final bool isHardwareWallet = currentWallet?.isHW == true; + + String tradingTooltipMessage() { + if (isHardwareWallet) { + return LocaleKeys.trezorWalletOnlyTooltip.tr(); + } + if (!tradingEnabled) { + return LocaleKeys.tradingDisabledTooltip.tr(); + } + return ''; + } + + String walletOnlyTooltipMessage() { + return isHardwareWallet + ? LocaleKeys.trezorWalletOnlyTooltip.tr() + : ''; + } return Container( height: double.infinity, @@ -78,19 +95,20 @@ class _MainMenuDesktopState extends State { MainMenuValue.wallet, ), ), - DesktopMenuDesktopItem( - key: const Key('main-menu-fiat'), - enabled: currentWallet?.isHW != true, - menu: MainMenuValue.fiat, - onTap: onTapItem, - isSelected: _checkSelectedItem( - MainMenuValue.fiat, + Tooltip( + message: walletOnlyTooltipMessage(), + child: DesktopMenuDesktopItem( + key: const Key('main-menu-fiat'), + enabled: currentWallet?.isHW != true, + menu: MainMenuValue.fiat, + onTap: onTapItem, + isSelected: _checkSelectedItem( + MainMenuValue.fiat, + ), ), ), Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-dex'), enabled: currentWallet?.isHW != true, @@ -102,9 +120,7 @@ class _MainMenuDesktopState extends State { ), ), Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-bridge'), enabled: currentWallet?.isHW != true, @@ -117,9 +133,7 @@ class _MainMenuDesktopState extends State { ), if (isMMBotEnabled && isAuthenticated) Tooltip( - message: tradingEnabled - ? '' - : LocaleKeys.tradingDisabledTooltip.tr(), + message: tradingTooltipMessage(), child: DesktopMenuDesktopItem( key: const Key('main-menu-market-maker-bot'), enabled: currentWallet?.isHW != true, diff --git a/lib/views/main_layout/widgets/main_layout_top_bar.dart b/lib/views/main_layout/widgets/main_layout_top_bar.dart index 6466729310..0fc75f5cc8 100644 --- a/lib/views/main_layout/widgets/main_layout_top_bar.dart +++ b/lib/views/main_layout/widgets/main_layout_top_bar.dart @@ -6,9 +6,11 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/release_options.dart'; +import 'package:web_dex/shared/constants.dart'; import 'package:web_dex/shared/utils/extensions/sdk_extensions.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/views/common/header/actions/account_switcher.dart'; @@ -18,6 +20,13 @@ class MainLayoutTopBar extends StatelessWidget { @override Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + final screenWidth = MediaQuery.of(context).size.width; + final bool isCompact = screenWidth < 1100; + final double horizontalPadding = isCompact ? 16 : 32; + final double leadingWidth = isCompact ? 160 : 200; return Container( decoration: BoxDecoration( border: Border( @@ -40,17 +49,19 @@ class MainLayoutTopBar extends StatelessWidget { } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: ActionTextButton( text: LocaleKeys.balance.tr(), - secondaryText: '\$${formatAmt(totalBalance)}', + secondaryText: hideBalances + ? '\$${maskedBalanceText}' + : '\$${formatAmt(totalBalance)}', onTap: null, ), ); }, ), - leadingWidth: 200, - actions: _getHeaderActions(context), + leadingWidth: leadingWidth, + actions: _getHeaderActions(context, horizontalPadding), titleSpacing: 0, ), ); @@ -77,7 +88,10 @@ class MainLayoutTopBar extends StatelessWidget { return total != 0 ? 0.01 : 0; } - List _getHeaderActions(BuildContext context) { + List _getHeaderActions( + BuildContext context, + double horizontalPadding, + ) { final languageCodes = localeList.map((e) => e.languageCode).toList(); final langCode2flags = { for (var loc in languageCodes) @@ -86,7 +100,7 @@ class MainLayoutTopBar extends StatelessWidget { return [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ diff --git a/lib/views/settings/widgets/general_settings/app_version_number.dart b/lib/views/settings/widgets/general_settings/app_version_number.dart index 3145180dd9..96966cb816 100644 --- a/lib/views/settings/widgets/general_settings/app_version_number.dart +++ b/lib/views/settings/widgets/general_settings/app_version_number.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/bloc/version_info/version_info_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; class AppVersionNumber extends StatelessWidget { const AppVersionNumber({super.key}); @@ -19,19 +20,24 @@ class AppVersionNumber extends StatelessWidget { children: [ SelectableText(LocaleKeys.komodoWallet.tr(), style: _textStyle), if (state.appVersion != null) - SelectableText( - '${LocaleKeys.version.tr()}: ${state.appVersion}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.version.tr(), + value: state.appVersion!, + ), + if (state.buildDate != null) + _MetadataRow( + label: LocaleKeys.buildDate.tr(), + value: state.buildDate!, ), if (state.commitHash != null) - SelectableText( - '${LocaleKeys.commit.tr()}: ${state.commitHash}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.commit.tr(), + value: state.commitHash!, ), if (state.apiCommitHash != null) - SelectableText( - '${LocaleKeys.api.tr()}: ${state.apiCommitHash}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.api.tr(), + value: state.apiCommitHash!, ), const SizedBox(height: 4), CoinsCommitInfo(state: state), @@ -61,14 +67,14 @@ class CoinsCommitInfo extends StatelessWidget { children: [ Text(LocaleKeys.coinAssets.tr(), style: _textStyle), if (state.currentCoinsCommit != null) - SelectableText( - '${LocaleKeys.bundled.tr()}: ${state.currentCoinsCommit}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.bundled.tr(), + value: state.currentCoinsCommit!, ), if (state.latestCoinsCommit != null) - SelectableText( - '${LocaleKeys.updated.tr()}: ${state.latestCoinsCommit}', - style: _textStyle, + _MetadataRow( + label: LocaleKeys.updated.tr(), + value: state.latestCoinsCommit!, ), ], ); @@ -76,3 +82,33 @@ class CoinsCommitInfo extends StatelessWidget { } const _textStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w500); + +class _MetadataRow extends StatelessWidget { + const _MetadataRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Text('$label:', style: _textStyle), + CopiedTextV2( + copiedValue: value, + isTruncated: true, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + textColor: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/general_settings.dart b/lib/views/settings/widgets/general_settings/general_settings.dart index 46df5ab878..d55cc49fdd 100644 --- a/lib/views/settings/widgets/general_settings/general_settings.dart +++ b/lib/views/settings/widgets/general_settings/general_settings.dart @@ -6,6 +6,7 @@ import 'package:web_dex/shared/widgets/hidden_with_wallet.dart'; import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; import 'package:web_dex/views/settings/widgets/general_settings/import_swaps.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_download_logs.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_hide_balances.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_analytics.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_diagnostic_logging.dart'; import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_test_coins.dart'; @@ -29,6 +30,8 @@ class GeneralSettings extends StatelessWidget { const SizedBox(height: 25), const SettingsManageAnalytics(), const SizedBox(height: 25), + const SettingsHideBalances(), + const SizedBox(height: 25), const SettingsManageTestCoins(), const SizedBox(height: 25), const HiddenWithoutWallet( diff --git a/lib/views/settings/widgets/general_settings/settings_hide_balances.dart b/lib/views/settings/widgets/general_settings/settings_hide_balances.dart new file mode 100644 index 0000000000..a9ce39c59a --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_hide_balances.dart @@ -0,0 +1,46 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsHideBalances extends StatelessWidget { + const SettingsHideBalances({super.key}); + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.hideBalancesTitle.tr(), + child: const _HideBalancesSwitcher(), + ); + } +} + +class _HideBalancesSwitcher extends StatelessWidget { + const _HideBalancesSwitcher(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + children: [ + UiSwitcher( + key: const Key('hide-balances-switcher'), + value: state.hideBalances, + onChanged: (value) => _onSwitcherChanged(context, value), + ), + const SizedBox(width: 15), + Expanded(child: Text(LocaleKeys.hideBalancesSubtitle.tr())), + ], + ), + ); + } + + void _onSwitcherChanged(BuildContext context, bool value) { + context.read().add(HideBalancesChanged(hideBalances: value)); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart index 9bc55875d8..722ff97be1 100644 --- a/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart +++ b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,35 +9,281 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; import 'package:web_dex/bloc/settings/settings_state.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; -class SettingsManageTradingBot extends StatelessWidget { +class SettingsManageTradingBot extends StatefulWidget { const SettingsManageTradingBot({super.key}); + @override + State createState() => + _SettingsManageTradingBotState(); +} + +class _SettingsManageTradingBotState extends State { + final SettingsRepository _settingsRepository = SettingsRepository(); + + bool _isExporting = false; + bool _isImporting = false; + @override Widget build(BuildContext context) { - return Column( - children: [ - SettingsSection( - title: LocaleKeys.expertMode.tr(), - child: const EnableTradingBotSwitcher(), + return SettingsSection( + title: LocaleKeys.expertMode.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _EnableTradingBotSwitcher(settingsRepository: _settingsRepository), + const SizedBox(height: 14), + _SaveOrdersSwitcher(settingsRepository: _settingsRepository), + const SizedBox(height: 14), + Wrap( + spacing: 12, + runSpacing: 10, + children: [ + _buildExportButton(context), + _buildImportButton(context), + ], + ), + const SizedBox(height: 8), + Text( + 'saveOrdersRestartHint'.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildExportButton(BuildContext context) { + return UiBorderButton( + width: 180, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: 'exportMakerOrders'.tr(), + icon: _isExporting + ? const UiSpinner() + : Icon( + Icons.file_download, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isExporting || _isImporting ? null : _exportMakerOrders, + ); + } + + Widget _buildImportButton(BuildContext context) { + return UiBorderButton( + width: 180, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: 'importMakerOrders'.tr(), + icon: _isImporting + ? const UiSpinner() + : Icon( + Icons.file_upload, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isExporting || _isImporting ? null : _importMakerOrders, + ); + } + + Future _exportMakerOrders() async { + setState(() => _isExporting = true); + + try { + final settings = await _settingsRepository.loadSettings(); + final configs = settings.marketMakerBotSettings.tradeCoinPairConfigs; + if (configs.isEmpty) { + _showMessage('noMakerOrdersToExport'.tr()); + return; + } + + final payload = { + 'version': 1, + 'exported_at': DateTime.now().toUtc().toIso8601String(), + 'trade_coin_pair_configs': configs.map((e) => e.toJson()).toList(), + }; + final timestamp = DateTime.now().toUtc().toIso8601String().replaceAll( + ':', + '-', + ); + + await FileLoader.fromPlatform().save( + fileName: 'maker_orders_$timestamp.json', + data: jsonEncode(payload), + type: LoadFileType.text, + ); + _showMessage( + 'makerOrdersExportSuccess'.tr(args: [configs.length.toString()]), + ); + } catch (error) { + _showMessage( + 'makerOrdersExportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + Future _importMakerOrders() async { + setState(() => _isImporting = true); + + try { + await FileLoader.fromPlatform().upload( + fileType: LoadFileType.text, + onUpload: (_, content) { + unawaited(_applyImportedOrders(content)); + }, + onError: (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [error]), + isError: true, + ); + if (mounted) { + setState(() => _isImporting = false); + } + }, + ); + } catch (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + // On desktop/native file picker this also handles cancel events. + setState(() => _isImporting = false); + } + } + } + + Future _applyImportedOrders(String? rawContent) async { + try { + final content = rawContent?.trim() ?? ''; + if (content.isEmpty) { + throw const FormatException('File is empty'); + } + + final importedConfigs = _decodeTradePairConfigs(content); + final stored = await _settingsRepository.loadSettings(); + final updatedMmSettings = stored.marketMakerBotSettings.copyWith( + tradeCoinPairConfigs: importedConfigs, + ); + + await _settingsRepository.updateSettings( + stored.copyWith(marketMakerBotSettings: updatedMmSettings), + ); + + if (!mounted) return; + context.read().add( + MarketMakerBotSettingsChanged(updatedMmSettings), + ); + _showMessage( + 'makerOrdersImportSuccess'.tr( + args: [importedConfigs.length.toString()], ), - ], + ); + } catch (error) { + _showMessage( + 'makerOrdersImportFailed'.tr(args: [_readableError(error)]), + isError: true, + ); + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + List _decodeTradePairConfigs(String jsonPayload) { + final decoded = jsonDecode(jsonPayload); + final dynamic rawConfigs; + if (decoded is List) { + rawConfigs = decoded; + } else if (decoded is Map) { + rawConfigs = + decoded['trade_coin_pair_configs'] ?? + decoded['tradeCoinPairConfigs'] ?? + decoded['orders']; + } else { + throw const FormatException('Unsupported file format'); + } + + if (rawConfigs is! List) { + throw const FormatException('Missing maker order configuration list'); + } + + final dedupedByName = {}; + for (final item in rawConfigs) { + if (item is! Map) { + continue; + } + + try { + final config = TradeCoinPairConfig.fromJson( + Map.from(item), + ); + dedupedByName[config.name] = config; + } catch (_) { + // Skip malformed entries and continue parsing. + } + } + + if (dedupedByName.isEmpty) { + throw const FormatException('No valid maker order configurations found'); + } + + return dedupedByName.values.toList(); + } + + void _showMessage(String message, {bool isError = false}) { + if (!mounted) return; + + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + + messenger.showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : null, + ), ); } + + String _readableError(Object error) { + final value = error.toString().trim(); + if (value.startsWith('Exception: ')) { + return value.replaceFirst('Exception: ', '').trim(); + } + return value.isEmpty ? LocaleKeys.somethingWrong.tr() : value; + } } -class EnableTradingBotSwitcher extends StatelessWidget { - const EnableTradingBotSwitcher({super.key}); +class _EnableTradingBotSwitcher extends StatelessWidget { + const _EnableTradingBotSwitcher({required this.settingsRepository}); + + final SettingsRepository settingsRepository; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) => Row( mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, children: [ UiSwitcher( key: const Key('enable-trading-bot-switcher'), @@ -41,16 +291,19 @@ class EnableTradingBotSwitcher extends StatelessWidget { onChanged: (value) => _onSwitcherChanged(context, value), ), const SizedBox(width: 15), - Text(LocaleKeys.enableTradingBot.tr()), + Flexible(child: Text(LocaleKeys.enableTradingBot.tr())), ], ), ); } - void _onSwitcherChanged(BuildContext context, bool value) { - final settings = context.read().state.mmBotSettings.copyWith( + Future _onSwitcherChanged(BuildContext context, bool value) async { + final stored = await settingsRepository.loadSettings(); + final settings = stored.marketMakerBotSettings.copyWith( isMMBotEnabled: value, ); + + if (!context.mounted) return; context.read().add(MarketMakerBotSettingsChanged(settings)); if (!value) { @@ -60,3 +313,37 @@ class EnableTradingBotSwitcher extends StatelessWidget { } } } + +class _SaveOrdersSwitcher extends StatelessWidget { + const _SaveOrdersSwitcher({required this.settingsRepository}); + + final SettingsRepository settingsRepository; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + UiSwitcher( + key: const Key('save-orders-switcher'), + value: state.mmBotSettings.saveOrdersBetweenLaunches, + onChanged: (value) => _onSwitcherChanged(context, value), + ), + const SizedBox(width: 15), + Flexible(child: Text('saveOrders'.tr())), + ], + ), + ); + } + + Future _onSwitcherChanged(BuildContext context, bool value) async { + final stored = await settingsRepository.loadSettings(); + final settings = stored.marketMakerBotSettings.copyWith( + saveOrdersBetweenLaunches: value, + ); + + if (!context.mounted) return; + context.read().add(MarketMakerBotSettingsChanged(settings)); + } +} diff --git a/lib/views/settings/widgets/settings_menu/settings_logout_button.dart b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart index 33f2ca0036..23bc90345c 100644 --- a/lib/views/settings/widgets/settings_menu/settings_logout_button.dart +++ b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart @@ -1,10 +1,9 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/common/screen.dart'; -import 'package:web_dex/dispatchers/popup_dispatcher.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/app_dialog.dart'; import 'package:web_dex/shared/widgets/logout_popup.dart'; class SettingsLogoutButton extends StatefulWidget { @@ -15,26 +14,14 @@ class SettingsLogoutButton extends StatefulWidget { } class _SettingsLogoutButtonState extends State { - late PopupDispatcher _logOutPopupManager; - - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _logOutPopupManager = PopupDispatcher( - context: scaffoldKey.currentContext ?? context, - popupContent: LogOutPopup( - onConfirm: () => _logOutPopupManager.close(), - onCancel: () => _logOutPopupManager.close(), - ), - ); - }); - super.initState(); - } - - @override - void dispose() { - _logOutPopupManager.close(); - super.dispose(); + Future _showLogoutDialog() async { + await AppDialog.showWithCallback( + context: context, + width: 320, + barrierDismissible: true, + childBuilder: (closeDialog) => + LogOutPopup(onConfirm: closeDialog, onCancel: closeDialog), + ); } @override @@ -42,30 +29,27 @@ class _SettingsLogoutButtonState extends State { return InkWell( key: const Key('settings-logout-button'), onTap: () { - _logOutPopupManager.show(); + _showLogoutDialog(); }, borderRadius: BorderRadius.circular(18), child: Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(24, 20, 0, 20), child: Row( - mainAxisAlignment: - isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, children: [ Text( LocaleKeys.logOut.tr(), style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, - color: theme.custom.warningColor, - ), + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.custom.warningColor, + ), ), const SizedBox(width: 6), - Icon( - Icons.exit_to_app, - color: theme.custom.warningColor, - size: 18, - ), + Icon(Icons.exit_to_app, color: theme.custom.warningColor, size: 18), ], ), ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index a88e70d342..8df42a6fed 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -3,8 +3,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart' - show ActivationStep, AssetId; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; @@ -90,6 +89,12 @@ class _ZhtlcActivationStatusBarState extends State { }); } + Future _cancelActivation(AssetId assetId) async { + await widget.activationService.cancelActivation(assetId); + await widget.activationService.clearActivationStatus(assetId); + await _refreshStatuses(); + } + @override Widget build(BuildContext context) { // Filter out completed statuses older than 5 seconds @@ -193,16 +198,18 @@ class _ZhtlcActivationStatusBarState extends State { assetId, startTime, progressPercentage, - currentStep, + _, statusMessage, ) { return _ActivationStatusDetails( assetId: assetId, progressPercentage: progressPercentage?.toDouble() ?? 0, - currentStep: currentStep!, statusMessage: statusMessage ?? LocaleKeys.inProgress.tr(), + onCancel: () { + unawaited(_cancelActivation(assetId)); + }, ); }, ), @@ -221,14 +228,14 @@ class _ActivationStatusDetails extends StatelessWidget { const _ActivationStatusDetails({ required this.assetId, required this.progressPercentage, - required this.currentStep, required this.statusMessage, + required this.onCancel, }); final AssetId assetId; final double progressPercentage; - final ActivationStep currentStep; final String statusMessage; + final VoidCallback onCancel; @override Widget build(BuildContext context) { @@ -257,6 +264,15 @@ class _ActivationStatusDetails extends StatelessWidget { ), ), ), + Tooltip( + message: LocaleKeys.cancel.tr(), + child: IconButton( + visualDensity: VisualDensity.compact, + iconSize: 18, + onPressed: onCancel, + icon: const Icon(Icons.close_rounded), + ), + ), ], ), ); diff --git a/lib/views/wallets_manager/wallets_manager_wrapper.dart b/lib/views/wallets_manager/wallets_manager_wrapper.dart index d7ff865464..7a87d791f0 100644 --- a/lib/views/wallets_manager/wallets_manager_wrapper.dart +++ b/lib/views/wallets_manager/wallets_manager_wrapper.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; @@ -13,6 +14,7 @@ class WalletsManagerWrapper extends StatefulWidget { this.selectedWallet, this.initialHdMode = false, this.rememberMe = false, + this.onCancel, super.key = const Key('wallets-manager-wrapper'), }); @@ -21,6 +23,7 @@ class WalletsManagerWrapper extends StatefulWidget { final Wallet? selectedWallet; final bool initialHdMode; final bool rememberMe; + final VoidCallback? onCancel; @override State createState() => _WalletsManagerWrapperState(); @@ -50,6 +53,13 @@ class _WalletsManagerWrapperState extends State { padding: const EdgeInsets.only(top: 30.0), child: WalletsTypeList(onWalletTypeClick: _onWalletTypeClick), ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: _handleCancel, + ), + ), ], ); } @@ -78,4 +88,13 @@ class _WalletsManagerWrapperState extends State { _selectedWalletType = null; }); } + + void _handleCancel() { + final onCancel = widget.onCancel; + if (onCancel != null) { + onCancel(); + return; + } + Navigator.of(context).maybePop(); + } } diff --git a/lib/views/wallets_manager/widgets/custom_seed_dialog.dart b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart index f72cfad128..476a345cb9 100644 --- a/lib/views/wallets_manager/widgets/custom_seed_dialog.dart +++ b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart @@ -17,52 +17,58 @@ Future customSeedDialog(BuildContext context) async { popupManager = PopupDispatcher( context: context, - popupContent: StatefulBuilder(builder: (context, setState) { - return Container( - constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), - child: Column( - children: [ - Text( - LocaleKeys.customSeedWarningText.tr(), - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 20), - TextField( - key: const Key('custom-seed-dialog-input'), - autofocus: true, - onChanged: (String text) { - setState(() { - isConfirmed = text.trim().toLowerCase() == - LocaleKeys.customSeedIUnderstand.tr().toLowerCase(); - }); - }, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( + barrierDismissible: false, + popupContent: StatefulBuilder( + builder: (context, setState) { + return Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + children: [ + Text( + LocaleKeys.customSeedWarningText.tr(), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + TextField( + key: const Key('custom-seed-dialog-input'), + autofocus: true, + onChanged: (String text) { + setState(() { + isConfirmed = + text.trim().toLowerCase() == + LocaleKeys.customSeedIUnderstand.tr().toLowerCase(); + }); + }, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( child: UiUnderlineTextButton( - key: const Key('custom-seed-dialog-cancel-button'), - text: LocaleKeys.cancel.tr(), - onPressed: () { - setState(() => isConfirmed = false); - close(); - })), - const SizedBox(width: 12), - Flexible( - child: UiPrimaryButton( - key: const Key('custom-seed-dialog-ok-button'), - text: LocaleKeys.ok.tr(), - onPressed: !isConfirmed ? null : close, + key: const Key('custom-seed-dialog-cancel-button'), + text: LocaleKeys.cancel.tr(), + onPressed: () { + setState(() => isConfirmed = false); + close(); + }, + ), ), - ), - ], - ) - ], - ), - ); - }), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + key: const Key('custom-seed-dialog-ok-button'), + text: LocaleKeys.ok.tr(), + onPressed: !isConfirmed ? null : close, + ), + ), + ], + ), + ], + ), + ); + }, + ), ); isOpen = true; diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart index 3d732d1bea..46bec74c36 100644 --- a/lib/views/wallets_manager/widgets/wallet_creation.dart +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -12,7 +12,6 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; -import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletCreation extends StatefulWidget { @@ -45,7 +44,6 @@ class _WalletCreationState extends State { final GlobalKey _formKey = GlobalKey(); bool _eulaAndTosChecked = false; bool _inProgress = false; - bool _isHdMode = true; bool _rememberMe = false; bool _arePasswordsValid = false; @@ -169,13 +167,6 @@ class _WalletCreationState extends State { if (_isCreateButtonEnabled) _onCreate(); }, ), - const SizedBox(height: 16), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { - setState(() => _isHdMode = value); - }, - ), const SizedBox(height: 20), QuickLoginSwitch( key: const Key('checkbox-one-click-login-signup'), @@ -237,7 +228,7 @@ class _WalletCreationState extends State { widget.onCreate( name: _nameController.text.trim(), password: _passwordController.text, - walletType: _isHdMode ? WalletType.hdwallet : WalletType.iguana, + walletType: WalletType.hdwallet, rememberMe: _rememberMe, ); }); 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 4e537a8707..60ffa0b8ee 100644 --- a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -17,7 +17,7 @@ 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/views/wallets_manager/widgets/custom_seed_checkbox.dart'; -import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_import_type_dropdown.dart'; import 'package:web_dex/views/wallets_manager/widgets/wallet_rename_dialog.dart'; class WalletFileData { @@ -54,7 +54,8 @@ class _WalletImportByFileState extends State { ); final GlobalKey _formKey = GlobalKey(); bool _isObscured = true; - bool _isHdMode = false; + bool _isHdMode = true; + bool _isHdOptionEnabled = true; bool _eulaAndTosChecked = false; bool _rememberMe = false; bool _allowCustomSeed = false; @@ -145,15 +146,17 @@ class _WalletImportByFileState extends State { ), ), const SizedBox(height: 30), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { + WalletImportTypeDropdown( + selectedType: _isHdMode + ? WalletType.hdwallet + : WalletType.iguana, + isHdOptionEnabled: _isHdOptionEnabled, + onChanged: (walletType) { setState(() { - _isHdMode = value; - // Reset custom seed usage and hide toggle on HD switch - if (_isHdMode) { - _allowCustomSeed = false; - } + _isHdMode = walletType == WalletType.hdwallet; + _commonError = null; + _allowCustomSeed = false; + _showCustomSeedToggle = false; }); }, ), @@ -257,11 +260,19 @@ class _WalletImportByFileState extends State { if (!isBip39) { if (_isHdMode) { setState(() { - _commonError = LocaleKeys.walletCreationHdBip39SeedError.tr(); + _isHdMode = false; + _isHdOptionEnabled = false; + _allowCustomSeed = false; + _commonError = LocaleKeys.walletCreationBip39SeedError.tr(); _showCustomSeedToggle = true; }); return; } + if (_isHdOptionEnabled) { + setState(() { + _isHdOptionEnabled = false; + }); + } if (!_allowCustomSeed) { setState(() { _commonError = LocaleKeys.walletCreationBip39SeedError.tr(); @@ -274,6 +285,7 @@ class _WalletImportByFileState extends State { walletConfig.seedPhrase = decryptedSeed; String name = widget.fileData.name.replaceFirst(RegExp(r'\.[^.]+$'), ''); + if (!mounted) return; final walletsRepository = RepositoryProvider.of( context, ); @@ -319,8 +331,11 @@ class _WalletImportByFileState extends State { } bool get _shouldShowCustomSeedToggle { + if (_isHdMode) return false; if (_allowCustomSeed) return true; // keep visible once enabled - if (_showCustomSeedToggle) return true; // show after first failure, even in HD + if (_showCustomSeedToggle) { + return true; // show after first non-HD BIP39 failure + } return false; } } diff --git a/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart b/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart new file mode 100644 index 0000000000..781dfeccff --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_import_type_dropdown.dart @@ -0,0 +1,157 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; + +class WalletImportTypeDropdown extends StatelessWidget { + const WalletImportTypeDropdown({ + super.key, + required this.selectedType, + required this.onChanged, + this.isHdOptionEnabled = true, + }); + + final WalletType selectedType; + final ValueChanged onChanged; + final bool isHdOptionEnabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.selectWalletType.tr(), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const Key('wallet-import-type-dropdown'), + initialValue: selectedType, + isExpanded: true, + borderRadius: BorderRadius.circular(12), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + selectedItemBuilder: (context) => [ + _buildSelectedItem(context, WalletType.hdwallet), + _buildSelectedItem(context, WalletType.iguana), + ], + items: [ + DropdownMenuItem( + value: WalletType.hdwallet, + enabled: isHdOptionEnabled, + child: _WalletImportTypeMenuItem( + title: 'walletImportTypeHdLabel'.tr(), + description: 'walletImportTypeHdDescription'.tr(), + icon: Icons.account_tree_outlined, + enabled: isHdOptionEnabled, + ), + ), + DropdownMenuItem( + value: WalletType.iguana, + child: _WalletImportTypeMenuItem( + title: 'walletImportTypeLegacyLabel'.tr(), + description: 'walletImportTypeLegacyDescription'.tr(), + icon: Icons.account_balance_wallet_outlined, + ), + ), + ], + onChanged: (WalletType? value) { + if (value == null) return; + if (value == WalletType.hdwallet && !isHdOptionEnabled) return; + onChanged(value); + }, + ), + if (!isHdOptionEnabled) ...[ + const SizedBox(height: 8), + Text( + 'walletImportTypeHdDisabledHint'.tr(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ); + } + + Widget _buildSelectedItem(BuildContext context, WalletType type) { + final bool isHdType = type == WalletType.hdwallet; + return Align( + alignment: Alignment.centerLeft, + child: Text( + isHdType + ? 'walletImportTypeHdLabel'.tr() + : 'walletImportTypeLegacyLabel'.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } +} + +class _WalletImportTypeMenuItem extends StatelessWidget { + const _WalletImportTypeMenuItem({ + required this.title, + required this.description, + required this.icon, + this.enabled = true, + }); + + final String title; + final String description; + final IconData icon; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final titleColor = enabled + ? theme.textTheme.bodyLarge?.color + : theme.disabledColor; + final descriptionColor = enabled + ? theme.textTheme.bodySmall?.color + : theme.disabledColor; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: titleColor), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + const SizedBox(height: 2), + Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: descriptionColor, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_list_item.dart b/lib/views/wallets_manager/widgets/wallet_list_item.dart index 7c8ebb4b6c..908727bdf0 100644 --- a/lib/views/wallets_manager/widgets/wallet_list_item.dart +++ b/lib/views/wallets_manager/widgets/wallet_list_item.dart @@ -1,60 +1,133 @@ import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:intl/intl.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/wallets_manager_models.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; class WalletListItem extends StatelessWidget { const WalletListItem({Key? key, required this.wallet, required this.onClick}) - : super(key: key); + : super(key: key); final Wallet wallet; final void Function(Wallet, WalletsManagerExistWalletAction) onClick; @override Widget build(BuildContext context) { + final theme = Theme.of(context); return UiPrimaryButton( - backgroundColor: Theme.of(context).cardColor, + backgroundColor: theme.cardColor, text: wallet.name, prefix: DecoratedBox( decoration: const BoxDecoration(shape: BoxShape.circle), child: Icon( Icons.person, size: 21, - color: Theme.of(context).textTheme.labelLarge?.color, + color: theme.textTheme.labelLarge?.color, ), ), - height: 40, - // backgroundColor: Theme.of(context).colorScheme.onSurface, + height: 68, onPressed: () => onClick(wallet, WalletsManagerExistWalletAction.logIn), - child: Row( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ DecoratedBox( decoration: const BoxDecoration(shape: BoxShape.circle), child: Icon( Icons.person, size: 21, - color: Theme.of(context).textTheme.labelLarge?.color, + color: theme.textTheme.labelLarge?.color, ), ), const SizedBox(width: 8), Expanded( - child: Text( - wallet.name, - // style: DefaultTextStyle.of(context).style.copyWith( - // fontWeight: FontWeight.w500, - // fontSize: 14, - // ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + wallet.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _MetaTag(label: _walletTypeLabel(wallet.config.type)), + _MetaTag( + label: _walletProvenanceLabel(wallet.config.provenance), + ), + _MetaTag( + label: _walletCreatedLabel(wallet.config.createdAt), + ), + ], + ), + ], ), ), IconButton( onPressed: () => onClick(wallet, WalletsManagerExistWalletAction.delete), - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + tooltip: LocaleKeys.delete.tr(), ), ], ), ); } + + String _walletTypeLabel(WalletType type) { + return switch (type) { + WalletType.hdwallet => 'HD', + WalletType.iguana => 'Iguana', + WalletType.trezor => 'Trezor', + WalletType.metamask => 'MetaMask', + WalletType.keplr => 'Keplr', + }; + } + + String _walletProvenanceLabel(WalletProvenance provenance) { + return switch (provenance) { + WalletProvenance.generated => 'Generated', + WalletProvenance.imported => 'Imported', + WalletProvenance.unknown => LocaleKeys.unknown.tr(), + }; + } + + String _walletCreatedLabel(DateTime? createdAt) { + if (createdAt == null) return LocaleKeys.unknown.tr(); + return DateFormat('yyyy-MM-dd').format(createdAt); + } +} + +class _MetaTag extends StatelessWidget { + const _MetaTag({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.4), + ), + color: theme.colorScheme.surface.withValues(alpha: 0.35), + ), + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith(fontSize: 11), + ), + ); + } } diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index 21fbc275b3..d80d596d05 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -14,6 +14,7 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/hd_wallet_mode_preference.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/hdwallet_mode_switch.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; @@ -44,12 +45,16 @@ class _WalletLogInState extends State { late bool _isHdMode; bool _isQuickLoginEnabled = false; KdfUser? _user; + bool? _storedHdPreference; @override void initState() { super.initState(); - _isHdMode = widget.initialHdMode; + _isHdMode = + widget.initialHdMode || + widget.wallet.config.type == WalletType.hdwallet; _isQuickLoginEnabled = widget.initialQuickLogin; + _loadHdModePreference(); unawaited(_fetchKdfUser()); } @@ -61,13 +66,25 @@ class _WalletLogInState extends State { ); if (user != null) { + final fallbackHdMode = + widget.initialHdMode || + user.wallet.config.type == WalletType.hdwallet; setState(() { _user = user; - _isHdMode = user.wallet.config.type == WalletType.hdwallet; + _isHdMode = _storedHdPreference ?? fallbackHdMode; }); } } + Future _loadHdModePreference() async { + final storedPreference = await readHdWalletModePreference(widget.wallet.id); + if (!mounted || storedPreference == null) return; + setState(() { + _storedHdPreference = storedPreference; + _isHdMode = storedPreference; + }); + } + @override void dispose() { _passwordController.dispose(); @@ -147,6 +164,9 @@ class _WalletLogInState extends State { value: _isHdMode, onChanged: (value) { setState(() => _isHdMode = value); + unawaited( + storeHdWalletModePreference(widget.wallet.id, value), + ); }, ), const SizedBox(height: 24), @@ -286,14 +306,18 @@ class _PasswordTextFieldState extends State { // Find common prefix int start = 0; - while (start < before.length && start < after.length && before[start] == after[start]) { + while (start < before.length && + start < after.length && + before[start] == after[start]) { start++; } // Find common suffix int endBefore = before.length - 1; int endAfter = after.length - 1; - while (endBefore >= start && endAfter >= start && before[endBefore] == after[endAfter]) { + while (endBefore >= start && + endAfter >= start && + before[endBefore] == after[endAfter]) { endBefore--; endAfter--; } diff --git a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart index 1b326a07f0..3a82a9898f 100644 --- a/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart +++ b/lib/views/wallets_manager/widgets/wallet_rename_dialog.dart @@ -20,6 +20,7 @@ Future walletRenameDialog( final result = await AppDialog.show( context: context, width: isMobile ? null : 360, + barrierDismissible: false, child: _WalletRenameContent( controller: controller, walletsRepository: walletsRepository, diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart index 92786dffa5..476ce43e1e 100644 --- a/lib/views/wallets_manager/widgets/wallet_simple_import.dart +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -18,7 +20,7 @@ import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/widgets/quick_login_switch.dart'; import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.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/views/wallets_manager/widgets/wallet_import_type_dropdown.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletSimpleImport extends StatefulWidget { @@ -49,6 +51,7 @@ class WalletSimpleImport extends StatefulWidget { enum WalletSimpleImportSteps { nameAndSeed, password } class _WalletImportWrapperState extends State { + static const int _maxSeedSuggestions = 8; WalletSimpleImportSteps _step = WalletSimpleImportSteps.nameAndSeed; final TextEditingController _nameController = TextEditingController(text: ''); final TextEditingController _seedController = TextEditingController(text: ''); @@ -60,8 +63,12 @@ class _WalletImportWrapperState extends State { bool _eulaAndTosChecked = false; bool _inProgress = false; bool _allowCustomSeed = false; - bool _isHdMode = false; + bool _isHdMode = true; bool _rememberMe = false; + List _bip39Words = const []; + List _seedWordSuggestions = const []; + int _activeWordStart = -1; + int _activeWordEnd = -1; bool get _isButtonEnabled { final isFormValid = _refreshFormValidationState(); @@ -152,13 +159,108 @@ class _WalletImportWrapperState extends State { void initState() { super.initState(); _seedController.addListener(_onSeedChanged); + unawaited(_loadBip39Wordlist()); } void _onSeedChanged() { - // Rebuild to update custom seed toggle visibility as user types + _updateSeedWordSuggestions(); + _syncWalletTypeWithSeedCompatibility(); setState(() {}); } + Future _loadBip39Wordlist() async { + try { + final wordlist = await rootBundle.loadString( + 'packages/komodo_defi_types/assets/bip-0039/english-wordlist.txt', + ); + final words = wordlist + .split('\n') + .map((word) => word.trim().toLowerCase()) + .where((word) => word.isNotEmpty) + .toList(growable: false); + + if (!mounted) return; + setState(() { + _bip39Words = words; + _updateSeedWordSuggestions(); + }); + } catch (_) { + // Suggestions are a progressive enhancement; import still works without + // the wordlist if asset loading fails. + } + } + + void _clearSeedWordSuggestions() { + _seedWordSuggestions = const []; + _activeWordStart = -1; + _activeWordEnd = -1; + } + + void _updateSeedWordSuggestions() { + if (_allowCustomSeed || _isSeedHidden || _bip39Words.isEmpty) { + _clearSeedWordSuggestions(); + return; + } + + final text = _seedController.text.toLowerCase(); + final cursor = _seedController.selection.baseOffset; + if (cursor < 0 || cursor > text.length) { + _clearSeedWordSuggestions(); + return; + } + + int start = cursor; + while (start > 0 && text[start - 1] != ' ') { + start--; + } + + int end = cursor; + while (end < text.length && text[end] != ' ') { + end++; + } + + final prefix = text.substring(start, cursor).trim(); + if (prefix.isEmpty || !RegExp(r'^[a-z]+$').hasMatch(prefix)) { + _clearSeedWordSuggestions(); + return; + } + + final suggestions = _bip39Words + .where((word) => word.startsWith(prefix)) + .take(_maxSeedSuggestions) + .toList(growable: false); + + if (suggestions.length == 1 && suggestions.first == prefix) { + _clearSeedWordSuggestions(); + return; + } + + _seedWordSuggestions = suggestions; + _activeWordStart = start; + _activeWordEnd = end; + } + + void _onSeedSuggestionSelected(String suggestion) { + if (_activeWordStart < 0 || _activeWordEnd < _activeWordStart) return; + + var nextText = _seedController.text.replaceRange( + _activeWordStart, + _activeWordEnd, + suggestion, + ); + var nextCursor = _activeWordStart + suggestion.length; + + if (nextCursor == nextText.length || nextText[nextCursor] != ' ') { + nextText = nextText.replaceRange(nextCursor, nextCursor, ' '); + nextCursor += 1; + } + + _seedController.value = TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: nextCursor), + ); + } + @override void dispose() { _nameController.dispose(); @@ -173,6 +275,7 @@ class _WalletImportWrapperState extends State { onChanged: (value) { setState(() { _allowCustomSeed = value; + _updateSeedWordSuggestions(); }); _refreshFormValidationState(); @@ -252,15 +355,34 @@ class _WalletImportWrapperState extends State { _buildNameField(), const SizedBox(height: 16), _buildSeedField(), + if (_seedWordSuggestions.isNotEmpty) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _seedWordSuggestions + .map( + (word) => ActionChip( + label: Text(word), + onPressed: () => _onSeedSuggestionSelected(word), + ), + ) + .toList(), + ), + ), + ], const SizedBox(height: 16), - HDWalletModeSwitch( - value: _isHdMode, - onChanged: (value) { + WalletImportTypeDropdown( + selectedType: _isHdMode ? WalletType.hdwallet : WalletType.iguana, + isHdOptionEnabled: _isHdCompatibleWithCurrentSeed, + onChanged: (walletType) { setState(() { - _isHdMode = value; + _isHdMode = walletType == WalletType.hdwallet; _allowCustomSeed = false; + _updateSeedWordSuggestions(); }); - _refreshFormValidationState(); }, ), @@ -317,6 +439,7 @@ class _WalletImportWrapperState extends State { onVisibilityChange: (bool isObscured) { setState(() { _isSeedHidden = isObscured; + _updateSeedWordSuggestions(); }); }, ), @@ -427,9 +550,7 @@ class _WalletImportWrapperState extends State { MnemonicFailedReason.invalidChecksum => LocaleKeys.mnemonicInvalidChecksumError.tr(), MnemonicFailedReason.invalidLength => - // TODO: Specify the valid lengths since not all lengths between 12 and - // 24 are valid - LocaleKeys.mnemonicInvalidLengthError.tr(args: ['12', '24']), + LocaleKeys.mnemonicInvalidLengthError.tr(), }; } @@ -444,4 +565,32 @@ class _WalletImportWrapperState extends State { final isBip39 = validator.validateBip39(seed); return !isBip39; } + + void _syncWalletTypeWithSeedCompatibility() { + if (_isHdMode && !_isHdCompatibleWithCurrentSeed) { + _isHdMode = false; + _allowCustomSeed = false; + } + } + + bool get _isHdCompatibleWithCurrentSeed { + final seed = _seedController.text.trim().toLowerCase(); + if (seed.isEmpty) return true; + + final words = seed.split(RegExp(r'\s+')).where((word) => word.isNotEmpty); + final int wordCount = words.length; + if (wordCount == 1) { + final token = words.first; + final bool looksLikeBip39Word = + RegExp(r'^[a-z]+$').hasMatch(token) && token.length <= 8; + return looksLikeBip39Word; + } + + if (wordCount < 12) { + return true; + } + + final validator = context.read().mnemonicValidator; + return validator.validateBip39(seed); + } }