import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart'; import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; part 'home_screen_event.dart'; part 'home_screen_state.dart'; class HomeScreenBloc extends Bloc { HomeScreenBloc({ required ClientSyncUseCase clientSyncUseCase, required GameInstallationService gameInstallationService, required LauncherApiClient launcherApiClient, required PreferencesRepository preferencesRepository, required LauncherSession session, required ClientManifest manifest, Duration manifestRefreshInterval = const Duration(minutes: 5), Duration realmRefreshInterval = const Duration(minutes: 1), }) : _clientSyncUseCase = clientSyncUseCase, _gameInstallationService = gameInstallationService, _launcherApiClient = launcherApiClient, _preferencesRepository = preferencesRepository, _session = session, _startupManifest = manifest, super( HomeScreenState( model: HomeScreenModel.initial().copyWith( isAuthenticated: true, remoteBuildHash: manifest.buildHash, ), ), ) { on(_onHomeScreenLoad); on(_onHomeScreenSyncRequested); on( _onHomeScreenManifestRefreshRequested, ); on(_onHomeScreenRealmRefreshRequested); on(_onHomeScreenAccountLoadRequested); on(_onHomeScreenOutputDirRequested); on(_onHomeScreenPauseRequested); on(_onHomeScreenPlayRequested); on(_onHomeScreenGameExited); on(_onHomeScreenLogoutRequested); on(_onHomeScreenSyncStatusChanged); on(_onHomeScreenSyncFailed); add(HomeScreenLoad()); _manifestRefreshTimer = Timer.periodic( manifestRefreshInterval, (_) => add(HomeScreenManifestRefreshRequested()), ); _realmRefreshTimer = Timer.periodic( realmRefreshInterval, (_) => add(HomeScreenRealmRefreshRequested()), ); } final ClientSyncUseCase _clientSyncUseCase; final GameInstallationService _gameInstallationService; final LauncherApiClient _launcherApiClient; final PreferencesRepository _preferencesRepository; StreamSubscription? _syncSubscription; final LauncherSession _session; final ClientManifest _startupManifest; Timer? _manifestRefreshTimer; Timer? _realmRefreshTimer; int? _activeGamePid; bool _pauseRequested = false; bool _gameLaunchPending = false; Future _onHomeScreenLoad( HomeScreenLoad event, Emitter emit, ) async { final outputPath = await _preferencesRepository.getOutputDir(); final hasClientExecutable = await _resolveExecutablePresence(outputPath); emit( HomeScreenState( model: state.model.copyWith( outputPath: outputPath, hasClientExecutable: hasClientExecutable, phase: _stablePhase(hasClientExecutable), statusText: _buildIdleStatus( isAuthenticated: state.model.isAuthenticated, outputPath: outputPath, hasClientExecutable: hasClientExecutable, ), ), ), ); if (outputPath != null) { add(HomeScreenSyncRequested(manifest: _startupManifest)); } add(HomeScreenRealmRefreshRequested()); add(HomeScreenAccountLoadRequested()); try { final newsItems = await _launcherApiClient.fetchNews( _session.accessToken, ); emit( HomeScreenState( model: state.model.copyWith( newsItems: newsItems, isLoadingNews: false, newsErrorMessage: null, ), ), ); } catch (error) { emit( HomeScreenState( model: state.model.copyWith( isLoadingNews: false, newsErrorMessage: _formatError(error), ), ), ); } } Future _onHomeScreenAccountLoadRequested( HomeScreenAccountLoadRequested event, Emitter emit, ) async { if (!state.model.isAuthenticated) { return; } try { final account = await _launcherApiClient.fetchAccount( _session.accessToken, ); if (!state.model.isAuthenticated) { return; } emit( HomeScreenState( model: state.model.copyWith( accountUsername: account.username.isEmpty ? null : account.username, ), ), ); } catch (_) { // Account metadata does not block patching or launching the game. } } Future _onHomeScreenRealmRefreshRequested( HomeScreenRealmRefreshRequested event, Emitter emit, ) async { if (!state.model.isAuthenticated) { return; } try { final realms = await _launcherApiClient.fetchRealms(); if (!state.model.isAuthenticated) { return; } emit( HomeScreenState( model: state.model.copyWith( realm: realms.isEmpty ? null : realms.first, ), ), ); } catch (_) { // Keep the last known realm state and retry on the next timer tick. } } Future _onHomeScreenManifestRefreshRequested( HomeScreenManifestRefreshRequested event, Emitter emit, ) async { if (!state.model.isAuthenticated || state.model.isSyncing || state.model.isGameRunning || state.model.phase == HomeScreenPhase.paused || state.model.outputPath == null) { return; } try { final manifest = await _launcherApiClient.fetchManifest( _session.accessToken, ); if (state.model.isSyncing || !state.model.isAuthenticated) { return; } if (manifest.buildHash == state.model.localBuildHash) { if (manifest.buildHash != state.model.remoteBuildHash) { emit( HomeScreenState( model: state.model.copyWith(remoteBuildHash: manifest.buildHash), ), ); } return; } add(HomeScreenSyncRequested(manifest: manifest)); } catch (_) { // Retry on the next timer tick without replacing the stable UI state. } } Future _onHomeScreenSyncRequested( HomeScreenSyncRequested event, Emitter emit, ) async { if (state.model.isSyncing || state.model.isGameRunning) { return; } final outputPath = state.model.outputPath; if (outputPath == null) { add(HomeScreenOutputDirRequested()); return; } await _syncSubscription?.cancel(); _pauseRequested = false; emit( HomeScreenState( model: state.model.copyWith( phase: HomeScreenPhase.syncing, progress: const DownloadProgress.initial(), statusText: 'Подготавливаю проверку клиента...', currentPath: null, errorMessage: null, processedFiles: 0, totalFiles: 0, syncStage: null, ), ), ); _syncSubscription = _clientSyncUseCase .call( ClientSyncUseCaseInput( request: ClientSyncRequest( installationDir: outputPath.toFilePath(), accessToken: _session.accessToken, manifest: event.manifest, ), isCancelled: () async => _pauseRequested, ), ) .listen( (status) => add(HomeScreenSyncStatusChanged(status)), onError: (error, _) => add(HomeScreenSyncFailed(error)), ); } Future _onHomeScreenOutputDirRequested( HomeScreenOutputDirRequested event, Emitter emit, ) async { if (state.model.isSyncing) { return; } final directory = await FilePicker.getDirectoryPath( dialogTitle: 'Выберите папку установки Moonwell', ); if (directory == null) { return; } final outputPath = Uri.directory(directory); await _preferencesRepository.setOutputDir(outputPath); final hasClientExecutable = await _resolveExecutablePresence(outputPath); emit( HomeScreenState( model: state.model.copyWith( outputPath: outputPath, hasClientExecutable: hasClientExecutable, phase: _stablePhase(hasClientExecutable), statusText: _buildIdleStatus( isAuthenticated: state.model.isAuthenticated, outputPath: outputPath, hasClientExecutable: hasClientExecutable, ), errorMessage: null, ), ), ); add(HomeScreenSyncRequested()); } Future _onHomeScreenPauseRequested( HomeScreenPauseRequested event, Emitter emit, ) async { if (!state.model.isSyncing) { return; } _pauseRequested = true; emit( HomeScreenState( model: state.model.copyWith( statusText: 'Останавливаю обновление...', errorMessage: null, ), ), ); } Future _onHomeScreenPlayRequested( HomeScreenPlayRequested event, Emitter emit, ) async { if (_gameLaunchPending || state.model.isGameRunning) { return; } final outputPath = state.model.outputPath; if (outputPath == null) { emit(_buildErrorState('Сначала выберите папку установки.')); return; } _gameLaunchPending = true; try { final installationDir = outputPath.toFilePath(); final authorization = await _launcherApiClient.issueGameTicket( _session.accessToken, ); final process = await _gameInstallationService.launchGame( installationDir, authorization: authorization, ); _activeGamePid = process.pid; unawaited( process.exitCode.then( (exitCode) { if (!isClosed) { add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode)); } }, onError: (_) { if (!isClosed) { add(HomeScreenGameExited(pid: process.pid, exitCode: null)); } }, ), ); emit( HomeScreenState( model: state.model.copyWith( phase: HomeScreenPhase.readyToPlay, isGameRunning: true, statusText: 'MoonWell запущен.', errorMessage: null, ), ), ); } catch (error) { emit( _buildErrorState( 'Не удалось запустить игру.', errorMessage: _formatError(error), ), ); } finally { _gameLaunchPending = false; } } Future _onHomeScreenGameExited( HomeScreenGameExited event, Emitter emit, ) async { if (_activeGamePid != event.pid) { return; } _activeGamePid = null; final hasClientExecutable = await _resolveExecutablePresence( state.model.outputPath, ); emit( HomeScreenState( model: state.model.copyWith( phase: _stablePhase(hasClientExecutable), hasClientExecutable: hasClientExecutable, isGameRunning: false, statusText: hasClientExecutable ? 'Игра закрыта. Клиент готов к запуску.' : 'Игра закрыта, но клиент MoonWell больше не найден.', errorMessage: null, ), ), ); } Future _onHomeScreenLogoutRequested( HomeScreenLogoutRequested event, Emitter emit, ) async { _pauseRequested = true; _activeGamePid = null; await _syncSubscription?.cancel(); _syncSubscription = null; await _preferencesRepository.clearLauncherSession(); emit( HomeScreenState( model: state.model.copyWith( phase: HomeScreenPhase.idle, isAuthenticated: false, isGameRunning: false, accountUsername: null, progress: const DownloadProgress.initial(), statusText: 'Сессия завершена. Войдите снова.', currentPath: null, errorMessage: null, localBuildHash: null, remoteBuildHash: null, processedFiles: 0, totalFiles: 0, newsItems: const [], isLoadingNews: false, newsErrorMessage: null, realm: null, ), ), ); } Future _onHomeScreenSyncStatusChanged( HomeScreenSyncStatusChanged event, Emitter emit, ) async { final outputPath = state.model.outputPath; final isCompleted = event.status.stage == ClientSyncStage.completed; final hasClientExecutable = isCompleted ? await _resolveExecutablePresence(outputPath) : state.model.hasClientExecutable; emit( HomeScreenState( model: state.model.copyWith( progress: event.status.progress, phase: isCompleted ? _stablePhase(hasClientExecutable) : HomeScreenPhase.syncing, hasClientExecutable: hasClientExecutable, statusText: event.status.message, currentPath: event.status.currentPath, errorMessage: null, localBuildHash: event.status.localBuildHash, remoteBuildHash: event.status.remoteBuildHash, processedFiles: event.status.processedFiles, totalFiles: event.status.totalFiles, syncStage: event.status.stage, ), ), ); } Future _onHomeScreenSyncFailed( HomeScreenSyncFailed event, Emitter emit, ) async { final hasClientExecutable = await _resolveExecutablePresence( state.model.outputPath, ); if (_pauseRequested || event.error is CancelledException) { _pauseRequested = false; emit( HomeScreenState( model: state.model.copyWith( phase: HomeScreenPhase.paused, hasClientExecutable: hasClientExecutable, statusText: 'Обновление приостановлено.', currentPath: null, errorMessage: null, syncStage: null, ), ), ); return; } emit( HomeScreenState( model: state.model.copyWith( phase: HomeScreenPhase.failure, hasClientExecutable: hasClientExecutable, statusText: 'Не удалось завершить обновление клиента.', currentPath: null, errorMessage: _formatError(event.error), syncStage: null, ), ), ); } HomeScreenState _buildErrorState(String statusText, {String? errorMessage}) { final hasClientExecutable = state.model.hasClientExecutable; return HomeScreenState( model: state.model.copyWith( phase: hasClientExecutable ? HomeScreenPhase.readyToPlay : HomeScreenPhase.failure, statusText: statusText, errorMessage: errorMessage ?? statusText, ), ); } HomeScreenPhase _stablePhase(bool hasClientExecutable) { return hasClientExecutable ? HomeScreenPhase.readyToPlay : HomeScreenPhase.ready; } Future _resolveExecutablePresence(Uri? outputPath) async { if (outputPath == null) { return false; } return _gameInstallationService.hasClientExecutable( outputPath.toFilePath(), ); } String _buildIdleStatus({ required bool isAuthenticated, required Uri? outputPath, required bool hasClientExecutable, }) { if (hasClientExecutable && isAuthenticated) { return 'Клиент найден. Можно играть или проверять обновления.'; } if (hasClientExecutable) { return 'Клиент найден. Можно играть или авторизоваться для проверки обновлений.'; } if (outputPath == null && !isAuthenticated) { return 'Выберите папку установки и авторизуйтесь.'; } if (outputPath == null) { return 'Авторизация выполнена. Выберите папку установки.'; } if (isAuthenticated) { return 'Папка выбрана. Можно устанавливать или обновлять клиент.'; } return 'Папка выбрана. Авторизуйтесь для проверки файлов.'; } String _formatError(Object error) { if (error is LauncherException) { return error.message; } if (error is DownloadException) { return error.message; } final raw = error.toString(); return raw.startsWith('Exception: ') ? raw.substring(11) : raw; } @override Future close() async { _pauseRequested = true; _activeGamePid = null; _manifestRefreshTimer?.cancel(); _realmRefreshTimer?.cancel(); await _syncSubscription?.cancel(); return super.close(); } }