базовая логика скачивания обновлений
This commit is contained in:
Vendored
+12
-3
@@ -7,19 +7,28 @@
|
||||
{
|
||||
"name": "moonwell_launcher",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
"type": "dart",
|
||||
"toolArgs": [
|
||||
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moonwell_launcher (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
"flutterMode": "profile",
|
||||
"toolArgs": [
|
||||
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moonwell_launcher (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
"flutterMode": "release",
|
||||
"toolArgs": [
|
||||
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
import 'package:moonwell_launcher/service_container.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = 'Введите логин и пароль.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final api = getIt<LauncherApiClient>();
|
||||
final session = await api.login(username: username, password: password);
|
||||
final manifest = await api.fetchManifest(session.accessToken);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) =>
|
||||
HomeScreen(session: session, manifest: manifest),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = _formatError(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatError(Object error) {
|
||||
final raw = error.toString();
|
||||
if (raw.startsWith('Exception: ')) return raw.substring(11);
|
||||
return raw;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment.center,
|
||||
radius: 1.0,
|
||||
colors: [
|
||||
MWColors.abyss.withAlpha(200),
|
||||
MWColors.abyss.withAlpha(240),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title bar
|
||||
Positioned(top: 0, left: 0, right: 0, child: _TitleBar(cs: cs)),
|
||||
// Login form — centered
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
height: 160,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: MWColors.abyss.withAlpha(210),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: MWColors.primary.withAlpha(30),
|
||||
blurRadius: 40,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
enabled: !_isLoading,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Логин',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
enabled: !_isLoading,
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Пароль',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _login,
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text('Войти'),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleBar extends StatelessWidget {
|
||||
const _TitleBar({required this.cs});
|
||||
final ColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
||||
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||
|
||||
class MoonWellApp extends StatelessWidget {
|
||||
@@ -9,7 +9,7 @@ class MoonWellApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'MoonWell',
|
||||
home: const HomeScreen(),
|
||||
home: const LoginScreen(),
|
||||
theme: moonWellTheme(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
part of 'mw_theme.dart';
|
||||
|
||||
/// Core palette derived from the logo
|
||||
/// Core palette
|
||||
class MWColors {
|
||||
// “Night sky” blues
|
||||
static const Color abyss = Color(0xFF0B101A); // page bg
|
||||
static const Color deepNavy = Color(0xFF0F1522); // surfaces
|
||||
static const Color stormNavy = Color(0xFF1A2233); // elevated surfaces
|
||||
static const Color moonBlue = Color(
|
||||
0xFF6BA3FF,
|
||||
); // tertiary accent (moonlight)
|
||||
// Surface & background
|
||||
static const Color abyss = Color(0xFF090F2B); // page bg
|
||||
static const Color deepNavy = Color(0xFF111840); // surfaces
|
||||
static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces
|
||||
|
||||
// “Ornate gold”
|
||||
static const Color gold = Color(0xFFE5B74A); // primary
|
||||
static const Color goldDark = Color(0xFF7A5A00); // primary container
|
||||
static const Color goldSoft = Color(0xFFF3D98C); // gradient highlight
|
||||
// Primary & secondary
|
||||
static const Color primary = Color(0xFF5460A2);
|
||||
static const Color secondary = Color(0xFF5F6BD2);
|
||||
static const Color tertiary = Color(0xFF6494EB);
|
||||
|
||||
// Lines & states
|
||||
static const Color outline = Color(0xFF2B3242);
|
||||
static const Color outlineGold = Color(0xFF7C6A3A);
|
||||
static const Color outline = Color(0xFF4B4E6E);
|
||||
|
||||
// Semantic
|
||||
static const Color success = Color(0xFF3DDC97);
|
||||
static const Color warning = Color(0xFFF0B429);
|
||||
static const Color error = Color(0xFFD86A8A);
|
||||
}
|
||||
|
||||
+23
-39
@@ -4,40 +4,26 @@ part 'mw_colors.dart';
|
||||
part 'mw_decorations.dart';
|
||||
|
||||
ThemeData moonWellTheme() {
|
||||
const cs = ColorScheme(
|
||||
const cs = ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: MWColors.gold,
|
||||
onPrimary: Color(0xFF1B1202),
|
||||
primaryContainer: MWColors.goldDark,
|
||||
onPrimaryContainer: Color(0xFFFFF0C2),
|
||||
primary: Color(0xFF5460A2),
|
||||
onPrimary: Color(0xFFDDE0FB),
|
||||
|
||||
secondary: Color(0xFFC08A2E), // bronze accent
|
||||
onSecondary: Color(0xFF201300),
|
||||
secondaryContainer: Color(0xFF3B2A00),
|
||||
onSecondaryContainer: Color(0xFFF6E2B6),
|
||||
secondary: Color(0xFF5F6BD2),
|
||||
onSecondary: Color(0xFFDDE0FB),
|
||||
|
||||
tertiary: MWColors.moonBlue, // moon-glow accent
|
||||
onTertiary: Color(0xFF081120),
|
||||
tertiaryContainer: Color(0xFF143A66),
|
||||
onTertiaryContainer: Color(0xFFD9EBFF),
|
||||
tertiary: Color(0xFF6494EB),
|
||||
onTertiary: Color(0xFF090F2B),
|
||||
|
||||
error: Color(0xFFFFB4AB),
|
||||
onError: Color(0xFF690005),
|
||||
errorContainer: Color(0xFF93000A),
|
||||
onErrorContainer: Color(0xFFFFDAD6),
|
||||
error: Color(0xFFD86A8A),
|
||||
onError: Color(0xFF090F2B),
|
||||
|
||||
surface: MWColors.abyss,
|
||||
onSurface: Color(0xFFE5EAF6),
|
||||
surfaceContainerHighest: MWColors.stormNavy,
|
||||
onSurfaceVariant: Color(0xFFC3C8D6),
|
||||
surface: Color(0xFF1A2759),
|
||||
onSurface: Color(0xFFDDE0FB),
|
||||
|
||||
outline: MWColors.outline,
|
||||
outlineVariant: Color(0xFF40495C),
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black87,
|
||||
inverseSurface: Color(0xFFE5EAF6),
|
||||
onInverseSurface: Color(0xFF11151E),
|
||||
inversePrimary: Color(0xFFF0D072),
|
||||
outline: Color(0xFF4B4E6E),
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xCC000000),
|
||||
);
|
||||
|
||||
final base = ThemeData(
|
||||
@@ -88,7 +74,7 @@ ThemeData moonWellTheme() {
|
||||
side: BorderSide(color: MWColors.outline.withAlpha(150)),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
shadowColor: MWColors.moonBlue.withAlpha(63),
|
||||
shadowColor: MWColors.tertiary.withAlpha(63),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
@@ -103,16 +89,14 @@ ThemeData moonWellTheme() {
|
||||
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
|
||||
),
|
||||
elevation: const WidgetStatePropertyAll(6),
|
||||
shadowColor: WidgetStatePropertyAll(MWColors.moonBlue.withAlpha(89)),
|
||||
shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)),
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return MWColors.gold.withAlpha(115);
|
||||
return MWColors.primary.withAlpha(115);
|
||||
}
|
||||
return cs.primary;
|
||||
}),
|
||||
foregroundColor: const WidgetStatePropertyAll(
|
||||
Color(0xFF1B1202),
|
||||
), // dark text on gold
|
||||
foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -136,7 +120,7 @@ ThemeData moonWellTheme() {
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
side: WidgetStatePropertyAll(
|
||||
BorderSide(color: MWColors.outlineGold.withAlpha(230)),
|
||||
BorderSide(color: MWColors.outline.withAlpha(230)),
|
||||
),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
@@ -211,14 +195,14 @@ ThemeData moonWellTheme() {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
MWColors.goldSoft, // highlight
|
||||
MWColors.gold, // body
|
||||
Color(0xFFC58A1E), // warm edge
|
||||
Color(0xFF7A85CC), // highlight
|
||||
Color(0xFF5460A2), // body
|
||||
Color(0xFF3E4880), // edge
|
||||
],
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
),
|
||||
textGlow: Shadow(
|
||||
color: Color(0x446BA3FF), // moon-glow
|
||||
color: Color(0x446494EB),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 0),
|
||||
),
|
||||
|
||||
+12
-8
@@ -1,10 +1,14 @@
|
||||
final class Config {
|
||||
static const host = 'storage.yandexcloud.net';
|
||||
static const bucketName = 'warcraft-client';
|
||||
static const id = 'ajeoijp637jj3527jd96';
|
||||
static const serviceAccountId = 'ajerr0qmscj6eof68buc';
|
||||
static const createdAt = "2025-08-24T12:08:22.166354325Z";
|
||||
static const keyId = 'YCAJEwIWaVVRCAx2YM2XMqcCS';
|
||||
static const secret = '';
|
||||
static const objectName = 'World of Warcraft.zip';
|
||||
static const launcherApiBaseUrl = String.fromEnvironment(
|
||||
'MOONWELL_API_BASE_URL',
|
||||
);
|
||||
static const gameExecutableName = 'Wow.exe';
|
||||
static const cacheDirectoryName = 'Cache';
|
||||
static const ignoredVerificationDirectories = <String>{
|
||||
'cache',
|
||||
'errors',
|
||||
'logs',
|
||||
'screenshots',
|
||||
'wtf',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:injectable/injectable.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/downloader/use_cases/download_object_use_case.dart';
|
||||
|
||||
typedef DownloadId = String;
|
||||
|
||||
class _Session {
|
||||
_Session({required this.id, required this.request, required this.useCase})
|
||||
: progressController = StreamController<DownloadProgress>.broadcast();
|
||||
|
||||
final DownloadId id;
|
||||
final DownloadRequest request;
|
||||
final DownloadObjectUseCase useCase;
|
||||
|
||||
final StreamController<DownloadProgress> progressController;
|
||||
StreamSubscription<DownloadProgress>? sub;
|
||||
bool paused = false;
|
||||
bool cancelled = false;
|
||||
|
||||
Stream<DownloadProgress> get stream => progressController.stream;
|
||||
|
||||
Future<void> start() async {
|
||||
cancelled = false;
|
||||
paused = false;
|
||||
sub = useCase
|
||||
.call(
|
||||
DownloadObjectUseCaseInput(
|
||||
request: request,
|
||||
isCancelled: () async => cancelled || paused,
|
||||
),
|
||||
)
|
||||
.listen(
|
||||
progressController.add,
|
||||
onError: progressController.addError,
|
||||
onDone: () => progressController.close(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
paused = true;
|
||||
await sub?.cancel();
|
||||
sub = null;
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
if (cancelled) return;
|
||||
paused = false;
|
||||
await start(); // will resume from file size
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
cancelled = true;
|
||||
await sub?.cancel();
|
||||
sub = null;
|
||||
if (!progressController.isClosed) {
|
||||
progressController.addError(const CancelledException());
|
||||
await progressController.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@lazySingleton
|
||||
class DownloadManager {
|
||||
DownloadManager(this._useCase);
|
||||
|
||||
final DownloadObjectUseCase _useCase;
|
||||
|
||||
final Map<DownloadId, _Session> _sessions = {};
|
||||
|
||||
/// Start or replace a session with [id].
|
||||
Stream<DownloadProgress> start({
|
||||
required DownloadId id,
|
||||
required DownloadRequest request,
|
||||
}) {
|
||||
// dispose old session if exists
|
||||
_sessions[id]?.cancel();
|
||||
final s = _Session(id: id, request: request, useCase: _useCase);
|
||||
_sessions[id] = s;
|
||||
unawaited(s.start());
|
||||
return s.stream;
|
||||
}
|
||||
|
||||
Stream<DownloadProgress>? progress(DownloadId id) => _sessions[id]?.stream;
|
||||
|
||||
Future<void> pause(DownloadId id) => _sessions[id]?.pause() ?? Future.value();
|
||||
Future<void> resume(DownloadId id) =>
|
||||
_sessions[id]?.resume() ?? Future.value();
|
||||
Future<void> cancel(DownloadId id) async {
|
||||
await _sessions[id]?.cancel();
|
||||
_sessions.remove(id);
|
||||
}
|
||||
|
||||
bool isActive(DownloadId id) => _sessions.containsKey(id);
|
||||
Iterable<DownloadId> activeIds() => _sessions.keys;
|
||||
|
||||
/// Cancel and clear all sessions (e.g., on sign-out).
|
||||
Future<void> cancelAll() async {
|
||||
for (final s in _sessions.values) {
|
||||
await s.cancel();
|
||||
}
|
||||
_sessions.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' as c;
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
|
||||
|
||||
@LazySingleton(as: FileSystem)
|
||||
class IoFileSystem implements FileSystem {
|
||||
@override
|
||||
Future<void> ensureParentExists(String path) =>
|
||||
File(path).parent.create(recursive: true);
|
||||
|
||||
@override
|
||||
Future<bool> exists(String path) => File(path).exists();
|
||||
|
||||
@override
|
||||
Future<int> sizeOf(String path) async => (await File(path).stat()).size;
|
||||
|
||||
@override
|
||||
Future<void> truncate(String path) async =>
|
||||
File(path).writeAsBytes(const [], flush: true);
|
||||
|
||||
@override
|
||||
Future<void> createEmpty(String path) => File(path).create(recursive: true);
|
||||
|
||||
@override
|
||||
Future<StreamSink<List<int>>> openAppend(String path) async =>
|
||||
File(path).openWrite(mode: FileMode.append);
|
||||
|
||||
@override
|
||||
Future<bool> verifyFile(
|
||||
String path, {
|
||||
required String expected,
|
||||
required String algo,
|
||||
}) async {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
|
||||
final digestHex = await _computeDigestHex(file, algo);
|
||||
return _constantTimeEquals(digestHex.toLowerCase(), expected.toLowerCase());
|
||||
}
|
||||
|
||||
Future<String> _computeDigestHex(File file, String algo) async {
|
||||
final hash = switch (algo.toLowerCase()) {
|
||||
'md5' => c.md5,
|
||||
'sha256' => c.sha256,
|
||||
_ => throw ArgumentError('Unsupported checksum algo: $algo'),
|
||||
};
|
||||
|
||||
// Stream the file into the hash converter; no extra buffers.
|
||||
final digest = await hash.bind(file.openRead()).first; // -> c.Digest
|
||||
return digest.toString(); // lowercase hex
|
||||
}
|
||||
|
||||
bool _constantTimeEquals(String a, String b) {
|
||||
if (a.length != b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:minio/minio.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
|
||||
|
||||
/// MinIO storage reader implementation.
|
||||
@LazySingleton(as: StorageReader)
|
||||
class MinioStorageReader implements StorageReader {
|
||||
final Minio _client;
|
||||
|
||||
MinioStorageReader(this._client);
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> readFromOffset({
|
||||
required String bucket,
|
||||
required String key,
|
||||
required int startOffset,
|
||||
}) {
|
||||
return _client.getPartialObject(bucket, key, startOffset);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteObjectMeta> stat({
|
||||
required String bucket,
|
||||
required String key,
|
||||
}) async {
|
||||
final s = await _client.statObject(bucket, key);
|
||||
return RemoteObjectMeta(size: s.size ?? 0, eTag: s.etag ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/// Represents a request to download an object from a storage bucket.
|
||||
class DownloadRequest {
|
||||
/// Local file path to save the downloaded object
|
||||
final String destinationPath;
|
||||
|
||||
const DownloadRequest({required this.destinationPath});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/// Represents the result of a download operation.
|
||||
class DownloadResult {
|
||||
/// Local file path where the downloaded object is stored.
|
||||
final String path;
|
||||
|
||||
/// Number of bytes downloaded.
|
||||
final int bytes;
|
||||
|
||||
/// Entity tag (ETag) of the downloaded object.
|
||||
final String eTag;
|
||||
|
||||
const DownloadResult({
|
||||
required this.path,
|
||||
required this.bytes,
|
||||
required this.eTag,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/// Represents metadata for a remote object in a storage bucket.
|
||||
class RemoteObjectMeta {
|
||||
/// Size of the object in bytes.
|
||||
final int size;
|
||||
|
||||
/// Entity tag (ETag) of the object.
|
||||
final String eTag;
|
||||
|
||||
const RemoteObjectMeta({required this.size, required this.eTag});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Interface for interacting with the file system.
|
||||
abstract interface class FileSystem {
|
||||
/// Ensures that the parent directory of the given path exists.
|
||||
Future<void> ensureParentExists(String path);
|
||||
|
||||
/// Checks if a file or directory exists at the given path.
|
||||
Future<bool> exists(String path);
|
||||
|
||||
/// Returns the size of the file at the given path.
|
||||
Future<int> sizeOf(String path);
|
||||
|
||||
/// Truncates the file at the given path to zero length.
|
||||
Future<void> truncate(String path);
|
||||
|
||||
/// Creates an empty file at the given path.
|
||||
Future<void> createEmpty(String path);
|
||||
|
||||
/// Opens a stream sink for appending data to the file at the given path.
|
||||
Future<StreamSink<List<int>>> openAppend(String path);
|
||||
|
||||
/// Verifies the integrity of a file at the given path.
|
||||
Future<bool> verifyFile(
|
||||
String path, {
|
||||
required String expected,
|
||||
required String algo,
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
|
||||
|
||||
/// Interface for reading data from storage.
|
||||
abstract interface class StorageReader {
|
||||
/// Retrieves metadata about a remote object.
|
||||
Future<RemoteObjectMeta> stat({required String bucket, required String key});
|
||||
|
||||
/// Reads data from a remote object starting at the specified offset.
|
||||
Future<Stream<List<int>>> readFromOffset({
|
||||
required String bucket,
|
||||
required String key,
|
||||
required int startOffset,
|
||||
});
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/core/use_case.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/downloader/domain/entities/download_result.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
|
||||
|
||||
final class DownloadObjectUseCaseInput {
|
||||
final DownloadRequest request;
|
||||
|
||||
final FutureOr<bool> Function()? isCancelled;
|
||||
|
||||
final void Function(DownloadResult result)? onComplete;
|
||||
|
||||
const DownloadObjectUseCaseInput({
|
||||
required this.request,
|
||||
this.isCancelled,
|
||||
this.onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
@lazySingleton
|
||||
class DownloadObjectUseCase
|
||||
implements StreamUseCase<DownloadObjectUseCaseInput, DownloadProgress> {
|
||||
final StorageReader _storage;
|
||||
|
||||
final FileSystem _fileSystem;
|
||||
|
||||
const DownloadObjectUseCase({
|
||||
required StorageReader storage,
|
||||
required FileSystem fileSystem,
|
||||
}) : _storage = storage,
|
||||
_fileSystem = fileSystem;
|
||||
|
||||
@override
|
||||
Stream<DownloadProgress> call(DownloadObjectUseCaseInput input) async* {
|
||||
final request = input.request;
|
||||
|
||||
final meta = await _storage.stat(
|
||||
bucket: Config.bucketName,
|
||||
key: Config.objectName,
|
||||
);
|
||||
final total = meta.size;
|
||||
|
||||
final pathToFile = '${request.destinationPath}${Config.objectName}';
|
||||
|
||||
await _fileSystem.ensureParentExists(pathToFile);
|
||||
|
||||
int offset = 0;
|
||||
if (await _fileSystem.exists(pathToFile)) {
|
||||
offset = await _fileSystem.sizeOf(pathToFile);
|
||||
if (offset > total) {
|
||||
await _fileSystem.truncate(pathToFile);
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
if (offset == total) {
|
||||
input.onComplete?.call(
|
||||
DownloadResult(path: pathToFile, bytes: offset, eTag: meta.eTag),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await _fileSystem.createEmpty(pathToFile);
|
||||
}
|
||||
|
||||
final objectStream = await _storage.readFromOffset(
|
||||
bucket: Config.bucketName,
|
||||
key: Config.objectName,
|
||||
startOffset: offset,
|
||||
);
|
||||
final sink = await _fileSystem.openAppend(pathToFile);
|
||||
final controller = StreamController<DownloadProgress>();
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
int lastBytes = 0;
|
||||
int downloaded = offset;
|
||||
|
||||
Timer? ticker;
|
||||
|
||||
void tick() {
|
||||
final elapsedMs = sw.elapsedMilliseconds;
|
||||
final bps = elapsedMs == 0 ? 0 : (lastBytes * 1000) ~/ elapsedMs;
|
||||
final remaining = (total - downloaded).clamp(0, total);
|
||||
final eta = bps > 0 ? Duration(seconds: remaining ~/ bps) : Duration.zero;
|
||||
|
||||
controller.add(
|
||||
DownloadProgress(
|
||||
downloaded: downloaded,
|
||||
total: total,
|
||||
speed: bps,
|
||||
eta: eta,
|
||||
),
|
||||
);
|
||||
lastBytes = 0;
|
||||
sw
|
||||
..reset()
|
||||
..start();
|
||||
}
|
||||
|
||||
ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick());
|
||||
|
||||
late StreamSubscription<List<int>> sub;
|
||||
controller.onCancel = () async {
|
||||
await sub.cancel();
|
||||
await sink.close();
|
||||
ticker?.cancel();
|
||||
};
|
||||
|
||||
sub = objectStream.listen(
|
||||
(chunk) async {
|
||||
if (input.isCancelled != null && await input.isCancelled!.call()) {
|
||||
ticker?.cancel();
|
||||
await sub.cancel();
|
||||
await sink.close();
|
||||
controller.addError(const CancelledException());
|
||||
await controller.close();
|
||||
return;
|
||||
}
|
||||
sink.add(chunk);
|
||||
final n = chunk.length;
|
||||
downloaded += n;
|
||||
lastBytes += n;
|
||||
},
|
||||
onError: (e, st) async {
|
||||
ticker?.cancel();
|
||||
await sink.close();
|
||||
await controller.close();
|
||||
throw _mapError(e);
|
||||
},
|
||||
onDone: () async {
|
||||
ticker?.cancel();
|
||||
tick(); // final snapshot
|
||||
await sink.close();
|
||||
await controller.close();
|
||||
input.onComplete?.call(
|
||||
DownloadResult(path: pathToFile, bytes: downloaded, eTag: meta.eTag),
|
||||
);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
yield* controller.stream;
|
||||
}
|
||||
|
||||
DownloadException _mapError(Object e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('SocketException') || s.contains('HandshakeException')) {
|
||||
return NetworkException(s);
|
||||
}
|
||||
if (s.contains('Minio')) return RemoteException(s);
|
||||
return IoException(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/core/use_case.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/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_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
|
||||
final class ClientSyncRequest {
|
||||
final String installationDir;
|
||||
final String accessToken;
|
||||
final ClientManifest? manifest;
|
||||
|
||||
const ClientSyncRequest({
|
||||
required this.installationDir,
|
||||
required this.accessToken,
|
||||
this.manifest,
|
||||
});
|
||||
}
|
||||
|
||||
final class ClientSyncUseCaseInput {
|
||||
final ClientSyncRequest request;
|
||||
final FutureOr<bool> Function()? isCancelled;
|
||||
|
||||
const ClientSyncUseCaseInput({required this.request, this.isCancelled});
|
||||
}
|
||||
|
||||
@lazySingleton
|
||||
class ClientSyncUseCase
|
||||
implements StreamUseCase<ClientSyncUseCaseInput, ClientSyncStatus> {
|
||||
ClientSyncUseCase({
|
||||
required LauncherApiClient launcherApiClient,
|
||||
required GameInstallationService installationService,
|
||||
}) : _launcherApiClient = launcherApiClient,
|
||||
_installationService = installationService;
|
||||
|
||||
final LauncherApiClient _launcherApiClient;
|
||||
final GameInstallationService _installationService;
|
||||
|
||||
@override
|
||||
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
|
||||
final controller = StreamController<ClientSyncStatus>();
|
||||
unawaited(_run(input, controller));
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
ClientSyncUseCaseInput input,
|
||||
StreamController<ClientSyncStatus> controller,
|
||||
) async {
|
||||
try {
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.fetchingManifest,
|
||||
progress: const DownloadProgress.initial(),
|
||||
message: 'Loading client manifest...',
|
||||
currentPath: null,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: null,
|
||||
processedFiles: 0,
|
||||
totalFiles: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final manifest =
|
||||
input.request.manifest ??
|
||||
await _launcherApiClient.fetchManifest(input.request.accessToken);
|
||||
final remoteBuildHash = manifest.buildHash;
|
||||
|
||||
final localSnapshot = await _installationService.scanInstallation(
|
||||
input.request.installationDir,
|
||||
isCancelled: input.isCancelled,
|
||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.scanningLocalFiles,
|
||||
progress: progress,
|
||||
message: 'Hashing local files...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: totalFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
|
||||
final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot);
|
||||
final staleFiles = _resolveStaleFiles(manifest, localSnapshot);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.comparingFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: localSnapshot.files.length,
|
||||
total: manifest.files.length,
|
||||
eta: Duration.zero,
|
||||
),
|
||||
message: _buildComparisonMessage(
|
||||
localSnapshot: localSnapshot,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
filesToDownload: filesToDownload.length,
|
||||
staleFiles: staleFiles.length,
|
||||
),
|
||||
currentPath: null,
|
||||
localBuildHash: localSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: localSnapshot.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
|
||||
if (localSnapshot.buildHash == remoteBuildHash &&
|
||||
filesToDownload.isEmpty &&
|
||||
staleFiles.isEmpty) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.completed,
|
||||
progress: _fullProgress(manifest),
|
||||
message: 'Client is up to date.',
|
||||
currentPath: null,
|
||||
localBuildHash: localSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: manifest.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (staleFiles.isNotEmpty) {
|
||||
await _removeStaleFiles(
|
||||
controller: controller,
|
||||
installationDir: input.request.installationDir,
|
||||
staleFiles: staleFiles,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
isCancelled: input.isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
if (filesToDownload.isNotEmpty) {
|
||||
await _downloadChangedFiles(
|
||||
controller: controller,
|
||||
manifest: manifest,
|
||||
accessToken: input.request.accessToken,
|
||||
installationDir: input.request.installationDir,
|
||||
filesToDownload: filesToDownload,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
isCancelled: input.isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
final verifiedSnapshot = await _installationService.scanInstallation(
|
||||
input.request.installationDir,
|
||||
isCancelled: input.isCancelled,
|
||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.verifyingInstallation,
|
||||
progress: progress,
|
||||
message: 'Verifying updated client...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: totalFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
_ensureVerificationPassed(
|
||||
manifest: manifest,
|
||||
snapshot: verifiedSnapshot,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.completed,
|
||||
progress: _fullProgress(manifest),
|
||||
message: 'Client is ready to play.',
|
||||
currentPath: null,
|
||||
localBuildHash: verifiedSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: manifest.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<ClientManifestFile> _resolveFilesToDownload(
|
||||
ClientManifest manifest,
|
||||
ClientInstallationSnapshot snapshot,
|
||||
) {
|
||||
final localFilesByPath = snapshot.filesByPath;
|
||||
|
||||
return manifest.files.where((remoteFile) {
|
||||
final localFile = localFilesByPath[remoteFile.path];
|
||||
return localFile == null ||
|
||||
localFile.sha256 != remoteFile.sha256 ||
|
||||
localFile.size != remoteFile.size;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> _resolveStaleFiles(
|
||||
ClientManifest manifest,
|
||||
ClientInstallationSnapshot snapshot,
|
||||
) {
|
||||
final remoteFiles = manifest.filesByPath;
|
||||
|
||||
return snapshot.files
|
||||
.where((localFile) => !remoteFiles.containsKey(localFile.path))
|
||||
.map((file) => file.path)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _removeStaleFiles({
|
||||
required StreamController<ClientSyncStatus> controller,
|
||||
required String installationDir,
|
||||
required List<String> staleFiles,
|
||||
required String remoteBuildHash,
|
||||
required FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
var removedFiles = 0;
|
||||
|
||||
for (final relativePath in staleFiles) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
await _installationService.deleteRelativeFile(
|
||||
installationDir,
|
||||
relativePath,
|
||||
);
|
||||
removedFiles += 1;
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.removingStaleFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: removedFiles,
|
||||
total: staleFiles.length,
|
||||
eta: Duration.zero,
|
||||
),
|
||||
message: 'Removing obsolete files...',
|
||||
currentPath: relativePath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: removedFiles,
|
||||
totalFiles: staleFiles.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadChangedFiles({
|
||||
required StreamController<ClientSyncStatus> controller,
|
||||
required ClientManifest manifest,
|
||||
required String accessToken,
|
||||
required String installationDir,
|
||||
required List<ClientManifestFile> filesToDownload,
|
||||
required String remoteBuildHash,
|
||||
required FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
final totalBytes = filesToDownload.fold<int>(
|
||||
0,
|
||||
(sum, file) => sum + file.size,
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var downloadedBytes = 0;
|
||||
var bytesSinceLastTick = 0;
|
||||
var processedFiles = 0;
|
||||
String? currentPath;
|
||||
|
||||
void emitProgress() {
|
||||
final elapsedMs = stopwatch.elapsedMilliseconds;
|
||||
final speed = elapsedMs == 0
|
||||
? 0
|
||||
: (bytesSinceLastTick * 1000) ~/ elapsedMs;
|
||||
final remainingBytes = totalBytes - downloadedBytes;
|
||||
final eta = speed > 0
|
||||
? Duration(seconds: remainingBytes ~/ speed)
|
||||
: Duration.zero;
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.downloadingFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: speed,
|
||||
downloaded: downloadedBytes,
|
||||
total: totalBytes,
|
||||
eta: eta,
|
||||
),
|
||||
message: 'Downloading changed files...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: filesToDownload.length,
|
||||
),
|
||||
);
|
||||
|
||||
bytesSinceLastTick = 0;
|
||||
stopwatch
|
||||
..reset()
|
||||
..start();
|
||||
}
|
||||
|
||||
final ticker = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => emitProgress(),
|
||||
);
|
||||
|
||||
try {
|
||||
for (final file in filesToDownload) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
|
||||
currentPath = file.path;
|
||||
final destinationPath = _installationService.resolveClientPath(
|
||||
installationDir,
|
||||
file.path,
|
||||
);
|
||||
final temporaryPath = '$destinationPath.moonwell.part';
|
||||
|
||||
await _installationService.deleteFileIfExists(temporaryPath);
|
||||
|
||||
await _launcherApiClient.downloadFile(
|
||||
accessToken: accessToken,
|
||||
file: file,
|
||||
destinationPath: temporaryPath,
|
||||
isCancelled: isCancelled,
|
||||
onChunkReceived: (chunkBytes) {
|
||||
downloadedBytes += chunkBytes;
|
||||
bytesSinceLastTick += chunkBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final isValid = await _installationService.verifySha256(
|
||||
temporaryPath,
|
||||
file.sha256,
|
||||
);
|
||||
if (!isValid) {
|
||||
await _installationService.deleteFileIfExists(temporaryPath);
|
||||
throw LauncherSyncException(
|
||||
'Downloaded file failed verification: ${file.path}',
|
||||
);
|
||||
}
|
||||
|
||||
await _installationService.replaceFile(
|
||||
temporaryPath: temporaryPath,
|
||||
destinationPath: destinationPath,
|
||||
);
|
||||
|
||||
processedFiles += 1;
|
||||
emitProgress();
|
||||
}
|
||||
} finally {
|
||||
ticker.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureVerificationPassed({
|
||||
required ClientManifest manifest,
|
||||
required ClientInstallationSnapshot snapshot,
|
||||
required String remoteBuildHash,
|
||||
}) {
|
||||
final localFilesByPath = snapshot.filesByPath;
|
||||
|
||||
final mismatchedFiles = manifest.files.where((remoteFile) {
|
||||
final localFile = localFilesByPath[remoteFile.path];
|
||||
return localFile == null ||
|
||||
localFile.sha256 != remoteFile.sha256 ||
|
||||
localFile.size != remoteFile.size;
|
||||
}).toList();
|
||||
|
||||
final extraFiles = snapshot.files
|
||||
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
|
||||
.toList();
|
||||
|
||||
if (snapshot.buildHash != remoteBuildHash ||
|
||||
mismatchedFiles.isNotEmpty ||
|
||||
extraFiles.isNotEmpty) {
|
||||
throw LauncherSyncException('Client verification failed after update.');
|
||||
}
|
||||
}
|
||||
|
||||
String _buildComparisonMessage({
|
||||
required ClientInstallationSnapshot localSnapshot,
|
||||
required String remoteBuildHash,
|
||||
required int filesToDownload,
|
||||
required int staleFiles,
|
||||
}) {
|
||||
if (localSnapshot.buildHash == remoteBuildHash &&
|
||||
filesToDownload == 0 &&
|
||||
staleFiles == 0) {
|
||||
return 'Local build matches the server manifest.';
|
||||
}
|
||||
|
||||
return 'Update required: $filesToDownload file(s) to download, '
|
||||
'$staleFiles file(s) to remove.';
|
||||
}
|
||||
|
||||
DownloadProgress _fullProgress(ClientManifest manifest) {
|
||||
return DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: manifest.totalSize,
|
||||
total: manifest.totalSize,
|
||||
eta: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
void _emit(
|
||||
StreamController<ClientSyncStatus> controller,
|
||||
ClientSyncStatus status,
|
||||
) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
|
||||
if (isCancelled != null && await isCancelled()) {
|
||||
throw const CancelledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/config.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/domain/entities/client_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
typedef InstallationScanProgressCallback =
|
||||
void Function(
|
||||
DownloadProgress progress,
|
||||
String currentPath,
|
||||
int processedFiles,
|
||||
int totalFiles,
|
||||
);
|
||||
|
||||
@lazySingleton
|
||||
class GameInstallationService {
|
||||
Future<ClientInstallationSnapshot> scanInstallation(
|
||||
String installationDir, {
|
||||
InstallationScanProgressCallback? onProgress,
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
final rootDirectory = Directory(installationDir);
|
||||
if (!await rootDirectory.exists()) {
|
||||
return ClientInstallationSnapshot.fromFiles(const []);
|
||||
}
|
||||
|
||||
final pendingFiles = <_PendingFile>[];
|
||||
await _collectFiles(
|
||||
rootDirectory,
|
||||
rootDirectory.path,
|
||||
pendingFiles,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
|
||||
pendingFiles.sort(
|
||||
(left, right) => left.relativePath.compareTo(right.relativePath),
|
||||
);
|
||||
|
||||
final totalBytes = pendingFiles.fold<int>(
|
||||
0,
|
||||
(sum, pendingFile) => sum + pendingFile.size,
|
||||
);
|
||||
|
||||
final files = <LocalClientFile>[];
|
||||
var processedBytes = 0;
|
||||
var processedFiles = 0;
|
||||
|
||||
for (final pendingFile in pendingFiles) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
|
||||
final sha256 = await computeSha256(pendingFile.file.path);
|
||||
processedBytes += pendingFile.size;
|
||||
processedFiles += 1;
|
||||
|
||||
files.add(
|
||||
LocalClientFile(
|
||||
path: pendingFile.relativePath,
|
||||
size: pendingFile.size,
|
||||
sha256: sha256,
|
||||
),
|
||||
);
|
||||
|
||||
onProgress?.call(
|
||||
DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: processedBytes,
|
||||
total: totalBytes,
|
||||
eta: Duration.zero,
|
||||
),
|
||||
pendingFile.relativePath,
|
||||
processedFiles,
|
||||
pendingFiles.length,
|
||||
);
|
||||
}
|
||||
|
||||
return ClientInstallationSnapshot.fromFiles(files);
|
||||
}
|
||||
|
||||
Future<String> computeSha256(String filePath) async {
|
||||
final digest = await sha256.bind(File(filePath).openRead()).first;
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
Future<bool> verifySha256(String filePath, String expectedHash) async {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final actualHash = await computeSha256(filePath);
|
||||
return actualHash.toLowerCase() == expectedHash.toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> deleteRelativeFile(
|
||||
String installationDir,
|
||||
String relativePath,
|
||||
) async {
|
||||
final file = File(resolveClientPath(installationDir, relativePath));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> replaceFile({
|
||||
required String temporaryPath,
|
||||
required String destinationPath,
|
||||
}) async {
|
||||
final destinationFile = File(destinationPath);
|
||||
await destinationFile.parent.create(recursive: true);
|
||||
|
||||
if (await destinationFile.exists()) {
|
||||
await destinationFile.delete();
|
||||
}
|
||||
|
||||
await File(temporaryPath).rename(destinationPath);
|
||||
}
|
||||
|
||||
Future<void> deleteFileIfExists(String filePath) async {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache(String installationDir) async {
|
||||
final cacheDirectory = Directory(
|
||||
p.join(installationDir, Config.cacheDirectoryName),
|
||||
);
|
||||
if (await cacheDirectory.exists()) {
|
||||
await cacheDirectory.delete(recursive: true);
|
||||
}
|
||||
|
||||
await cacheDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
Future<void> launchGame(String installationDir) async {
|
||||
final executablePath = getExecutablePath(installationDir);
|
||||
final executable = File(executablePath);
|
||||
|
||||
if (!await executable.exists()) {
|
||||
throw const LauncherSyncException(
|
||||
'Wow.exe was not found in the selected folder.',
|
||||
);
|
||||
}
|
||||
|
||||
await Process.start(
|
||||
executablePath,
|
||||
const <String>[],
|
||||
workingDirectory: installationDir,
|
||||
mode: ProcessStartMode.detached,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> hasClientExecutable(String installationDir) async {
|
||||
return File(getExecutablePath(installationDir)).exists();
|
||||
}
|
||||
|
||||
String getExecutablePath(String installationDir) {
|
||||
return p.join(installationDir, Config.gameExecutableName);
|
||||
}
|
||||
|
||||
String resolveClientPath(String installationDir, String relativePath) {
|
||||
final normalizedPath = normalizeClientPath(relativePath);
|
||||
final pathSegments = p.posix
|
||||
.split(normalizedPath)
|
||||
.where((segment) => segment.isNotEmpty && segment != '.')
|
||||
.toList();
|
||||
|
||||
if (pathSegments.isEmpty ||
|
||||
pathSegments.any((segment) => segment == '..') ||
|
||||
p.posix.isAbsolute(normalizedPath)) {
|
||||
throw LauncherSyncException('Invalid client file path: $relativePath');
|
||||
}
|
||||
|
||||
return p.joinAll([installationDir, ...pathSegments]);
|
||||
}
|
||||
|
||||
Future<void> _collectFiles(
|
||||
Directory directory,
|
||||
String rootPath,
|
||||
List<_PendingFile> pendingFiles, {
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
|
||||
final relativePath = normalizeClientPath(
|
||||
p.relative(entity.path, from: rootPath),
|
||||
);
|
||||
|
||||
if (relativePath.isEmpty || relativePath == '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_isExcludedPath(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity is Directory) {
|
||||
await _collectFiles(
|
||||
entity,
|
||||
rootPath,
|
||||
pendingFiles,
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity is! File) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final stat = await entity.stat();
|
||||
pendingFiles.add(
|
||||
_PendingFile(file: entity, relativePath: relativePath, size: stat.size),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isExcludedPath(String relativePath) {
|
||||
final segments = p.posix.split(normalizeClientPath(relativePath));
|
||||
if (segments.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Config.ignoredVerificationDirectories.contains(
|
||||
segments.first.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
|
||||
if (isCancelled != null && await isCancelled()) {
|
||||
throw const CancelledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PendingFile {
|
||||
final File file;
|
||||
final String relativePath;
|
||||
final int size;
|
||||
|
||||
const _PendingFile({
|
||||
required this.file,
|
||||
required this.relativePath,
|
||||
required this.size,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||
|
||||
@lazySingleton
|
||||
class LauncherApiClient {
|
||||
LauncherApiClient()
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(minutes: 30),
|
||||
headers: const {HttpHeaders.acceptHeader: 'application/json'},
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<LauncherSession> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.postUri(
|
||||
_resolveUri('api/launcher/login'),
|
||||
data: {'username': username, 'password': password},
|
||||
);
|
||||
|
||||
return LauncherSession.fromJson(_coerceMap(response.data));
|
||||
} on DioException catch (error) {
|
||||
throw LauncherApiException(_extractErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||
try {
|
||||
final response = await _dio.getUri(
|
||||
_resolveUri('api/launcher/manifest'),
|
||||
options: _authorizedOptions(accessToken),
|
||||
);
|
||||
|
||||
return ClientManifest.fromJson(_coerceMap(response.data));
|
||||
} on DioException catch (error) {
|
||||
throw LauncherApiException(_extractErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> downloadFile({
|
||||
required String accessToken,
|
||||
required ClientManifestFile file,
|
||||
required String destinationPath,
|
||||
required FutureOr<bool> Function()? isCancelled,
|
||||
required void Function(int chunkBytes) onChunkReceived,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.getUri<ResponseBody>(
|
||||
_resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'),
|
||||
options: _authorizedOptions(
|
||||
accessToken,
|
||||
responseType: ResponseType.stream,
|
||||
headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'},
|
||||
),
|
||||
);
|
||||
|
||||
final stream = response.data?.stream;
|
||||
if (stream == null) {
|
||||
throw const LauncherApiException(
|
||||
'Server returned an empty file stream.',
|
||||
);
|
||||
}
|
||||
|
||||
final sink = File(destinationPath).openWrite();
|
||||
|
||||
try {
|
||||
await for (final chunk in stream) {
|
||||
if (isCancelled != null && await isCancelled()) {
|
||||
throw const CancelledException();
|
||||
}
|
||||
|
||||
sink.add(chunk);
|
||||
onChunkReceived(chunk.length);
|
||||
}
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
} on DioException catch (error) {
|
||||
throw LauncherApiException(_extractErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
Options _authorizedOptions(
|
||||
String accessToken, {
|
||||
ResponseType? responseType,
|
||||
Map<String, Object?>? headers,
|
||||
}) {
|
||||
return Options(
|
||||
responseType: responseType,
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader: 'Bearer $accessToken',
|
||||
...?headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Uri _resolveUri(String path) {
|
||||
final rawBaseUrl = Config.launcherApiBaseUrl.trim();
|
||||
if (rawBaseUrl.isEmpty) {
|
||||
throw const LauncherConfigurationException(
|
||||
'Set MOONWELL_API_BASE_URL before using the launcher API.',
|
||||
);
|
||||
}
|
||||
|
||||
final baseUri = Uri.parse(
|
||||
rawBaseUrl.endsWith('/') ? rawBaseUrl : '$rawBaseUrl/',
|
||||
);
|
||||
|
||||
if (!baseUri.hasScheme || baseUri.host.isEmpty) {
|
||||
throw const LauncherConfigurationException(
|
||||
'MOONWELL_API_BASE_URL must be an absolute URL.',
|
||||
);
|
||||
}
|
||||
|
||||
return baseUri.resolve(path);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _coerceMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data is Map) {
|
||||
return data.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
|
||||
throw const LauncherApiException('Unexpected API response payload.');
|
||||
}
|
||||
|
||||
String _extractErrorMessage(DioException error) {
|
||||
final data = error.response?.data;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
if (data is Map) {
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
if (data is String && data.trim().isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.error is SocketException) {
|
||||
return 'Unable to reach the launcher API.';
|
||||
}
|
||||
|
||||
return error.message ?? 'Launcher API request failed.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
abstract interface class ClientHashEntry {
|
||||
String get path;
|
||||
int get size;
|
||||
String get sha256;
|
||||
}
|
||||
|
||||
String normalizeClientPath(String rawPath) {
|
||||
final sanitized = rawPath.replaceAll('\\', '/').trim();
|
||||
final normalized = p.posix.normalize(sanitized);
|
||||
return normalized.startsWith('./') ? normalized.substring(2) : normalized;
|
||||
}
|
||||
|
||||
String computeBuildHash(Iterable<ClientHashEntry> entries) {
|
||||
final canonicalEntries =
|
||||
entries
|
||||
.map(
|
||||
(entry) =>
|
||||
'${normalizeClientPath(entry.path)}:${entry.size}:${entry.sha256.toLowerCase()}',
|
||||
)
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
final payload = canonicalEntries.join('\n');
|
||||
return sha256.convert(utf8.encode(payload)).toString();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'client_hash_entry.dart';
|
||||
import 'local_client_file.dart';
|
||||
|
||||
final class ClientInstallationSnapshot {
|
||||
final List<LocalClientFile> files;
|
||||
final String buildHash;
|
||||
|
||||
const ClientInstallationSnapshot({
|
||||
required this.files,
|
||||
required this.buildHash,
|
||||
});
|
||||
|
||||
factory ClientInstallationSnapshot.fromFiles(List<LocalClientFile> files) {
|
||||
return ClientInstallationSnapshot(
|
||||
files: files,
|
||||
buildHash: computeBuildHash(files),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, LocalClientFile> get filesByPath => {
|
||||
for (final file in files) normalizeClientPath(file.path): file,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'client_hash_entry.dart';
|
||||
import 'client_manifest_file.dart';
|
||||
|
||||
final class ClientManifest {
|
||||
final List<ClientManifestFile> files;
|
||||
final String buildHash;
|
||||
|
||||
const ClientManifest({required this.files, required this.buildHash});
|
||||
|
||||
factory ClientManifest.fromJson(Map<String, dynamic> json) {
|
||||
final files =
|
||||
((json['files'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ClientManifestFile.fromJson)
|
||||
.toList())
|
||||
.cast<ClientManifestFile>();
|
||||
|
||||
return ClientManifest(files: files, buildHash: computeBuildHash(files));
|
||||
}
|
||||
|
||||
Map<String, ClientManifestFile> get filesByPath => {
|
||||
for (final file in files) normalizeClientPath(file.path): file,
|
||||
};
|
||||
|
||||
int get totalSize => files.fold(0, (sum, file) => sum + file.size);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'client_hash_entry.dart';
|
||||
|
||||
final class ClientManifestFile implements ClientHashEntry {
|
||||
@override
|
||||
final String path;
|
||||
|
||||
@override
|
||||
final int size;
|
||||
|
||||
@override
|
||||
final String sha256;
|
||||
|
||||
const ClientManifestFile({
|
||||
required this.path,
|
||||
required this.size,
|
||||
required this.sha256,
|
||||
});
|
||||
|
||||
factory ClientManifestFile.fromJson(Map<String, dynamic> json) {
|
||||
return ClientManifestFile(
|
||||
path: normalizeClientPath(json['path'] as String? ?? ''),
|
||||
size: (json['size'] as num? ?? 0).toInt(),
|
||||
sha256: (json['sha256'] as String? ?? '').toLowerCase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
|
||||
enum ClientSyncStage {
|
||||
fetchingManifest,
|
||||
scanningLocalFiles,
|
||||
comparingFiles,
|
||||
removingStaleFiles,
|
||||
downloadingFiles,
|
||||
verifyingInstallation,
|
||||
completed,
|
||||
}
|
||||
|
||||
final class ClientSyncStatus {
|
||||
final ClientSyncStage stage;
|
||||
final DownloadProgress progress;
|
||||
final String message;
|
||||
final String? currentPath;
|
||||
final String? localBuildHash;
|
||||
final String? remoteBuildHash;
|
||||
final int processedFiles;
|
||||
final int totalFiles;
|
||||
|
||||
const ClientSyncStatus({
|
||||
required this.stage,
|
||||
required this.progress,
|
||||
required this.message,
|
||||
required this.currentPath,
|
||||
required this.localBuildHash,
|
||||
required this.remoteBuildHash,
|
||||
required this.processedFiles,
|
||||
required this.totalFiles,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
sealed class LauncherException implements Exception {
|
||||
final String message;
|
||||
|
||||
const LauncherException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
final class LauncherConfigurationException extends LauncherException {
|
||||
const LauncherConfigurationException(super.message);
|
||||
}
|
||||
|
||||
final class LauncherApiException extends LauncherException {
|
||||
const LauncherApiException(super.message);
|
||||
}
|
||||
|
||||
final class LauncherSyncException extends LauncherException {
|
||||
const LauncherSyncException(super.message);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
final class LauncherSession {
|
||||
final String accessToken;
|
||||
final String tokenType;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const LauncherSession({
|
||||
required this.accessToken,
|
||||
required this.tokenType,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory LauncherSession.fromJson(Map<String, dynamic> json) {
|
||||
return LauncherSession(
|
||||
accessToken: json['access_token'] as String? ?? '',
|
||||
tokenType: json['token_type'] as String? ?? 'Bearer',
|
||||
expiresAt: _parseDateTime(json['expires_at'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDateTime(String? rawValue) {
|
||||
if (rawValue == null || rawValue.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTime.tryParse(rawValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'client_hash_entry.dart';
|
||||
|
||||
final class LocalClientFile implements ClientHashEntry {
|
||||
@override
|
||||
final String path;
|
||||
|
||||
@override
|
||||
final int size;
|
||||
|
||||
@override
|
||||
final String sha256;
|
||||
|
||||
const LocalClientFile({
|
||||
required this.path,
|
||||
required this.size,
|
||||
required this.sha256,
|
||||
});
|
||||
}
|
||||
+8
-5
@@ -214,7 +214,7 @@ class _LeftPane extends StatelessWidget {
|
||||
Text(
|
||||
'Classic MMO Launcher',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: cs.onSurface.withOpacity(0.85),
|
||||
color: cs.onSurface.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
@@ -289,7 +289,7 @@ class _LeftPane extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceVariant.withOpacity(0.5),
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outline),
|
||||
boxShadow: (deco.cardGlow),
|
||||
@@ -332,7 +332,7 @@ class _GildedProgressBar extends StatelessWidget {
|
||||
return Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceVariant,
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: cs.outline),
|
||||
),
|
||||
@@ -352,7 +352,10 @@ class _GildedProgressBar extends StatelessWidget {
|
||||
// Subtle top highlight
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(height: 6, color: Colors.white.withOpacity(0.08)),
|
||||
child: Container(
|
||||
height: 6,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -446,7 +449,7 @@ class _PatchNotesPane extends StatelessWidget {
|
||||
child: Text(
|
||||
s,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withOpacity(0.9),
|
||||
color: cs.onSurface.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
|
||||
import 'package:moonwell_launcher/service_container.dart';
|
||||
|
||||
final _log = Logger('Downloader');
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
await configureDependencies();
|
||||
_setupLogging();
|
||||
|
||||
final manager = getIt<DownloadManager>();
|
||||
|
||||
// Start the download and keep a handle to the subscription
|
||||
final stream = manager.start(
|
||||
id: 'client',
|
||||
request: DownloadRequest(
|
||||
destinationPath: '/Users/kalistratovm/Games/MoonWell/client.zip',
|
||||
),
|
||||
);
|
||||
|
||||
final done = Completer<int>(); // exit code completer
|
||||
late StreamSubscription sub;
|
||||
|
||||
// Graceful shutdown on Ctrl-C / SIGTERM
|
||||
final sigintSub = ProcessSignal.sigint.watch();
|
||||
sigintSub.listen((_) async {
|
||||
_log.warning('SIGINT received. Cancelling all downloads…');
|
||||
await manager.cancelAll();
|
||||
await sub.cancel();
|
||||
if (!done.isCompleted) {
|
||||
done.complete(130); // 130 = terminated by Ctrl-C
|
||||
}
|
||||
});
|
||||
|
||||
final sigtermSub = ProcessSignal.sigterm.watch().listen((_) async {
|
||||
_log.warning('SIGTERM received. Cancelling all downloads…');
|
||||
await manager.cancelAll();
|
||||
await sub.cancel();
|
||||
done.complete(143); // 143 = terminated by SIGTERM
|
||||
});
|
||||
|
||||
sub = stream.listen(
|
||||
(p) {
|
||||
_log.info('Speed: ${p.speed}');
|
||||
_log.info('ETA: ${p.eta}');
|
||||
_log.info('Done: ${p.downloaded}/${p.total}');
|
||||
},
|
||||
onError: (e, st) async {
|
||||
_log.severe('Download error', e, st);
|
||||
await sigtermSub.cancel();
|
||||
done.complete(1);
|
||||
},
|
||||
onDone: () async {
|
||||
_log.info('Download completed');
|
||||
await sigtermSub.cancel();
|
||||
done.complete(0);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
// ⬇️ Keep process alive until one of the completions above fires
|
||||
final code = await done.future;
|
||||
exit(code);
|
||||
}
|
||||
|
||||
void _setupLogging() {
|
||||
Logger.root.level = Level.INFO;
|
||||
Logger.root.onRecord.listen((rec) {
|
||||
final color = switch (rec.level) {
|
||||
Level.SEVERE || Level.SHOUT => '\x1B[31m',
|
||||
Level.WARNING => '\x1B[33m',
|
||||
Level.INFO => '\x1B[36m',
|
||||
_ => '\x1B[90m',
|
||||
};
|
||||
stdout.writeln(
|
||||
'$color[${rec.time.toIso8601String()}] '
|
||||
'${rec.level.name.padRight(7)} '
|
||||
'${rec.loggerName}: ${rec.message}\x1B[0m',
|
||||
);
|
||||
if (rec.error != null) stdout.writeln(' Error: ${rec.error}');
|
||||
if (rec.stackTrace != null) stdout.writeln(' Stack: ${rec.stackTrace}');
|
||||
});
|
||||
}
|
||||
@@ -11,20 +11,15 @@
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:get_it/get_it.dart' as _i174;
|
||||
import 'package:injectable/injectable.dart' as _i526;
|
||||
import 'package:minio/minio.dart' as _i875;
|
||||
import 'package:shared_preferences/shared_preferences.dart' as _i460;
|
||||
|
||||
import 'features/downloader/application/download_manager.dart' as _i535;
|
||||
import 'features/downloader/data/io_file_system.dart' as _i173;
|
||||
import 'features/downloader/data/minio_storage_reader.dart' as _i980;
|
||||
import 'features/downloader/domain/repositories/file_system.dart' as _i843;
|
||||
import 'features/downloader/domain/repositories/storage_reader.dart' as _i839;
|
||||
import 'features/downloader/use_cases/download_object_use_case.dart' as _i925;
|
||||
import 'features/launcher/application/client_sync_use_case.dart' as _i757;
|
||||
import 'features/launcher/data/game_installation_service.dart' as _i315;
|
||||
import 'features/launcher/data/launcher_api_client.dart' as _i703;
|
||||
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
|
||||
as _i662;
|
||||
import 'features/preferences/domain/repositories/preferences_repository.dart'
|
||||
as _i44;
|
||||
import 'third_party/minio.dart' as _i285;
|
||||
import 'third_party/shared_preferences.dart' as _i1006;
|
||||
|
||||
const String _flutter = 'flutter';
|
||||
@@ -37,35 +32,28 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
}) async {
|
||||
final gh = _i526.GetItHelper(this, environment, environmentFilter);
|
||||
final sharedPreferencesModule = _$SharedPreferencesModule();
|
||||
final minioModule = _$MinioModule();
|
||||
await gh.factoryAsync<_i460.SharedPreferences>(
|
||||
() => sharedPreferencesModule.prefs,
|
||||
preResolve: true,
|
||||
);
|
||||
gh.lazySingleton<_i875.Minio>(() => minioModule.minioClient);
|
||||
gh.lazySingleton<_i839.StorageReader>(
|
||||
() => _i980.MinioStorageReader(gh<_i875.Minio>()),
|
||||
gh.lazySingleton<_i315.GameInstallationService>(
|
||||
() => _i315.GameInstallationService(),
|
||||
);
|
||||
gh.lazySingleton<_i703.LauncherApiClient>(() => _i703.LauncherApiClient());
|
||||
gh.lazySingleton<_i757.ClientSyncUseCase>(
|
||||
() => _i757.ClientSyncUseCase(
|
||||
launcherApiClient: gh<_i703.LauncherApiClient>(),
|
||||
installationService: gh<_i315.GameInstallationService>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i843.FileSystem>(() => _i173.IoFileSystem());
|
||||
gh.lazySingleton<_i44.PreferencesRepository>(
|
||||
() => _i662.SharedPrefsPreferencesRepository(
|
||||
preferences: gh<_i460.SharedPreferences>(),
|
||||
),
|
||||
registerFor: {_flutter},
|
||||
);
|
||||
gh.lazySingleton<_i925.DownloadObjectUseCase>(
|
||||
() => _i925.DownloadObjectUseCase(
|
||||
storage: gh<_i839.StorageReader>(),
|
||||
fileSystem: gh<_i843.FileSystem>(),
|
||||
),
|
||||
);
|
||||
gh.lazySingleton<_i535.DownloadManager>(
|
||||
() => _i535.DownloadManager(gh<_i925.DownloadObjectUseCase>()),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
|
||||
|
||||
class _$MinioModule extends _i285.MinioModule {}
|
||||
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:minio/minio.dart';
|
||||
import 'package:moonwell_launcher/config.dart';
|
||||
|
||||
@module
|
||||
abstract class MinioModule {
|
||||
@lazySingleton
|
||||
Minio get minioClient => Minio(
|
||||
endPoint: Config.host,
|
||||
accessKey: Config.keyId,
|
||||
secretKey: Config.secret,
|
||||
);
|
||||
}
|
||||
+7
-47
@@ -18,7 +18,7 @@ packages:
|
||||
source: hosted
|
||||
version: "7.7.1"
|
||||
args:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
@@ -49,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
buffer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: buffer
|
||||
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -242,7 +234,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
@@ -336,14 +328,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -376,14 +360,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.1"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -433,7 +409,7 @@ packages:
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
@@ -460,10 +436,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -472,14 +448,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
minio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: minio
|
||||
sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.8"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -600,14 +568,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -785,10 +745,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-5
@@ -34,17 +34,12 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
file: ^7.0.1
|
||||
dio: ^5.9.0
|
||||
minio: ^3.5.8
|
||||
args: ^2.7.0
|
||||
logging: ^1.3.0
|
||||
path: ^1.9.1
|
||||
injectable: ^2.5.1
|
||||
crypto: ^3.0.6
|
||||
get_it: ^8.2.0
|
||||
flutter_bloc: ^9.1.1
|
||||
rxdart: ^0.28.0
|
||||
file_picker: ^10.3.2
|
||||
pixelarticons: ^0.4.0
|
||||
shared_preferences: ^2.5.3
|
||||
@@ -76,6 +71,7 @@ flutter:
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
assets:
|
||||
- assets/background.png
|
||||
- assets/logo.png
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
|
||||
Reference in New Issue
Block a user