MCSI-2 Browse installing directory #19

Merged
squid merged 5 commits from MCSI-2_browse_installing_path into main 2025-07-10 22:38:48 +08:00
31 changed files with 482 additions and 221 deletions

View File

@ -1,4 +1,8 @@
{ {
"dart.flutterSdkPath": ".fvm/versions/3.29.3", "dart.flutterSdkPath": ".fvm/versions/3.29.3",
"dart.lineLength": 120 "dart.sdkPath": ".fvm/versions/3.29.3",
"[dart]": {
"editor.rulers": [120],
"editor.suggest.insertMode": "insert"
}
} }

View File

@ -9,6 +9,9 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
formatter:
page_width: 120
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -0,0 +1,7 @@
import 'dart:typed_data';
import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart';
abstract interface class InstallationApiService {
Future<Uint8List> fetchRemoteFile(Uri url, {DownloadProgressCallback? onProgressChanged});
}

View File

@ -0,0 +1,5 @@
import 'dart:typed_data';
abstract interface class InstallationFileStorage {
Future<void> saveFile(Uint8List fileBytes, String path);
}

View File

@ -0,0 +1,16 @@
import 'package:minecraft_server_installer/main/adapter/gateway/installation_api_service.dart';
import 'package:minecraft_server_installer/main/adapter/gateway/installation_file_storage.dart';
import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart';
class InstallationRepositoryImpl implements InstallationRepository {
final InstallationApiService _apiService;
final InstallationFileStorage _fileStorage;
InstallationRepositoryImpl(this._apiService, this._fileStorage);
@override
Future<void> downloadServerFile(Uri url, String path, {DownloadProgressCallback? onProgressChanged}) async {
final fileBytes = await _apiService.fetchRemoteFile(url, onProgressChanged: onProgressChanged);
await _fileStorage.saveFile(fileBytes, path);
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_state.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/progress_view_model.dart';
import 'package:minecraft_server_installer/main/application/use_case/download_file_use_case.dart';
import 'package:minecraft_server_installer/main/constants.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
import 'package:path/path.dart' as path;
class InstallationBloc extends Bloc<InstallationEvent, InstallationState> {
InstallationBloc(DownloadFileUseCase downloadFileUseCase) : super(const InstallationState.empty()) {
on<InstallationStartedEvent>((_, emit) async {
if (!state.canStartToInstall) {
return;
}
final gameVersion = state.gameVersion!;
final savePath = state.savePath!;
emit(state.copyWith(isLocked: true, downloadProgress: const ProgressViewModel.start()));
await downloadFileUseCase(
gameVersion.url,
path.join(savePath, Constants.serverFileName),
onProgressChanged: (progressValue) => add(_InstallationProgressValueChangedEvent(progressValue)),
);
emit(state.copyWith(isLocked: false, downloadProgress: const ProgressViewModel.complete()));
});
on<_InstallationProgressValueChangedEvent>((event, emit) {
ProgressViewModel newProgress;
if (event.progressValue < 0) {
newProgress = state.downloadProgress.copyWith(value: 0.0);
} else if (event.progressValue > 1) {
newProgress = state.downloadProgress.copyWith(value: 1.0);
} else {
newProgress = state.downloadProgress.copyWith(value: event.progressValue);
}
emit(state.copyWith(downloadProgress: newProgress));
});
on<InstallationConfigurationUpdatedEvent>((event, emit) {
final newState = state.copyWith(
gameVersion: event.gameVersion,
savePath: event.savePath,
);
emit(newState);
});
}
}
sealed class InstallationEvent {}
class InstallationStartedEvent extends InstallationEvent {}
class _InstallationProgressValueChangedEvent extends InstallationEvent {
final double progressValue;
_InstallationProgressValueChangedEvent(this.progressValue);
}
class InstallationConfigurationUpdatedEvent extends InstallationEvent {
final GameVersionViewModel? gameVersion;
final String? savePath;
InstallationConfigurationUpdatedEvent({
this.gameVersion,
this.savePath,
});
}

View File

@ -0,0 +1,52 @@
import 'package:equatable/equatable.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/progress_view_model.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
class InstallationState with EquatableMixin {
final GameVersionViewModel? gameVersion;
final String? savePath;
final ProgressViewModel downloadProgress;
final bool isLocked;
const InstallationState({
required this.gameVersion,
required this.savePath,
required this.downloadProgress,
required this.isLocked,
});
const InstallationState.empty()
: this(
gameVersion: null,
savePath: null,
downloadProgress: const ProgressViewModel.zero(),
isLocked: false,
);
@override
List<Object?> get props => [
gameVersion,
savePath,
downloadProgress,
isLocked,
];
InstallationState copyWith({
GameVersionViewModel? gameVersion,
String? savePath,
ProgressViewModel? downloadProgress,
bool? isLocked,
}) =>
InstallationState(
gameVersion: gameVersion ?? this.gameVersion,
savePath: savePath ?? this.savePath,
downloadProgress: downloadProgress ?? this.downloadProgress,
isLocked: isLocked ?? this.isLocked,
);
bool get isGameVersionSelected => gameVersion != null;
bool get isSavePathSelected => savePath != null && savePath!.isNotEmpty;
bool get canStartToInstall => isGameVersionSelected && isSavePathSelected && !isLocked;
}

View File

@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
class ProgressViewModel with EquatableMixin {
/// The value should between 0.0 and 1.0.
final double value;
final bool isInProgress;
const ProgressViewModel({
required this.value,
required this.isInProgress,
});
const ProgressViewModel.zero() : this(value: 0.0, isInProgress: false);
const ProgressViewModel.start() : this(value: 0.0, isInProgress: true);
const ProgressViewModel.complete() : this(value: 1.0, isInProgress: false);
@override
List<Object?> get props => [
value,
isInProgress,
];
ProgressViewModel copyWith({
double? value,
bool? isInProgress,
}) =>
ProgressViewModel(
value: value ?? this.value,
isInProgress: isInProgress ?? this.isInProgress,
);
}

View File

@ -0,0 +1,5 @@
typedef DownloadProgressCallback = void Function(double progress);
abstract interface class InstallationRepository {
Future<void> downloadServerFile(Uri url, String path, {DownloadProgressCallback? onProgressChanged});
}

View File

@ -0,0 +1,10 @@
import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart';
class DownloadFileUseCase {
final InstallationRepository _installationRepository;
DownloadFileUseCase(this._installationRepository);
Future<void> call(Uri url, String path, {DownloadProgressCallback? onProgressChanged}) =>
_installationRepository.downloadServerFile(url, path, onProgressChanged: onProgressChanged);
}

View File

@ -0,0 +1,40 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:minecraft_server_installer/main/adapter/gateway/installation_api_service.dart';
import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart';
class InstallationApiServiceImpl implements InstallationApiService {
@override
Future<Uint8List> fetchRemoteFile(Uri url, {DownloadProgressCallback? onProgressChanged}) async {
final client = http.Client();
final request = http.Request('GET', url);
final response = await client.send(request);
final contentLength = response.contentLength;
final completer = Completer<Uint8List>();
final bytes = <int>[];
var receivedBytes = 0;
response.stream.listen(
(chunk) {
bytes.addAll(chunk);
receivedBytes += chunk.length;
if (onProgressChanged != null && contentLength != null) {
onProgressChanged(receivedBytes / contentLength);
}
},
onDone: () {
if (onProgressChanged != null) {
onProgressChanged(1);
}
completer.complete(Uint8List.fromList(bytes));
},
onError: completer.completeError,
cancelOnError: true,
);
return completer.future;
}
}

View File

@ -0,0 +1,18 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:minecraft_server_installer/main/adapter/gateway/installation_file_storage.dart';
class InstallationFileStorageImpl implements InstallationFileStorage {
@override
Future<void> saveFile(Uint8List fileBytes, String path) async {
final file = File(path);
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.create();
await file.writeAsBytes(fileBytes, flush: true);
}
}

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_state.dart';
import 'package:minecraft_server_installer/main/framework/ui/path_browsing_field.dart';
import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; import 'package:minecraft_server_installer/main/framework/ui/strings.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_state.dart';
import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart'; import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart';
class BasicConfigurationTab extends StatefulWidget { class BasicConfigurationTab extends StatefulWidget {
@ -18,38 +19,38 @@ class _BasicConfigurationTabState extends State<BasicConfigurationTab> {
GameVersionViewModel? selectedGameVersion; GameVersionViewModel? selectedGameVersion;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Column(
return Column( children: [
children: [ const GameVersionDropdown(),
const GameVersionDropdown(), const Gap(16),
const Spacer(), const PathBrowsingField(),
BlocConsumer<VanillaBloc, VanillaState>( const Spacer(),
listener: (_, __) {}, _bottomControl,
builder: ],
(context, state) => Row( );
mainAxisAlignment: MainAxisAlignment.end,
children: [ Widget get _bottomControl => BlocConsumer<InstallationBloc, InstallationState>(
if (state.isDownloading) Expanded(child: LinearProgressIndicator(value: state.downloadProgress)), listener: (_, __) {},
const Gap(32), builder: (_, state) => Row(
ElevatedButton.icon( mainAxisAlignment: MainAxisAlignment.end,
style: ElevatedButton.styleFrom( children: [
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), if (state.downloadProgress.isInProgress)
), Expanded(child: LinearProgressIndicator(value: state.downloadProgress.value)),
onPressed: state.isGameVersionSelected ? _downloadServerFile : null, const Gap(32),
icon: const Icon(Icons.download), ElevatedButton.icon(
label: const Padding( style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))),
padding: EdgeInsets.symmetric(vertical: 12), onPressed: context.watch<InstallationBloc>().state.canStartToInstall ? _downloadServerFile : null,
child: Text(Strings.buttonStartToInstall), icon: const Icon(Icons.download),
), label: const Padding(
), padding: EdgeInsets.symmetric(vertical: 12),
], child: Text(Strings.buttonStartToInstall),
), ),
),
],
), ),
], );
);
}
void _downloadServerFile() { void _downloadServerFile() {
context.read<VanillaBloc>().add(VanillaServerFileDownloadedEvent()); context.read<InstallationBloc>().add((InstallationStartedEvent()));
} }
} }

View File

@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/gateway/installation_repository_impl.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_state.dart';
import 'package:minecraft_server_installer/main/application/use_case/download_file_use_case.dart';
import 'package:minecraft_server_installer/main/framework/api/installation_api_service_impl.dart';
import 'package:minecraft_server_installer/main/framework/storage/installation_file_storage_impl.dart';
import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart'; import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart';
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_state.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart'; import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart';
import 'package:minecraft_server_installer/vanilla/framework/api/vanilla_api_service_impl.dart'; import 'package:minecraft_server_installer/vanilla/framework/api/vanilla_api_service_impl.dart';
import 'package:minecraft_server_installer/vanilla/framework/storage/vanilla_file_storage_impl.dart';
class MinecraftServerInstaller extends StatelessWidget { class MinecraftServerInstaller extends StatelessWidget {
const MinecraftServerInstaller({super.key}); const MinecraftServerInstaller({super.key});
@ -17,26 +20,28 @@ class MinecraftServerInstaller extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final installationApiService = InstallationApiServiceImpl();
final installationFileStorage = InstallationFileStorageImpl();
final installationRepository = InstallationRepositoryImpl(installationApiService, installationFileStorage);
final gameVersionApiService = VanillaApiServiceImpl(); final gameVersionApiService = VanillaApiServiceImpl();
final gameVersionFileStorage = VanillaFileStorageImpl(); final gameVersionRepository = VanillaRepositoryImpl(gameVersionApiService);
final gameVersionRepository = VanillaRepositoryImpl(gameVersionApiService, gameVersionFileStorage);
final downloadFileUseCase = DownloadFileUseCase(installationRepository);
final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository);
final downloadServerFileUseCase = DownloadServerFileUseCase(gameVersionRepository);
return MaterialApp( return MaterialApp(
title: 'Minecraft Server Installer', title: 'Minecraft Server Installer',
theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)), theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)),
home: MultiBlocProvider( home: MultiBlocProvider(
providers: [ providers: [
BlocProvider(create: (_) => InstallationBloc(downloadFileUseCase)),
BlocProvider<VanillaBloc>( BlocProvider<VanillaBloc>(
create: create: (_) => VanillaBloc(getGameVersionListUseCase)..add(VanillaGameVersionListLoadedEvent()),
(context) =>
VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase)
..add(VanillaGameVersionListLoadedEvent()),
), ),
], ],
child: Scaffold( child: Scaffold(
body: BlocConsumer<VanillaBloc, VanillaState>( body: BlocConsumer<InstallationBloc, InstallationState>(
listener: (_, __) {}, listener: (_, __) {},
builder: (_, state) { builder: (_, state) {
if (state.isLocked) { if (state.isLocked) {

View File

@ -0,0 +1,75 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_state.dart';
import 'package:minecraft_server_installer/main/framework/ui/strings.dart';
class PathBrowsingField extends StatefulWidget {
const PathBrowsingField({super.key});
@override
State<PathBrowsingField> createState() => _PathBrowsingFieldState();
}
class _PathBrowsingFieldState extends State<PathBrowsingField> {
final _textEditingController = TextEditingController();
@override
void initState() {
super.initState();
_textEditingController.text = context.read<InstallationBloc>().state.savePath ?? '';
}
@override
Widget build(BuildContext context) => BlocConsumer<InstallationBloc, InstallationState>(
listener: (_, state) {
if (state.savePath != null) {
_textEditingController.text = state.savePath!;
}
},
builder: (_, __) => Row(
children: [
Expanded(
child: TextField(
controller: _textEditingController,
readOnly: true,
canRequestFocus: false,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)),
label: const Text('${Strings.fieldPath} *'),
),
),
),
const Gap(8),
SizedBox(
height: 48,
child: OutlinedButton(
onPressed: _browseDirectory,
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: const Text(Strings.buttonBrowse),
),
),
],
),
);
Future<void> _browseDirectory() async {
final directory = await FilePicker.platform.getDirectoryPath(
dialogTitle: Strings.dialogTitleSelectDirectory,
initialDirectory: _textEditingController.text.isNotEmpty ? _textEditingController.text : null,
);
if (!mounted || directory == null) {
return;
}
context.read<InstallationBloc>().add(InstallationConfigurationUpdatedEvent(
savePath: directory,
));
}
}

View File

@ -1,4 +1,7 @@
abstract class Strings { abstract class Strings {
static const fieldGameVersion = '遊戲版本'; static const fieldGameVersion = '遊戲版本';
static const fieldPath = '安裝路徑';
static const buttonStartToInstall = '開始安裝'; static const buttonStartToInstall = '開始安裝';
static const buttonBrowse = '瀏覽';
static const dialogTitleSelectDirectory = '選擇安裝目錄';
} }

View File

@ -1,10 +1,5 @@
import 'dart:typed_data';
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart';
abstract interface class VanillaApiService { abstract interface class VanillaApiService {
Future<List<GameVersion>> fetchGameVersionList(); Future<List<GameVersion>> fetchGameVersionList();
Future<Uint8List> fetchServerFile(Uri url, {DownloadProgressCallback? onProgressChanged});
} }

View File

@ -1,5 +0,0 @@
import 'dart:typed_data';
abstract interface class VanillaFileStorage {
Future<void> saveFile(Uint8List fileBytes, String savePath);
}

View File

@ -1,25 +1,12 @@
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_api_service.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_api_service.dart';
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_file_storage.dart';
import 'package:minecraft_server_installer/vanilla/application/repository/vanilla_repository.dart'; import 'package:minecraft_server_installer/vanilla/application/repository/vanilla_repository.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart';
class VanillaRepositoryImpl implements VanillaRepository { class VanillaRepositoryImpl implements VanillaRepository {
final VanillaApiService _gameVersionApiService; final VanillaApiService _gameVersionApiService;
final VanillaFileStorage _gameVersionFileStorage;
VanillaRepositoryImpl(this._gameVersionApiService, this._gameVersionFileStorage); VanillaRepositoryImpl(this._gameVersionApiService);
@override @override
Future<List<GameVersion>> getGameVersionList() => _gameVersionApiService.fetchGameVersionList(); Future<List<GameVersion>> getGameVersionList() => _gameVersionApiService.fetchGameVersionList();
@override
Future<void> downloadServerFile(
GameVersion version,
String savePath, {
DownloadProgressCallback? onProgressChanged,
}) async {
final fileBytes = await _gameVersionApiService.fetchServerFile(version.url, onProgressChanged: onProgressChanged);
await _gameVersionFileStorage.saveFile(fileBytes, savePath);
}
} }

View File

@ -5,13 +5,16 @@ class GameVersionViewModel with EquatableMixin {
final String name; final String name;
final Uri url; final Uri url;
const GameVersionViewModel({required this.name, required this.url}); const GameVersionViewModel({
required this.name,
required this.url,
});
GameVersionViewModel.from(GameVersion gameVersion) : name = gameVersion.name, url = gameVersion.url; GameVersionViewModel.fromEntity(GameVersion gameVersion)
: name = gameVersion.name,
url = gameVersion.url;
GameVersion toEntity() { GameVersion toEntity() => GameVersion(name: name, url: url);
return GameVersion(name: name, url: url);
}
@override @override
List<Object?> get props => [name, url]; List<Object?> get props => [name, url];

View File

@ -1,57 +1,22 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:minecraft_server_installer/main/constants.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_state.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_state.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart'; import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart';
import 'package:path/path.dart' as path;
class VanillaBloc extends Bloc<VanillaEvent, VanillaState> { class VanillaBloc extends Bloc<VanillaEvent, VanillaState> {
final GetGameVersionListUseCase _getGameVersionListUseCase; VanillaBloc(GetGameVersionListUseCase getGameVersionListUseCase) : super(const VanillaState.empty()) {
final DownloadServerFileUseCase _downloadServerFileUseCase;
VanillaBloc(this._getGameVersionListUseCase, this._downloadServerFileUseCase) : super(const VanillaState.empty()) {
on<VanillaGameVersionListLoadedEvent>((_, emit) async { on<VanillaGameVersionListLoadedEvent>((_, emit) async {
try { try {
final gameVersions = await _getGameVersionListUseCase(); final gameVersions = await getGameVersionListUseCase();
emit( emit(
const VanillaState.empty().copyWith( const VanillaState.empty().copyWith(
gameVersions: gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList(), gameVersions: gameVersions.map((entity) => GameVersionViewModel.fromEntity(entity)).toList(),
), ),
); );
} on Exception { } on Exception {
emit(const VanillaState.empty()); emit(const VanillaState.empty());
} }
}); });
on<VanillaGameVersionSelectedEvent>((event, emit) {
emit(state.copyWith(selectedGameVersion: event.gameVersion));
});
on<VanillaServerFileDownloadedEvent>((_, emit) async {
final gameVersion = state.selectedGameVersion;
if (gameVersion == null) {
return;
}
emit(state.copyWith(isLocked: true));
await _downloadServerFileUseCase(
gameVersion.toEntity(),
path.join('.', Constants.serverFileName),
onProgressChanged: (progress) => add(_VanillaDownloadProgressChangedEvent(progress)),
);
emit(state.copyWith(isLocked: false));
});
on<_VanillaDownloadProgressChangedEvent>((event, emit) {
if (event.progress < 0) {
emit(state.copyWith(downloadProgress: 0));
} else if (event.progress > 1) {
emit(state.copyWith(downloadProgress: 1));
} else {
emit(state.copyWith(downloadProgress: event.progress));
}
});
} }
} }
@ -64,11 +29,3 @@ class VanillaGameVersionSelectedEvent extends VanillaEvent {
VanillaGameVersionSelectedEvent(this.gameVersion); VanillaGameVersionSelectedEvent(this.gameVersion);
} }
class VanillaServerFileDownloadedEvent extends VanillaEvent {}
class _VanillaDownloadProgressChangedEvent extends VanillaEvent {
final double progress;
_VanillaDownloadProgressChangedEvent(this.progress);
}

View File

@ -2,37 +2,29 @@ import 'package:equatable/equatable.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
class VanillaState with EquatableMixin { class VanillaState with EquatableMixin {
final bool isLocked;
final double downloadProgress;
final List<GameVersionViewModel> gameVersions; final List<GameVersionViewModel> gameVersions;
final GameVersionViewModel? selectedGameVersion;
const VanillaState({ const VanillaState({
required this.isLocked,
required this.downloadProgress,
required this.gameVersions, required this.gameVersions,
required this.selectedGameVersion,
}); });
const VanillaState.empty() const VanillaState.empty()
: this(isLocked: false, downloadProgress: 0, gameVersions: const [], selectedGameVersion: null); : this(
gameVersions: const [],
);
@override @override
List<Object?> get props => [isLocked, downloadProgress, gameVersions, selectedGameVersion]; List<Object?> get props => [
gameVersions,
bool get isGameVersionSelected => selectedGameVersion != null; ];
bool get isDownloading => downloadProgress > 0 && downloadProgress < 1;
VanillaState copyWith({ VanillaState copyWith({
bool? isLocked, bool? isLocked,
double? downloadProgress, double? downloadProgress,
List<GameVersionViewModel>? gameVersions, List<GameVersionViewModel>? gameVersions,
GameVersionViewModel? selectedGameVersion, GameVersionViewModel? selectedGameVersion,
}) => VanillaState( }) =>
isLocked: isLocked ?? this.isLocked, VanillaState(
downloadProgress: downloadProgress ?? this.downloadProgress, gameVersions: gameVersions ?? this.gameVersions,
gameVersions: gameVersions ?? this.gameVersions, );
selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion,
);
} }

View File

@ -1,8 +1,5 @@
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart';
abstract interface class VanillaRepository { abstract interface class VanillaRepository {
Future<List<GameVersion>> getGameVersionList(); Future<List<GameVersion>> getGameVersionList();
Future<void> downloadServerFile(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged});
} }

View File

@ -1,13 +0,0 @@
import 'package:minecraft_server_installer/vanilla/application/repository/vanilla_repository.dart';
import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart';
typedef DownloadProgressCallback = void Function(double progress);
class DownloadServerFileUseCase {
final VanillaRepository _gameVersionRepository;
DownloadServerFileUseCase(this._gameVersionRepository);
Future<void> call(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}) =>
_gameVersionRepository.downloadServerFile(version, savePath, onProgressChanged: onProgressChanged);
}

View File

@ -4,7 +4,10 @@ class GameVersion with EquatableMixin {
final String name; final String name;
final Uri url; final Uri url;
const GameVersion({required this.name, required this.url}); const GameVersion({
required this.name,
required this.url,
});
@override @override
List<Object?> get props => [name, url]; List<Object?> get props => [name, url];

View File

@ -1,10 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:minecraft_server_installer/main/constants.dart'; import 'package:minecraft_server_installer/main/constants.dart';
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_api_service.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_api_service.dart';
import 'package:minecraft_server_installer/vanilla/application/use_case/download_server_file_use_case.dart';
import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart';
class VanillaApiServiceImpl implements VanillaApiService { class VanillaApiServiceImpl implements VanillaApiService {
@ -22,36 +20,4 @@ class VanillaApiServiceImpl implements VanillaApiService {
return gameVersionList; return gameVersionList;
} }
@override
Future<Uint8List> fetchServerFile(Uri url, {DownloadProgressCallback? onProgressChanged}) async {
final client = http.Client();
final request = http.Request('GET', url);
final response = await client.send(request);
final contentLength = response.contentLength;
final completer = Completer<Uint8List>();
final bytes = <int>[];
var receivedBytes = 0;
response.stream.listen(
(chunk) {
bytes.addAll(chunk);
receivedBytes += chunk.length;
if (onProgressChanged != null && contentLength != null) {
onProgressChanged(receivedBytes / contentLength);
}
},
onDone: () {
if (onProgressChanged != null) {
onProgressChanged(1);
}
completer.complete(Uint8List.fromList(bytes));
},
onError: completer.completeError,
cancelOnError: true,
);
return completer.future;
}
} }

View File

@ -1,18 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_file_storage.dart';
class VanillaFileStorageImpl implements VanillaFileStorage {
@override
Future<void> saveFile(Uint8List fileBytes, String savePath) async {
final file = File(savePath);
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.create();
await file.writeAsBytes(fileBytes, flush: true);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_bloc.dart';
import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; import 'package:minecraft_server_installer/main/framework/ui/strings.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
@ -10,26 +11,24 @@ class GameVersionDropdown extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocConsumer<VanillaBloc, VanillaState>( Widget build(BuildContext context) => BlocConsumer<VanillaBloc, VanillaState>(
listener: (_, __) {}, listener: (_, __) {},
builder: builder: (_, state) => DropdownMenu<GameVersionViewModel>(
(_, state) => DropdownMenu( initialSelection: context.read<InstallationBloc>().state.gameVersion,
initialSelection: state.selectedGameVersion,
enabled: state.gameVersions.isNotEmpty, enabled: state.gameVersions.isNotEmpty,
requestFocusOnTap: false, requestFocusOnTap: false,
expandedInsets: EdgeInsets.zero, expandedInsets: EdgeInsets.zero,
label: const Text('${Strings.fieldGameVersion} *'), label: const Text('${Strings.fieldGameVersion} *'),
onSelected: (value) { onSelected: (value) {
if (value != null) { if (value != null) {
context.read<VanillaBloc>().add(VanillaGameVersionSelectedEvent(value)); context.read<InstallationBloc>().add(InstallationConfigurationUpdatedEvent(gameVersion: value));
} }
}, },
dropdownMenuEntries: dropdownMenuEntries: state.gameVersions
state.gameVersions .map((gameVersion) => DropdownMenuEntry<GameVersionViewModel>(
.map( value: gameVersion,
(gameVersion) => label: gameVersion.name,
DropdownMenuEntry<GameVersionViewModel>(value: gameVersion, label: gameVersion.name), ))
) .toList(),
.toList(),
), ),
); );
} }

View File

@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -73,6 +81,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
url: "https://pub.dev"
source: hosted
version: "10.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -94,11 +118,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e
url: "https://pub.dev"
source: hosted
version: "2.0.28"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
gap: gap:
dependency: "direct main" dependency: "direct main"
description: description:
@ -344,6 +381,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
window_manager: window_manager:
dependency: "direct main" dependency: "direct main"
description: description:
@ -353,5 +398,5 @@ packages:
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
sdks: sdks:
dart: ">=3.7.2 <4.0.0" dart: ">=3.7.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.27.0"

View File

@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.7.2 sdk: ">=3.6.0 <4.0.0"
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@ -35,6 +35,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
equatable: ^2.0.7 equatable: ^2.0.7
file_picker: ^10.2.0
flutter_bloc: ^9.1.1 flutter_bloc: ^9.1.1
gap: ^3.0.1 gap: ^3.0.1
http: ^1.4.0 http: ^1.4.0