Files
peyawallet/lib/state/wallet_controller.dart
T
2026-04-06 20:58:42 +02:00

382 lines
11 KiB
Dart

import 'dart:io';
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/transactions.dart';
import '../services/wallet_cache_recovery.dart';
class WalletState {
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.isSyncing,
required this.error,
});
final WalletInfo? walletInfo;
final int balanceAtomic;
final int unlockedAtomic;
final List<WalletTransaction> transactions;
final List<SubaddressInfo> subaddresses;
final SyncStatus syncStatus;
final bool isLoading;
final bool isSyncing;
final String? error;
WalletState copyWith({
WalletInfo? walletInfo,
int? balanceAtomic,
int? unlockedAtomic,
List<WalletTransaction>? transactions,
List<SubaddressInfo>? subaddresses,
SyncStatus? syncStatus,
bool? isLoading,
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,
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,
isSyncing: false,
error: null,
);
}
}
class WalletController extends StateNotifier<WalletState> {
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<void> tryAutoOpen(AppConfig config) async {
final wallet = config.lastWallet;
if (wallet == null) {
return;
}
final resolvedPath = _resolveOpenPath(wallet.path);
if (_isDirectoryPath(resolvedPath)) {
state = state.copyWith(error: 'Wallet file not found in ${wallet.path}');
return;
}
if (!File(resolvedPath).existsSync() && !File('$resolvedPath.keys').existsSync()) {
state = state.copyWith(error: 'Wallet not found at ${wallet.path}');
return;
}
await _cacheRecovery.recoverIfNeeded(resolvedPath);
await openWallet(path: resolvedPath, password: '');
}
Future<void> createWallet({
required String name,
required String path,
required String password,
}) async {
await _runWithLoading(() async {
await Directory(path).create(recursive: true);
final resolvedPath = _resolveWalletFilePath(path, name: name);
await _cacheRecovery.markOpenAttempt(resolvedPath);
await _repository.createWallet(name: name, path: path, password: password);
try {
await _postOpen(name: name, path: resolvedPath);
} finally {
await _cacheRecovery.clearOpenAttempt();
}
});
}
Future<void> restoreWalletFromSeed({
required String name,
required String seed,
required String path,
required String password,
}) async {
await _runWithLoading(() async {
await Directory(path).create(recursive: true);
final resolvedPath = _resolveWalletFilePath(path, name: name);
await _cacheRecovery.markOpenAttempt(resolvedPath);
await _repository.restoreWalletFromSeed(
name: name,
seed: seed,
path: path,
password: password,
);
try {
await _postOpen(name: name, path: resolvedPath);
} finally {
await _cacheRecovery.clearOpenAttempt();
}
});
}
Future<void> openWallet({
required String path,
required String password,
}) async {
await _runWithLoading(() async {
final resolvedPath = _resolveOpenPath(path);
await _cacheRecovery.recoverIfNeeded(resolvedPath);
await _cacheRecovery.markOpenAttempt(resolvedPath);
try {
await _repository.openWallet(path: resolvedPath, password: password);
await _postOpen(name: p.basename(resolvedPath), path: resolvedPath);
} finally {
await _cacheRecovery.clearOpenAttempt();
}
});
}
Future<void> connectNode(NodeConfig nodeConfig) async {
await _runWithLoading(() async {
await _repository.connectNode(nodeConfig);
});
}
Future<void> switchWallet() async {
await _repository.closeWallet();
await _cacheRecovery.clearOpenAttempt();
state = WalletState.initial();
}
Future<String> getSeed() async {
if (state.walletInfo == null) {
throw StateError('No wallet loaded');
}
return _repository.getSeed();
}
Future<String> createSubaddress({String? label}) async {
if (state.walletInfo == null) {
throw StateError('No wallet loaded');
}
return _runWithLoading(() => _repository.createSubaddress(label: label));
}
Future<void> 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,
);
});
await refreshSubaddresses();
}
Future<void> refreshSubaddresses() async {
if (state.walletInfo == null) {
return;
}
final subaddresses = await _repository.getSubaddresses();
state = state.copyWith(subaddresses: subaddresses);
}
Future<SendPreview> prepareSend(SendRequest request) async {
if (state.walletInfo == null) {
throw StateError('No wallet loaded');
}
return _runWithLoading(() => _repository.prepareSend(request));
}
Future<SendResult> commitPreparedSend() async {
if (state.walletInfo == null) {
throw StateError('No wallet loaded');
}
return _runWithLoading(() async {
final result = await _repository.commitPreparedSend();
await _refreshSnapshot();
return result;
});
}
Future<void> discardPreparedSend() async {
await _repository.discardPreparedSend();
}
Future<void> syncNow() async {
if (state.isSyncing || state.walletInfo == null) {
return;
}
state = state.copyWith(isSyncing: true);
try {
await _ensureConnected();
await _refreshSnapshot();
} catch (error, stack) {
_logger.e('Sync failed', error: error, stackTrace: stack);
state = state.copyWith(error: error.toString());
} finally {
state = state.copyWith(isSyncing: false);
}
}
Future<void> 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<void> _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<void> _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,
error: null,
);
}
Future<WalletSnapshot> _refreshSnapshot() async {
final snapshot = await _repository.refresh();
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 == null
? null
: 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<T> _runWithLoading<T>(Future<T> Function() action) async {
state = state.copyWith(isLoading: true, error: null);
try {
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);
}
}
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<File>()
.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();
}
}