import 'dart:io'; import 'package:flutter/scheduler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:path/path.dart' as p; import '../data/wallet_repository.dart'; import '../domain/models.dart'; import '../domain/send.dart'; import '../domain/staking.dart'; import '../domain/transactions.dart'; import '../services/wallet_cache_recovery.dart'; class WalletState { static const Object _sentinel = Object(); const WalletState({ required this.walletInfo, required this.balanceAtomic, required this.unlockedAtomic, required this.transactions, required this.subaddresses, required this.syncStatus, required this.isLoading, required this.isBlockingOperation, required this.operationMessage, required this.isSyncing, required this.error, }); final WalletInfo? walletInfo; final int balanceAtomic; final int unlockedAtomic; final List transactions; final List subaddresses; final SyncStatus syncStatus; final bool isLoading; final bool isBlockingOperation; final String? operationMessage; final bool isSyncing; final String? error; WalletState copyWith({ WalletInfo? walletInfo, int? balanceAtomic, int? unlockedAtomic, List? transactions, List? subaddresses, SyncStatus? syncStatus, bool? isLoading, bool? isBlockingOperation, Object? operationMessage = _sentinel, bool? isSyncing, String? error, }) { return WalletState( walletInfo: walletInfo ?? this.walletInfo, balanceAtomic: balanceAtomic ?? this.balanceAtomic, unlockedAtomic: unlockedAtomic ?? this.unlockedAtomic, transactions: transactions ?? this.transactions, subaddresses: subaddresses ?? this.subaddresses, syncStatus: syncStatus ?? this.syncStatus, isLoading: isLoading ?? this.isLoading, isBlockingOperation: isBlockingOperation ?? this.isBlockingOperation, operationMessage: identical(operationMessage, _sentinel) ? this.operationMessage : operationMessage as String?, isSyncing: isSyncing ?? this.isSyncing, error: error, ); } factory WalletState.initial() { return WalletState( walletInfo: null, balanceAtomic: 0, unlockedAtomic: 0, transactions: const [], subaddresses: const [], syncStatus: SyncStatus.initial(), isLoading: false, isBlockingOperation: false, operationMessage: null, isSyncing: false, error: null, ); } } class WalletController extends StateNotifier { WalletController({ required WalletRepository repository, required Logger logger, required AppConfig Function() readConfig, }) : _repository = repository, _logger = logger, _readConfig = readConfig, _cacheRecovery = WalletCacheRecovery(logger: logger), super(WalletState.initial()); final WalletRepository _repository; final Logger _logger; final AppConfig Function() _readConfig; final WalletCacheRecovery _cacheRecovery; Future tryAutoOpen(AppConfig config) async { final wallet = config.lastWallet; if (wallet == null) { return false; } final resolvedPath = _resolveOpenPath(wallet.path); if (_isDirectoryPath(resolvedPath)) { state = state.copyWith(error: 'Wallet file not found in ${wallet.path}'); return false; } if (!File(resolvedPath).existsSync() && !File('$resolvedPath.keys').existsSync()) { state = state.copyWith(error: 'Wallet not found at ${wallet.path}'); return false; } if (!await verifyWalletPassword(path: resolvedPath, password: '')) { return true; } await _cacheRecovery.recoverIfNeeded(resolvedPath); await openWallet(path: resolvedPath, password: ''); return false; } Future createWallet({ required String name, required String path, required String password, }) async { await _runWithLoading(() async { _updateOperationStatus('Preparing wallet directory...'); await Directory(path).create(recursive: true); final resolvedPath = _resolveWalletFilePath(path, name: name); _updateOperationStatus('Creating wallet...'); await _cacheRecovery.markOpenAttempt(resolvedPath); await _repository.createWallet( name: name, path: path, password: password); try { _updateOperationStatus('Loading wallet data...'); await _postOpen(name: name, path: resolvedPath); } finally { await _cacheRecovery.clearOpenAttempt(); } }, message: 'Creating wallet...'); } Future prepareWalletSeedDraft({ required String name, required String password, }) async { return _runWithLoading(() async { _updateOperationStatus('Preparing temporary wallet...'); final tempDir = await Directory.systemTemp.createTemp('peyawallet-draft-'); try { _updateOperationStatus('Generating seed phrase...'); await _repository.createWallet( name: name, path: tempDir.path, password: password, ); final seed = await _repository.getSeed(); await _repository.closeWallet(); return seed; } finally { try { await _repository.closeWallet(); } catch (_) {} if (tempDir.existsSync()) { await tempDir.delete(recursive: true); } } }, message: 'Preparing seed backup...'); } Future restoreWalletFromSeed({ required String name, required String seed, required String path, required String password, }) async { await _runWithLoading(() async { _updateOperationStatus('Preparing wallet directory...'); await Directory(path).create(recursive: true); final resolvedPath = _resolveWalletFilePath(path, name: name); _updateOperationStatus('Restoring wallet from seed...'); await _cacheRecovery.markOpenAttempt(resolvedPath); await _repository.restoreWalletFromSeed( name: name, seed: seed, path: path, password: password, ); try { _updateOperationStatus('Loading wallet data...'); await _postOpen(name: name, path: resolvedPath); } finally { await _cacheRecovery.clearOpenAttempt(); } }, message: 'Restoring wallet...'); } Future openWallet({ required String path, required String password, }) async { await _runWithLoading(() async { final resolvedPath = _resolveOpenPath(path); _updateOperationStatus('Checking wallet files...'); await _cacheRecovery.recoverIfNeeded(resolvedPath); _updateOperationStatus('Opening wallet...'); await _cacheRecovery.markOpenAttempt(resolvedPath); try { await _repository.openWallet(path: resolvedPath, password: password); _updateOperationStatus('Loading wallet data...'); await _postOpen(name: p.basename(resolvedPath), path: resolvedPath); } finally { await _cacheRecovery.clearOpenAttempt(); } }, message: 'Opening wallet...'); } Future verifyWalletPassword({ required String path, required String password, }) async { final resolvedPath = _resolveOpenPath(path); return _repository.verifyWalletPassword( path: resolvedPath, password: password, ); } Future walletRequiresPassword(String path) async { return !await verifyWalletPassword(path: path, password: ''); } Future connectNode(NodeConfig nodeConfig) async { await _runWithLoading(() async { await _repository.connectNode(nodeConfig); }, blocking: false, message: 'Connecting to node...'); } Future switchWallet() async { await _repository.closeWallet(); await _cacheRecovery.clearOpenAttempt(); state = WalletState.initial(); } Future getSeed() async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _repository.getSeed(); } Future createSubaddress({String? label}) async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _runWithLoading( () => _repository.createSubaddress(label: label), blocking: false, message: 'Creating subaddress...', ); } Future setSubaddressLabel({ required int accountIndex, required int addressIndex, required String label, }) async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } await _runWithLoading(() { return _repository.setSubaddressLabel( accountIndex: accountIndex, addressIndex: addressIndex, label: label, ); }, blocking: false, message: 'Updating subaddress...'); await refreshSubaddresses(); } Future refreshSubaddresses() async { if (state.walletInfo == null) { return; } final subaddresses = await _repository.getSubaddresses(); state = state.copyWith(subaddresses: subaddresses); } Future prepareSend(SendRequest request) async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _runWithLoading( () => _repository.prepareSend(request), blocking: false, message: 'Preparing transaction...', ); } Future commitPreparedSend() async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _runWithLoading(() async { final result = await _repository.commitPreparedSend(); await _refreshSnapshot(); return result; }, blocking: false, message: 'Submitting transaction...'); } Future discardPreparedSend() async { await _repository.discardPreparedSend(); } Future prepareStake({ required int amountAtomic, int accountIndex = 0, }) async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _runWithLoading(() { return _repository.prepareStake( amountAtomic: amountAtomic, accountIndex: accountIndex, ); }, blocking: false, message: 'Preparing stake...'); } Future> getActiveStakeYields({ required List activeStakes, required int currentHeight, }) async { if (state.walletInfo == null || activeStakes.isEmpty) { return const {}; } return _repository.getActiveStakeYields( activeStakes: activeStakes, currentHeight: currentHeight, ); } Future commitPreparedStake() async { if (state.walletInfo == null) { throw StateError('No wallet loaded'); } return _runWithLoading(() async { final result = await _repository.commitPreparedStake(); await _refreshSnapshot(); return result; }, blocking: false, message: 'Submitting stake...'); } Future discardPreparedStake() async { await _repository.discardPreparedStake(); } Future syncNow({bool announceStages = false}) async { if (state.isSyncing || state.walletInfo == null) { return; } state = state.copyWith(isSyncing: true); try { await _ensureConnected(); await _refreshSnapshot(announceStages: announceStages); } catch (error, stack) { _logger.e('Sync failed', error: error, stackTrace: stack); state = state.copyWith(error: error.toString()); } finally { if (announceStages) { _updateOperationStatus(null); } state = state.copyWith(isSyncing: false); } } Future rescanBlockchain({int? fromHeight}) async { if (state.walletInfo == null) { return; } state = state.copyWith(isSyncing: true, error: null); try { await _ensureConnected(); await _repository.rescanBlockchain(fromHeight: fromHeight); await _refreshSnapshot(); } catch (error, stack) { _logger.e('Rescan failed', error: error, stackTrace: stack); state = state.copyWith(error: error.toString()); } finally { state = state.copyWith(isSyncing: false); } } Future _ensureConnected() async { if (await _repository.isConnected()) { return; } final config = _readConfig(); await _repository.connectNode(config.nodeConfig); if (!await _repository.isConnected()) { throw StateError('Failed to connect to node'); } } Future _postOpen({required String name, required String path}) async { final address = await _repository.getAddress(); final subaddresses = await _repository.getSubaddresses(); state = state.copyWith( walletInfo: WalletInfo(name: name, path: path, address: address), balanceAtomic: 0, syncStatus: SyncStatus.initial(), transactions: const [], subaddresses: subaddresses, operationMessage: null, error: null, ); } Future _refreshSnapshot({bool announceStages = false}) async { if (announceStages) { _updateOperationStatus('Refreshing balances...'); } final snapshot = await _repository.refresh(); if (announceStages) { _updateOperationStatus('Loading transactions...'); } final transactions = await _repository.getTransactions(); final syncStatus = SyncStatus( synced: snapshot.synced, progress: snapshot.progress, nodeHeight: snapshot.nodeHeight, walletHeight: snapshot.walletHeight, lastSync: DateTime.now(), ); final updatedWalletInfo = state.walletInfo?.copyWith(address: snapshot.address); state = state.copyWith( walletInfo: updatedWalletInfo ?? state.walletInfo, balanceAtomic: snapshot.balanceAtomic, unlockedAtomic: snapshot.unlockedAtomic, transactions: transactions, subaddresses: snapshot.subaddresses.isEmpty ? state.subaddresses : snapshot.subaddresses, syncStatus: syncStatus, error: null, ); return snapshot; } Future _runWithLoading( Future Function() action, { bool blocking = true, String? message, }) async { state = state.copyWith( isLoading: true, isBlockingOperation: blocking, operationMessage: message, error: null, ); try { await SchedulerBinding.instance.endOfFrame; return await action(); } catch (error, stack) { _logger.e('Wallet action failed', error: error, stackTrace: stack); state = state.copyWith(error: error.toString()); rethrow; } finally { state = state.copyWith( isLoading: false, isBlockingOperation: false, operationMessage: null, ); } } void _updateOperationStatus(String? message) { state = state.copyWith( operationMessage: message, isBlockingOperation: false, ); } String _resolveWalletFilePath(String basePath, {required String name}) { if (basePath.isEmpty) { return basePath; } if (_isDirectoryPath(basePath)) { return p.join(basePath, name); } return basePath; } String _resolveOpenPath(String basePath) { if (!_isDirectoryPath(basePath)) { return basePath; } final dirName = p.basename(basePath); final candidate = p.join(basePath, dirName); if (File(candidate).existsSync() || File('$candidate.keys').existsSync()) { return candidate; } final dir = Directory(basePath); if (!dir.existsSync()) { return basePath; } final entries = dir .listSync() .whereType() .map((entry) => entry.path) .where((entryPath) => !entryPath.endsWith('.keys')) .toList() ..sort(); for (final entryPath in entries) { if (File('$entryPath.keys').existsSync()) { return entryPath; } } return basePath; } bool _isDirectoryPath(String path) { if (path.endsWith(p.separator)) { return true; } return Directory(path).existsSync(); } }