базовая логика скачивания обновлений
This commit is contained in:
@@ -4,128 +4,365 @@ 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/application/download_manager.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/downloader/domain/entities/download_request.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/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';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'home_screen_event.dart';
|
||||
part 'home_screen_state.dart';
|
||||
|
||||
const _downloadId = 'client';
|
||||
|
||||
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
final DownloadManager _downloadManager;
|
||||
final PreferencesRepository _preferencesRepository;
|
||||
|
||||
StreamSubscription<DownloadProgress>? _downloadProgressSubscription;
|
||||
|
||||
HomeScreenBloc({
|
||||
required DownloadManager downloadManager,
|
||||
required ClientSyncUseCase clientSyncUseCase,
|
||||
required GameInstallationService gameInstallationService,
|
||||
required PreferencesRepository preferencesRepository,
|
||||
}) : _downloadManager = downloadManager,
|
||||
required LauncherSession session,
|
||||
required ClientManifest manifest,
|
||||
}) : _clientSyncUseCase = clientSyncUseCase,
|
||||
_gameInstallationService = gameInstallationService,
|
||||
_preferencesRepository = preferencesRepository,
|
||||
super(HomeScreenInitialState(model: HomeScreenModel.initial())) {
|
||||
_setupHandlers();
|
||||
_session = session,
|
||||
_manifest = manifest,
|
||||
super(HomeScreenState(
|
||||
model: HomeScreenModel.initial().copyWith(
|
||||
isAuthenticated: true,
|
||||
remoteBuildHash: manifest.buildHash,
|
||||
),
|
||||
)) {
|
||||
on<HomeScreenLoad>(_onHomeScreenLoad);
|
||||
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
||||
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
||||
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
||||
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
||||
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
||||
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
||||
|
||||
add(HomeScreenLoad());
|
||||
}
|
||||
|
||||
void _setupHandlers() {
|
||||
on<HomeScreenLoad>(_onHomeScreenLoad);
|
||||
on<HomeScreenDownloadRequested>(_onHomeScreenDownloadRequested);
|
||||
on<HomeScreenDownloadPaused>(_onHomeScreenDownloadPaused);
|
||||
on<HomeScreenOutputDirRequested>(_onHomeOutputDirRequested);
|
||||
on<HomeScreenDownloadProgressUpdated>(
|
||||
_onHomeScreenDownloadProgressUpdated,
|
||||
transformer: (events, mapper) =>
|
||||
events.throttleTime(const Duration(seconds: 2)).switchMap(mapper),
|
||||
);
|
||||
}
|
||||
final ClientSyncUseCase _clientSyncUseCase;
|
||||
final GameInstallationService _gameInstallationService;
|
||||
final PreferencesRepository _preferencesRepository;
|
||||
|
||||
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
||||
final LauncherSession _session;
|
||||
final ClientManifest _manifest;
|
||||
bool _pauseRequested = false;
|
||||
|
||||
Future<void> _onHomeScreenLoad(
|
||||
HomeScreenLoad event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
final outputDir = await _preferencesRepository.getOutputDir();
|
||||
final outputPath = await _preferencesRepository.getOutputDir();
|
||||
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
||||
|
||||
emit(
|
||||
HomeScreenReadyToDownloadState(
|
||||
model: state.model.copyWith(outputPath: outputDir),
|
||||
HomeScreenState(
|
||||
model: state.model.copyWith(
|
||||
outputPath: outputPath,
|
||||
hasClientExecutable: hasClientExecutable,
|
||||
phase: _stablePhase(hasClientExecutable),
|
||||
statusText: _buildIdleStatus(
|
||||
isAuthenticated: state.model.isAuthenticated,
|
||||
outputPath: outputPath,
|
||||
hasClientExecutable: hasClientExecutable,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onHomeScreenDownloadRequested(
|
||||
HomeScreenDownloadRequested event,
|
||||
Future<void> _onHomeScreenSyncRequested(
|
||||
HomeScreenSyncRequested event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
if (state.model.outputPath == null) {
|
||||
emit(HomeScreenOutputDirSelectionState(model: state.model.copyWith()));
|
||||
if (state.model.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
_downloadProgressSubscription = _downloadManager
|
||||
.start(
|
||||
id: _downloadId,
|
||||
request: DownloadRequest(
|
||||
destinationPath: state.model.outputPath!.toFilePath(),
|
||||
),
|
||||
)
|
||||
.listen((progress) => add(HomeScreenDownloadProgressUpdated(progress)));
|
||||
final outputPath = state.model.outputPath;
|
||||
if (outputPath == null) {
|
||||
add(HomeScreenOutputDirRequested());
|
||||
return;
|
||||
}
|
||||
|
||||
emit(HomeScreenDownloadInitializing(model: state.model.copyWith()));
|
||||
}
|
||||
await _syncSubscription?.cancel();
|
||||
_pauseRequested = false;
|
||||
|
||||
void _onHomeScreenDownloadProgressUpdated(
|
||||
HomeScreenDownloadProgressUpdated event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) {
|
||||
emit(
|
||||
HomeScreenDownloadingState(
|
||||
model: state.model.copyWith(progress: event.progress),
|
||||
HomeScreenState(
|
||||
model: state.model.copyWith(
|
||||
phase: HomeScreenPhase.syncing,
|
||||
progress: const DownloadProgress.initial(),
|
||||
statusText: 'Подготавливаю проверку клиента...',
|
||||
currentPath: null,
|
||||
errorMessage: null,
|
||||
processedFiles: 0,
|
||||
totalFiles: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_syncSubscription = _clientSyncUseCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: outputPath.toFilePath(),
|
||||
accessToken: _session.accessToken,
|
||||
manifest: _manifest,
|
||||
),
|
||||
isCancelled: () async => _pauseRequested,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
(status) => add(HomeScreenSyncStatusChanged(status)),
|
||||
onError: (error, _) => add(HomeScreenSyncFailed(error)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _downloadProgressSubscription?.cancel();
|
||||
await _downloadManager.cancel(_downloadId);
|
||||
|
||||
return super.close();
|
||||
}
|
||||
|
||||
FutureOr<void> _onHomeScreenDownloadPaused(
|
||||
HomeScreenDownloadPaused event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
await _downloadManager.pause(_downloadId);
|
||||
|
||||
emit(HomeScreenDownloadPausedState(model: state.model.copyWith()));
|
||||
}
|
||||
|
||||
FutureOr<void> _onHomeOutputDirRequested(
|
||||
Future<void> _onHomeScreenOutputDirRequested(
|
||||
HomeScreenOutputDirRequested event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
emit(HomeScreenReadyToDownloadState(model: state.model.copyWith()));
|
||||
if (state.model.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
final directory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: 'Please select installation directory',
|
||||
dialogTitle: 'Выберите папку установки Moonwell',
|
||||
);
|
||||
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _preferencesRepository.setOutputDir(Uri.directory(directory));
|
||||
final outputPath = Uri.directory(directory);
|
||||
await _preferencesRepository.setOutputDir(outputPath);
|
||||
|
||||
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
||||
|
||||
emit(
|
||||
HomeScreenReadyToDownloadState(
|
||||
model: state.model.copyWith(outputPath: Uri.directory(directory)),
|
||||
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 {
|
||||
final outputPath = state.model.outputPath;
|
||||
if (outputPath == null) {
|
||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final installationDir = outputPath.toFilePath();
|
||||
await _gameInstallationService.clearCache(installationDir);
|
||||
await _gameInstallationService.launchGame(installationDir);
|
||||
|
||||
emit(
|
||||
HomeScreenState(
|
||||
model: state.model.copyWith(
|
||||
phase: HomeScreenPhase.readyToPlay,
|
||||
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
||||
errorMessage: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
emit(
|
||||
_buildErrorState(
|
||||
'Не удалось запустить игру.',
|
||||
errorMessage: _formatError(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
HomeScreenState(
|
||||
model: state.model.copyWith(
|
||||
phase: HomeScreenPhase.failure,
|
||||
hasClientExecutable: hasClientExecutable,
|
||||
statusText: 'Не удалось завершить обновление клиента.',
|
||||
currentPath: null,
|
||||
errorMessage: _formatError(event.error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
await _syncSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,22 @@ sealed class HomeScreenEvent {}
|
||||
|
||||
final class HomeScreenLoad extends HomeScreenEvent {}
|
||||
|
||||
final class HomeScreenDownloadRequested extends HomeScreenEvent {}
|
||||
final class HomeScreenSyncRequested extends HomeScreenEvent {}
|
||||
|
||||
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
|
||||
|
||||
final class HomeScreenDownloadPaused extends HomeScreenEvent {}
|
||||
final class HomeScreenPauseRequested extends HomeScreenEvent {}
|
||||
|
||||
final class HomeScreenDownloadProgressUpdated extends HomeScreenEvent {
|
||||
final DownloadProgress progress;
|
||||
final class HomeScreenPlayRequested extends HomeScreenEvent {}
|
||||
|
||||
HomeScreenDownloadProgressUpdated(this.progress);
|
||||
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
|
||||
final ClientSyncStatus status;
|
||||
|
||||
HomeScreenSyncStatusChanged(this.status);
|
||||
}
|
||||
|
||||
final class HomeScreenSyncFailed extends HomeScreenEvent {
|
||||
final Object error;
|
||||
|
||||
HomeScreenSyncFailed(this.error);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,107 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
|
||||
enum HomeScreenPhase {
|
||||
idle,
|
||||
ready,
|
||||
authenticating,
|
||||
syncing,
|
||||
paused,
|
||||
readyToPlay,
|
||||
failure,
|
||||
}
|
||||
|
||||
@immutable
|
||||
final class HomeScreenModel {
|
||||
/// The current download progress.
|
||||
static const Object _sentinel = Object();
|
||||
|
||||
final DownloadProgress progress;
|
||||
|
||||
/// The output path for the downloaded file.
|
||||
final Uri? outputPath;
|
||||
final HomeScreenPhase phase;
|
||||
final bool isAuthenticated;
|
||||
final bool hasClientExecutable;
|
||||
final String statusText;
|
||||
final String? currentPath;
|
||||
final String? errorMessage;
|
||||
final String? localBuildHash;
|
||||
final String? remoteBuildHash;
|
||||
final int processedFiles;
|
||||
final int totalFiles;
|
||||
|
||||
const HomeScreenModel({required this.progress, this.outputPath});
|
||||
const HomeScreenModel({
|
||||
required this.progress,
|
||||
required this.outputPath,
|
||||
required this.phase,
|
||||
required this.isAuthenticated,
|
||||
required this.hasClientExecutable,
|
||||
required this.statusText,
|
||||
required this.currentPath,
|
||||
required this.errorMessage,
|
||||
required this.localBuildHash,
|
||||
required this.remoteBuildHash,
|
||||
required this.processedFiles,
|
||||
required this.totalFiles,
|
||||
});
|
||||
|
||||
const HomeScreenModel.initial()
|
||||
: progress = const DownloadProgress.initial(),
|
||||
outputPath = null;
|
||||
outputPath = null,
|
||||
phase = HomeScreenPhase.idle,
|
||||
isAuthenticated = false,
|
||||
hasClientExecutable = false,
|
||||
statusText = 'Загрузка...',
|
||||
currentPath = null,
|
||||
errorMessage = null,
|
||||
localBuildHash = null,
|
||||
remoteBuildHash = null,
|
||||
processedFiles = 0,
|
||||
totalFiles = 0;
|
||||
|
||||
HomeScreenModel copyWith({DownloadProgress? progress, Uri? outputPath}) {
|
||||
bool get isBusy =>
|
||||
phase == HomeScreenPhase.authenticating ||
|
||||
phase == HomeScreenPhase.syncing;
|
||||
|
||||
bool get isSyncing => phase == HomeScreenPhase.syncing;
|
||||
|
||||
bool get canPlay => hasClientExecutable && !isBusy;
|
||||
|
||||
HomeScreenModel copyWith({
|
||||
DownloadProgress? progress,
|
||||
Object? outputPath = _sentinel,
|
||||
HomeScreenPhase? phase,
|
||||
bool? isAuthenticated,
|
||||
bool? hasClientExecutable,
|
||||
String? statusText,
|
||||
Object? currentPath = _sentinel,
|
||||
Object? errorMessage = _sentinel,
|
||||
Object? localBuildHash = _sentinel,
|
||||
Object? remoteBuildHash = _sentinel,
|
||||
int? processedFiles,
|
||||
int? totalFiles,
|
||||
}) {
|
||||
return HomeScreenModel(
|
||||
progress: progress ?? this.progress,
|
||||
outputPath: outputPath ?? this.outputPath,
|
||||
outputPath: outputPath == _sentinel
|
||||
? this.outputPath
|
||||
: outputPath as Uri?,
|
||||
phase: phase ?? this.phase,
|
||||
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
|
||||
statusText: statusText ?? this.statusText,
|
||||
currentPath: currentPath == _sentinel
|
||||
? this.currentPath
|
||||
: currentPath as String?,
|
||||
errorMessage: errorMessage == _sentinel
|
||||
? this.errorMessage
|
||||
: errorMessage as String?,
|
||||
localBuildHash: localBuildHash == _sentinel
|
||||
? this.localBuildHash
|
||||
: localBuildHash as String?,
|
||||
remoteBuildHash: remoteBuildHash == _sentinel
|
||||
? this.remoteBuildHash
|
||||
: remoteBuildHash as String?,
|
||||
processedFiles: processedFiles ?? this.processedFiles,
|
||||
totalFiles: totalFiles ?? this.totalFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,8 @@
|
||||
part of 'home_screen_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class HomeScreenState {
|
||||
final class HomeScreenState {
|
||||
final HomeScreenModel model;
|
||||
|
||||
const HomeScreenState({required this.model});
|
||||
}
|
||||
|
||||
final class HomeScreenInitialState extends HomeScreenState {
|
||||
const HomeScreenInitialState({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenReadyToDownloadState extends HomeScreenState {
|
||||
const HomeScreenReadyToDownloadState({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenDownloadPausedState extends HomeScreenState {
|
||||
const HomeScreenDownloadPausedState({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenDownloadInitializing extends HomeScreenState {
|
||||
const HomeScreenDownloadInitializing({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenDownloadingState extends HomeScreenState {
|
||||
const HomeScreenDownloadingState({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenOutputDirSelectionState extends HomeScreenState {
|
||||
const HomeScreenOutputDirSelectionState({required super.model});
|
||||
}
|
||||
|
||||
final class HomeScreenReadyToPlayState extends HomeScreenState {
|
||||
const HomeScreenReadyToPlayState({required super.model});
|
||||
}
|
||||
|
||||
@@ -1,95 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
|
||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/application/download_manager.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/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
||||
import 'package:moonwell_launcher/service_container.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
const HomeScreen({super.key, required this.session, required this.manifest});
|
||||
|
||||
final LauncherSession session;
|
||||
final ClientManifest manifest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocProvider<HomeScreenBloc>(
|
||||
create: (context) => HomeScreenBloc(
|
||||
downloadManager: getIt<DownloadManager>(),
|
||||
clientSyncUseCase: getIt<ClientSyncUseCase>(),
|
||||
gameInstallationService: getIt<GameInstallationService>(),
|
||||
preferencesRepository: getIt<PreferencesRepository>(),
|
||||
session: session,
|
||||
manifest: manifest,
|
||||
),
|
||||
child: BlocConsumer<HomeScreenBloc, HomeScreenState>(
|
||||
listener: (context, state) {
|
||||
if (state is HomeScreenOutputDirSelectionState) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('Alert'),
|
||||
content: Text('Please select client location.'),
|
||||
actions: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
onPressed: () => context.read<HomeScreenBloc>().add(
|
||||
HomeScreenOutputDirRequested(),
|
||||
),
|
||||
label: Text('Select directory'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
// subtle vignette for “night” vibe
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -0.5),
|
||||
radius: 1.2,
|
||||
colors: [MWColors.deepNavy, MWColors.abyss],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/background.png',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: HomeScreenContent(
|
||||
title: Text(
|
||||
'MoonWell',
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background image
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
||||
),
|
||||
// Gradient overlay
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
MWColors.abyss.withAlpha(180),
|
||||
MWColors.abyss.withAlpha(220),
|
||||
MWColors.abyss.withAlpha(240),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
if (state is HomeScreenDownloadInitializing)
|
||||
Center(child: const CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Main content
|
||||
const Positioned.fill(child: HomeScreenContent()),
|
||||
// Custom title bar
|
||||
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleBar extends StatelessWidget {
|
||||
const _TitleBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
_TitleBarButton(
|
||||
icon: Icons.remove,
|
||||
onTap: () => windowManager.minimize(),
|
||||
color: cs.onSurface,
|
||||
),
|
||||
_TitleBarButton(
|
||||
icon: Icons.crop_square,
|
||||
onTap: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
},
|
||||
color: cs.onSurface,
|
||||
),
|
||||
_TitleBarButton(
|
||||
icon: Icons.close,
|
||||
onTap: () => SystemNavigator.pop(),
|
||||
color: MWColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleBarButton extends StatelessWidget {
|
||||
const _TitleBarButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 36,
|
||||
child: Icon(icon, size: 16, color: color.withAlpha(180)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,184 +1,455 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart';
|
||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||
import 'package:pixelarticons/pixel.dart';
|
||||
|
||||
class HomeScreenContent extends StatelessWidget {
|
||||
const HomeScreenContent({super.key, required this.title});
|
||||
const HomeScreenContent({super.key});
|
||||
|
||||
final Widget title;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<HomeScreenBloc>().state;
|
||||
final model = state.model;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
String _speedLabel(BuildContext context) {
|
||||
final kbps = context.read<HomeScreenBloc>().state.model.progress.speed;
|
||||
|
||||
if (kbps <= 0) return '—';
|
||||
if (kbps >= 1024) {
|
||||
return '${(kbps / 1024).toStringAsFixed(2)} MB/s';
|
||||
}
|
||||
return '${kbps.toStringAsFixed(0)} kB/s';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 36),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Left panel — News
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _NewsPanel(cs: cs),
|
||||
),
|
||||
// Right panel — Status & Controls
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: _RightPanel(model: model, cs: cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom bar
|
||||
_BottomBar(model: model, cs: cs),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// News panel (left side)
|
||||
// ---------------------------------------------------------------------------
|
||||
class _NewsPanel extends StatelessWidget {
|
||||
const _NewsPanel({required this.cs});
|
||||
final ColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 24, top: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Logo
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Image.asset(
|
||||
'assets/logo.png',
|
||||
height: 120,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Новости',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// News list
|
||||
Expanded(
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
Colors.white.withAlpha(0),
|
||||
],
|
||||
stops: const [0.0, 0.85, 1.0],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(right: 16, bottom: 24),
|
||||
children: const [
|
||||
_NewsCard(
|
||||
title: 'Добро пожаловать в MoonWell!',
|
||||
description:
|
||||
'Мы рады приветствовать вас на нашем сервере. '
|
||||
'Установите клиент, авторизуйтесь и окунитесь '
|
||||
'в мир приключений!',
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
_NewsCard(
|
||||
title: 'Обновление клиента',
|
||||
description:
|
||||
'Лаунчер автоматически проверит и загрузит '
|
||||
'последние обновления игрового клиента при входе.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewsCard extends StatelessWidget {
|
||||
const _NewsCard({required this.title, required this.description});
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface.withAlpha(140),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withAlpha(100)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: cs.tertiary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurface.withAlpha(200),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right panel (status & controls)
|
||||
// ---------------------------------------------------------------------------
|
||||
class _RightPanel extends StatelessWidget {
|
||||
const _RightPanel({required this.model, required this.cs});
|
||||
|
||||
final HomeScreenModel model;
|
||||
final ColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
|
||||
|
||||
final state = context.read<HomeScreenBloc>().state;
|
||||
|
||||
final percent = _resolvePercentage(context);
|
||||
final eta = state.model.progress.eta;
|
||||
|
||||
final isDownloading = state is HomeScreenDownloadingState;
|
||||
final canPlay = state is HomeScreenReadyToPlayState;
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Classic+ Wrath of the Lich King experience',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: cs.onSurface.withAlpha(200),
|
||||
),
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: MWColors.abyss.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||
boxShadow: deco.cardGlow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(),
|
||||
// Folder selector
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.read<HomeScreenBloc>().add(
|
||||
HomeScreenOutputDirRequested(),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const Spacer(),
|
||||
// Progress bar
|
||||
GildedProgressBar(
|
||||
value: state.model.progress.total == 0
|
||||
? 0
|
||||
: state.model.progress.downloaded /
|
||||
state.model.progress.total,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'$percent%',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Pixel.speedfast, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_speedLabel(context),
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Pixel.timeline, // Updated to use PixelArtIcons
|
||||
size: 18,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
eta == Duration.zero
|
||||
? '—'
|
||||
: '${eta.inMinutes}:${(eta.inSeconds % 60).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Controls row
|
||||
Row(
|
||||
spacing: 12.0,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(
|
||||
canPlay ? Icons.play_arrow_rounded : Icons.download,
|
||||
),
|
||||
label: Text(
|
||||
canPlay
|
||||
? 'Play'
|
||||
: (isDownloading
|
||||
? 'Downloading…'
|
||||
: (state.model.progress.downloaded == 0
|
||||
? 'Install'
|
||||
: 'Resume')),
|
||||
),
|
||||
onPressed: canPlay
|
||||
? null
|
||||
: (isDownloading ? null : () => _onStart(context)),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: Icon(Pixel.pause),
|
||||
label: Text('Pause'),
|
||||
onPressed: isDownloading ? () => _onPause(context) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// Disk path / build info (placeholders)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withAlpha(128),
|
||||
color: cs.surface.withAlpha(100),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outline),
|
||||
boxShadow: (deco.cardGlow),
|
||||
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Pixel.folder),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.read<HomeScreenBloc>().add(
|
||||
HomeScreenOutputDirRequested(),
|
||||
),
|
||||
child: Text(
|
||||
'Install path: ${state.model.outputPath?.toFilePath() ?? '—'}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Text(
|
||||
model.outputPath?.toFilePath() ?? 'Выбрать папку',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.edit, size: 14, color: cs.onSurfaceVariant.withAlpha(120)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outline.withAlpha(60)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
model.statusText,
|
||||
style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13),
|
||||
),
|
||||
if (model.currentPath != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
model.currentPath!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (model.totalFiles > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Файлы: ${model.processedFiles}/${model.totalFiles}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
if (model.errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
model.errorMessage!,
|
||||
style: TextStyle(color: cs.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Build hashes
|
||||
if (model.remoteBuildHash != null || model.localBuildHash != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Build: ${_shortHash(model.localBuildHash)} / ${_shortHash(model.remoteBuildHash)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolvePercentage(BuildContext context) {
|
||||
final state = context.read<HomeScreenBloc>().state;
|
||||
|
||||
if (state.model.progress.total == 0) {
|
||||
return '0.0';
|
||||
}
|
||||
|
||||
final percentage =
|
||||
state.model.progress.downloaded / state.model.progress.total;
|
||||
|
||||
return (percentage * 100).clamp(0, 100).toStringAsFixed(1);
|
||||
}
|
||||
|
||||
void _onStart(BuildContext context) {
|
||||
context.read<HomeScreenBloc>().add(HomeScreenDownloadRequested());
|
||||
}
|
||||
|
||||
void _onPause(BuildContext context) {
|
||||
context.read<HomeScreenBloc>().add(HomeScreenDownloadPaused());
|
||||
}
|
||||
|
||||
void _onReset(BuildContext context) {
|
||||
// context.read<HomeScreenBloc>().add(HomeScreenDownloadReset());
|
||||
String _shortHash(String? hash) {
|
||||
if (hash == null || hash.isEmpty) return '—';
|
||||
if (hash.length <= 12) return hash;
|
||||
return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bottom bar (play button, progress, version)
|
||||
// ---------------------------------------------------------------------------
|
||||
class _BottomBar extends StatelessWidget {
|
||||
const _BottomBar({required this.model, required this.cs});
|
||||
|
||||
final HomeScreenModel model;
|
||||
final ColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressValue = model.progress.total == 0
|
||||
? 0.0
|
||||
: model.progress.downloaded / model.progress.total;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
MWColors.abyss.withAlpha(0),
|
||||
MWColors.abyss.withAlpha(230),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress bar (only when syncing)
|
||||
if (model.isSyncing) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: GildedProgressBar(value: progressValue)),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${(progressValue * 100).clamp(0, 100).toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_formatSpeed(model.progress.speed),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Bottom row: play + status
|
||||
Row(
|
||||
children: [
|
||||
// Play button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(_resolvePrimaryIcon(model), size: 20),
|
||||
label: Text(_resolvePrimaryLabel(model)),
|
||||
onPressed: model.isBusy
|
||||
? null
|
||||
: () => _onPrimaryAction(context, model),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (model.canPlay || model.isSyncing) ...[
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: OutlinedButton.icon(
|
||||
icon: Icon(_resolveSecondaryIcon(model), size: 18),
|
||||
label: Text(_resolveSecondaryLabel(model)),
|
||||
onPressed: () => _onSecondaryAction(context, model),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 16),
|
||||
// Status ticker
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface.withAlpha(100),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outline.withAlpha(60)),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
model.currentPath ?? model.statusText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withAlpha(160),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPrimaryAction(BuildContext context, HomeScreenModel model) {
|
||||
final bloc = context.read<HomeScreenBloc>();
|
||||
|
||||
if (model.canPlay) {
|
||||
bloc.add(HomeScreenPlayRequested());
|
||||
return;
|
||||
}
|
||||
|
||||
bloc.add(HomeScreenSyncRequested());
|
||||
}
|
||||
|
||||
void _onSecondaryAction(BuildContext context, HomeScreenModel model) {
|
||||
final bloc = context.read<HomeScreenBloc>();
|
||||
|
||||
if (model.isSyncing) {
|
||||
bloc.add(HomeScreenPauseRequested());
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.canPlay) {
|
||||
bloc.add(HomeScreenSyncRequested());
|
||||
return;
|
||||
}
|
||||
|
||||
bloc.add(HomeScreenOutputDirRequested());
|
||||
}
|
||||
|
||||
String _resolvePrimaryLabel(HomeScreenModel model) {
|
||||
if (model.isSyncing) return 'Обновление...';
|
||||
if (model.canPlay) return 'Играть';
|
||||
return model.hasClientExecutable ? 'Обновить' : 'Установить';
|
||||
}
|
||||
|
||||
IconData _resolvePrimaryIcon(HomeScreenModel model) {
|
||||
if (model.canPlay) return Icons.play_arrow_rounded;
|
||||
return Icons.download_rounded;
|
||||
}
|
||||
|
||||
String _resolveSecondaryLabel(HomeScreenModel model) {
|
||||
if (model.isSyncing) return 'Пауза';
|
||||
if (model.canPlay) return 'Проверить';
|
||||
return 'Папка';
|
||||
}
|
||||
|
||||
IconData _resolveSecondaryIcon(HomeScreenModel model) {
|
||||
if (model.isSyncing) return Pixel.pause;
|
||||
if (model.canPlay) return Icons.refresh_rounded;
|
||||
return Pixel.folder;
|
||||
}
|
||||
|
||||
String _formatSpeed(int bytesPerSecond) {
|
||||
if (bytesPerSecond <= 0) return '—';
|
||||
if (bytesPerSecond >= 1024 * 1024) {
|
||||
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
||||
}
|
||||
if (bytesPerSecond >= 1024) {
|
||||
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
|
||||
}
|
||||
return '$bytesPerSecond B/s';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user