Files
moonwell-launcher/lib/app/home_screen/bloc/home_screen_bloc.dart
T
2026-08-17 20:25:08 +04:00

613 lines
18 KiB
Dart

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<HomeScreenEvent, HomeScreenState> {
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<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenManifestRefreshRequested>(
_onHomeScreenManifestRefreshRequested,
);
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenGameExited>(_onHomeScreenGameExited);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_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<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
bool _gameLaunchPending = false;
Future<void> _onHomeScreenLoad(
HomeScreenLoad event,
Emitter<HomeScreenState> 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<void> _onHomeScreenAccountLoadRequested(
HomeScreenAccountLoadRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenRealmRefreshRequested(
HomeScreenRealmRefreshRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenManifestRefreshRequested(
HomeScreenManifestRefreshRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenSyncRequested(
HomeScreenSyncRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenOutputDirRequested(
HomeScreenOutputDirRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenPauseRequested(
HomeScreenPauseRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isSyncing) {
return;
}
_pauseRequested = true;
emit(
HomeScreenState(
model: state.model.copyWith(
statusText: 'Останавливаю обновление...',
errorMessage: null,
),
),
);
}
Future<void> _onHomeScreenPlayRequested(
HomeScreenPlayRequested event,
Emitter<HomeScreenState> 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();
await _gameInstallationService.clearCache(installationDir);
final authorization = await _launcherApiClient.issueGameTicket(
_session.accessToken,
);
final process = await _gameInstallationService.launchGame(
installationDir,
authorization: authorization,
);
_activeGamePid = process.pid;
unawaited(
process.exitCode.then<void>(
(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: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
),
);
} catch (error) {
emit(
_buildErrorState(
'Не удалось запустить игру.',
errorMessage: _formatError(error),
),
);
} finally {
_gameLaunchPending = false;
}
}
Future<void> _onHomeScreenGameExited(
HomeScreenGameExited event,
Emitter<HomeScreenState> 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
? 'Игра закрыта. Клиент готов к запуску.'
: 'Игра закрыта, но Wow.exe больше не найден.',
errorMessage: null,
),
),
);
}
Future<void> _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> 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<void> _onHomeScreenSyncStatusChanged(
HomeScreenSyncStatusChanged event,
Emitter<HomeScreenState> 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<void> _onHomeScreenSyncFailed(
HomeScreenSyncFailed event,
Emitter<HomeScreenState> 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<bool> _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<void> close() async {
_pauseRequested = true;
_activeGamePid = null;
_manifestRefreshTimer?.cancel();
_realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
}