From 3719023cf943421446f9b84d2c3e5df149f38a51 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Thu, 10 Jul 2025 16:05:50 +0800 Subject: [PATCH 1/5] MCSI-2 refactor: add path argument to VanillaServerFileDownloadedEvent --- lib/main/framework/ui/basic_configuration_tab.dart | 2 +- lib/vanilla/adapter/presentation/vanilla_bloc.dart | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 8ce10fd..692449e 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -50,6 +50,6 @@ class _BasicConfigurationTabState extends State { } void _downloadServerFile() { - context.read().add(VanillaServerFileDownloadedEvent()); + context.read().add(VanillaServerFileDownloadedEvent('.')); } } diff --git a/lib/vanilla/adapter/presentation/vanilla_bloc.dart b/lib/vanilla/adapter/presentation/vanilla_bloc.dart index 23cb010..845aabd 100644 --- a/lib/vanilla/adapter/presentation/vanilla_bloc.dart +++ b/lib/vanilla/adapter/presentation/vanilla_bloc.dart @@ -28,7 +28,7 @@ class VanillaBloc extends Bloc { emit(state.copyWith(selectedGameVersion: event.gameVersion)); }); - on((_, emit) async { + on((event, emit) async { final gameVersion = state.selectedGameVersion; if (gameVersion == null) { return; @@ -37,7 +37,7 @@ class VanillaBloc extends Bloc { emit(state.copyWith(isLocked: true)); await _downloadServerFileUseCase( gameVersion.toEntity(), - path.join('.', Constants.serverFileName), + path.join(event.savePath, Constants.serverFileName), onProgressChanged: (progress) => add(_VanillaDownloadProgressChangedEvent(progress)), ); emit(state.copyWith(isLocked: false)); @@ -65,7 +65,11 @@ class VanillaGameVersionSelectedEvent extends VanillaEvent { VanillaGameVersionSelectedEvent(this.gameVersion); } -class VanillaServerFileDownloadedEvent extends VanillaEvent {} +class VanillaServerFileDownloadedEvent extends VanillaEvent { + final String savePath; + + VanillaServerFileDownloadedEvent(this.savePath); +} class _VanillaDownloadProgressChangedEvent extends VanillaEvent { final double progress; From 8f233ea5520a94c553c61e0bfb1c6b274364e060 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Thu, 10 Jul 2025 20:10:20 +0800 Subject: [PATCH 2/5] MCSI-2 feat: click button and browse a directory --- .vscode/settings.json | 6 +- analysis_options.yaml | 3 + devtools_options.yaml | 3 + .../presentation/installation_bloc.dart | 27 ++++++++ .../presentation/installation_state.dart | 35 ++++++++++ .../framework/ui/basic_configuration_tab.dart | 56 ++++++++-------- .../ui/minecraft_server_installer.dart | 8 +-- .../framework/ui/path_browsing_field.dart | 67 +++++++++++++++++++ lib/main/framework/ui/strings.dart | 3 + .../gateway/vanilla_repository_impl.dart | 5 +- .../presentation/game_version_view_model.dart | 13 ++-- .../adapter/presentation/vanilla_bloc.dart | 2 +- .../adapter/presentation/vanilla_state.dart | 27 +++++--- .../repository/vanilla_repository.dart | 6 +- .../download_server_file_use_case.dart | 12 +++- lib/vanilla/domain/entity/game_version.dart | 5 +- .../framework/ui/game_version_dropdown.dart | 20 +++--- pubspec.lock | 49 +++++++++++++- pubspec.yaml | 3 +- 19 files changed, 284 insertions(+), 66 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/main/adapter/presentation/installation_bloc.dart create mode 100644 lib/main/adapter/presentation/installation_state.dart create mode 100644 lib/main/framework/ui/path_browsing_field.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 146b1f5..dbaa3ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,8 @@ { "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" + } } diff --git a/analysis_options.yaml b/analysis_options.yaml index a610cc8..91522c0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,9 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +formatter: + page_width: 120 + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/lib/main/adapter/presentation/installation_bloc.dart b/lib/main/adapter/presentation/installation_bloc.dart new file mode 100644 index 0000000..bfb9d47 --- /dev/null +++ b/lib/main/adapter/presentation/installation_bloc.dart @@ -0,0 +1,27 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:minecraft_server_installer/main/adapter/presentation/installation_state.dart'; +import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; + +class InstallationBloc extends Bloc { + InstallationBloc() : super(const InstallationState.empty()) { + on((event, emit) { + final newState = state.copyWith( + gameVersion: event.gameVersion, + savePath: event.savePath, + ); + emit(newState); + }); + } +} + +sealed class InstallationEvent {} + +class InstallationConfigurationUpdatedEvent extends InstallationEvent { + final GameVersionViewModel? gameVersion; + final String? savePath; + + InstallationConfigurationUpdatedEvent({ + this.gameVersion, + this.savePath, + }); +} diff --git a/lib/main/adapter/presentation/installation_state.dart b/lib/main/adapter/presentation/installation_state.dart new file mode 100644 index 0000000..c9767a6 --- /dev/null +++ b/lib/main/adapter/presentation/installation_state.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; + +class InstallationState with EquatableMixin { + final GameVersionViewModel? gameVersion; + final String? savePath; + + const InstallationState({ + this.gameVersion, + this.savePath, + }); + + const InstallationState.empty() + : this( + gameVersion: null, + savePath: null, + ); + + @override + List get props => [ + gameVersion, + savePath, + ]; + + InstallationState copyWith({ + GameVersionViewModel? gameVersion, + String? savePath, + }) => + InstallationState( + gameVersion: gameVersion ?? this.gameVersion, + savePath: savePath ?? this.savePath, + ); + + bool get canStartToInstall => gameVersion != null && savePath != null && savePath!.isNotEmpty; +} diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 692449e..184bd53 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gap/gap.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/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; @@ -18,36 +19,35 @@ class _BasicConfigurationTabState extends State { GameVersionViewModel? selectedGameVersion; @override - Widget build(BuildContext context) { - return Column( - children: [ - const GameVersionDropdown(), - const Spacer(), - BlocConsumer( - listener: (_, __) {}, - builder: - (context, state) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (state.isDownloading) Expanded(child: LinearProgressIndicator(value: state.downloadProgress)), - const Gap(32), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: state.isGameVersionSelected ? _downloadServerFile : null, - icon: const Icon(Icons.download), - label: const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text(Strings.buttonStartToInstall), - ), - ), - ], + Widget build(BuildContext context) => Column( + children: [ + const GameVersionDropdown(), + const Gap(16), + const PathBrowsingField(), + const Spacer(), + _bottomControl, + ], + ); + + Widget get _bottomControl => BlocConsumer( + listener: (_, __) {}, + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (state.isDownloading) Expanded(child: LinearProgressIndicator(value: state.downloadProgress)), + const Gap(32), + ElevatedButton.icon( + style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))), + onPressed: state.isGameVersionSelected ? _downloadServerFile : null, + icon: const Icon(Icons.download), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text(Strings.buttonStartToInstall), ), + ), + ], ), - ], - ); - } + ); void _downloadServerFile() { context.read().add(VanillaServerFileDownloadedEvent('.')); diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index 190ba96..3e861c1 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.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/basic_configuration_tab.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; @@ -29,11 +30,10 @@ class MinecraftServerInstaller extends StatelessWidget { home: MultiBlocProvider( providers: [ BlocProvider( - create: - (context) => - VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase) - ..add(VanillaGameVersionListLoadedEvent()), + create: (_) => VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase) + ..add(VanillaGameVersionListLoadedEvent()), ), + BlocProvider(create: (_) => InstallationBloc()) ], child: Scaffold( body: BlocConsumer( diff --git a/lib/main/framework/ui/path_browsing_field.dart b/lib/main/framework/ui/path_browsing_field.dart new file mode 100644 index 0000000..7e4f953 --- /dev/null +++ b/lib/main/framework/ui/path_browsing_field.dart @@ -0,0 +1,67 @@ +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 createState() => _PathBrowsingFieldState(); +} + +class _PathBrowsingFieldState extends State { + final _textEditingController = TextEditingController(); + + @override + Widget build(BuildContext context) => BlocConsumer( + listener: (_, state) { + if (state.savePath != null) { + _textEditingController.text = state.savePath!; + } + }, + builder: (_, __) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + readOnly: true, + 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 _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().add(InstallationConfigurationUpdatedEvent( + savePath: directory, + )); + } +} diff --git a/lib/main/framework/ui/strings.dart b/lib/main/framework/ui/strings.dart index 25acf60..340d38f 100644 --- a/lib/main/framework/ui/strings.dart +++ b/lib/main/framework/ui/strings.dart @@ -1,4 +1,7 @@ abstract class Strings { static const fieldGameVersion = '遊戲版本'; + static const fieldPath = '安裝路徑'; static const buttonStartToInstall = '開始安裝'; + static const buttonBrowse = '瀏覽'; + static const dialogTitleSelectDirectory = '選擇安裝目錄'; } diff --git a/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart b/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart index 3bd2dca..a6e7782 100644 --- a/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart +++ b/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart @@ -19,7 +19,10 @@ class VanillaRepositoryImpl implements VanillaRepository { String savePath, { DownloadProgressCallback? onProgressChanged, }) async { - final fileBytes = await _gameVersionApiService.fetchServerFile(version.url, onProgressChanged: onProgressChanged); + final fileBytes = await _gameVersionApiService.fetchServerFile( + version.url, + onProgressChanged: onProgressChanged, + ); await _gameVersionFileStorage.saveFile(fileBytes, savePath); } } diff --git a/lib/vanilla/adapter/presentation/game_version_view_model.dart b/lib/vanilla/adapter/presentation/game_version_view_model.dart index cb50d77..0c96bf0 100644 --- a/lib/vanilla/adapter/presentation/game_version_view_model.dart +++ b/lib/vanilla/adapter/presentation/game_version_view_model.dart @@ -5,13 +5,16 @@ class GameVersionViewModel with EquatableMixin { final String name; 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() { - return GameVersion(name: name, url: url); - } + GameVersion toEntity() => GameVersion(name: name, url: url); @override List get props => [name, url]; diff --git a/lib/vanilla/adapter/presentation/vanilla_bloc.dart b/lib/vanilla/adapter/presentation/vanilla_bloc.dart index 845aabd..8e92690 100644 --- a/lib/vanilla/adapter/presentation/vanilla_bloc.dart +++ b/lib/vanilla/adapter/presentation/vanilla_bloc.dart @@ -16,7 +16,7 @@ class VanillaBloc extends Bloc { final gameVersions = await _getGameVersionListUseCase(); emit( const VanillaState.empty().copyWith( - gameVersions: gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList(), + gameVersions: gameVersions.map((entity) => GameVersionViewModel.fromEntity(entity)).toList(), ), ); } on Exception { diff --git a/lib/vanilla/adapter/presentation/vanilla_state.dart b/lib/vanilla/adapter/presentation/vanilla_state.dart index 6ae3e2d..f0185b2 100644 --- a/lib/vanilla/adapter/presentation/vanilla_state.dart +++ b/lib/vanilla/adapter/presentation/vanilla_state.dart @@ -15,10 +15,20 @@ class VanillaState with EquatableMixin { }); const VanillaState.empty() - : this(isLocked: false, downloadProgress: 0, gameVersions: const [], selectedGameVersion: null); + : this( + isLocked: false, + downloadProgress: 0, + gameVersions: const [], + selectedGameVersion: null, + ); @override - List get props => [isLocked, downloadProgress, gameVersions, selectedGameVersion]; + List get props => [ + isLocked, + downloadProgress, + gameVersions, + selectedGameVersion, + ]; bool get isGameVersionSelected => selectedGameVersion != null; @@ -29,10 +39,11 @@ class VanillaState with EquatableMixin { double? downloadProgress, List? gameVersions, GameVersionViewModel? selectedGameVersion, - }) => VanillaState( - isLocked: isLocked ?? this.isLocked, - downloadProgress: downloadProgress ?? this.downloadProgress, - gameVersions: gameVersions ?? this.gameVersions, - selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, - ); + }) => + VanillaState( + isLocked: isLocked ?? this.isLocked, + downloadProgress: downloadProgress ?? this.downloadProgress, + gameVersions: gameVersions ?? this.gameVersions, + selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, + ); } diff --git a/lib/vanilla/application/repository/vanilla_repository.dart b/lib/vanilla/application/repository/vanilla_repository.dart index f9c0bcb..d3e2ef6 100644 --- a/lib/vanilla/application/repository/vanilla_repository.dart +++ b/lib/vanilla/application/repository/vanilla_repository.dart @@ -4,5 +4,9 @@ import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.da abstract interface class VanillaRepository { Future> getGameVersionList(); - Future downloadServerFile(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}); + Future downloadServerFile( + GameVersion version, + String savePath, { + DownloadProgressCallback? onProgressChanged, + }); } diff --git a/lib/vanilla/application/use_case/download_server_file_use_case.dart b/lib/vanilla/application/use_case/download_server_file_use_case.dart index 431ef72..c5e848d 100644 --- a/lib/vanilla/application/use_case/download_server_file_use_case.dart +++ b/lib/vanilla/application/use_case/download_server_file_use_case.dart @@ -8,6 +8,14 @@ class DownloadServerFileUseCase { DownloadServerFileUseCase(this._gameVersionRepository); - Future call(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}) => - _gameVersionRepository.downloadServerFile(version, savePath, onProgressChanged: onProgressChanged); + Future call( + GameVersion version, + String savePath, { + DownloadProgressCallback? onProgressChanged, + }) => + _gameVersionRepository.downloadServerFile( + version, + savePath, + onProgressChanged: onProgressChanged, + ); } diff --git a/lib/vanilla/domain/entity/game_version.dart b/lib/vanilla/domain/entity/game_version.dart index f1c6321..e8e4afc 100644 --- a/lib/vanilla/domain/entity/game_version.dart +++ b/lib/vanilla/domain/entity/game_version.dart @@ -4,7 +4,10 @@ class GameVersion with EquatableMixin { final String name; final Uri url; - const GameVersion({required this.name, required this.url}); + const GameVersion({ + required this.name, + required this.url, + }); @override List get props => [name, url]; diff --git a/lib/vanilla/framework/ui/game_version_dropdown.dart b/lib/vanilla/framework/ui/game_version_dropdown.dart index 2d9b9b7..1c03072 100644 --- a/lib/vanilla/framework/ui/game_version_dropdown.dart +++ b/lib/vanilla/framework/ui/game_version_dropdown.dart @@ -10,9 +10,8 @@ class GameVersionDropdown extends StatelessWidget { @override Widget build(BuildContext context) => BlocConsumer( - listener: (_, __) {}, - builder: - (_, state) => DropdownMenu( + listener: (_, __) {}, + builder: (_, state) => DropdownMenu( initialSelection: state.selectedGameVersion, enabled: state.gameVersions.isNotEmpty, requestFocusOnTap: false, @@ -23,13 +22,12 @@ class GameVersionDropdown extends StatelessWidget { context.read().add(VanillaGameVersionSelectedEvent(value)); } }, - dropdownMenuEntries: - state.gameVersions - .map( - (gameVersion) => - DropdownMenuEntry(value: gameVersion, label: gameVersion.name), - ) - .toList(), + dropdownMenuEntries: state.gameVersions + .map((gameVersion) => DropdownMenuEntry( + value: gameVersion, + label: gameVersion.name, + )) + .toList(), ), - ); + ); } diff --git a/pubspec.lock b/pubspec.lock index d89613a..1c9a21e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -73,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: flutter @@ -94,11 +118,24 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" gap: dependency: "direct main" description: @@ -344,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" window_manager: dependency: "direct main" description: @@ -353,5 +398,5 @@ packages: source: hosted version: "0.5.0" sdks: - dart: ">=3.7.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index a19fc57..80d9f6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ^3.7.2 + sdk: ">=3.6.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 equatable: ^2.0.7 + file_picker: ^10.2.0 flutter_bloc: ^9.1.1 gap: ^3.0.1 http: ^1.4.0 From b2d2bbda125e944555e7ab9e52b59c4ba7badc42 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Thu, 10 Jul 2025 20:16:22 +0800 Subject: [PATCH 3/5] MCSI-2 feat: download the server file to the path --- lib/main/framework/ui/basic_configuration_tab.dart | 6 ++++-- lib/main/framework/ui/path_browsing_field.dart | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 184bd53..4f6909a 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,6 +1,7 @@ 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/framework/ui/path_browsing_field.dart'; import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; @@ -31,7 +32,7 @@ class _BasicConfigurationTabState extends State { Widget get _bottomControl => BlocConsumer( listener: (_, __) {}, - builder: (context, state) => Row( + builder: (_, state) => Row( mainAxisAlignment: MainAxisAlignment.end, children: [ if (state.isDownloading) Expanded(child: LinearProgressIndicator(value: state.downloadProgress)), @@ -50,6 +51,7 @@ class _BasicConfigurationTabState extends State { ); void _downloadServerFile() { - context.read().add(VanillaServerFileDownloadedEvent('.')); + final savePath = context.read().state.savePath; + context.read().add(VanillaServerFileDownloadedEvent(savePath!)); } } diff --git a/lib/main/framework/ui/path_browsing_field.dart b/lib/main/framework/ui/path_browsing_field.dart index 7e4f953..6ef5727 100644 --- a/lib/main/framework/ui/path_browsing_field.dart +++ b/lib/main/framework/ui/path_browsing_field.dart @@ -16,6 +16,13 @@ class PathBrowsingField extends StatefulWidget { class _PathBrowsingFieldState extends State { final _textEditingController = TextEditingController(); + @override + void initState() { + super.initState(); + + _textEditingController.text = context.read().state.savePath ?? ''; + } + @override Widget build(BuildContext context) => BlocConsumer( listener: (_, state) { From b3aa8e095cc09e8a4cd4d389aabf19ac5d56ccf1 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Thu, 10 Jul 2025 20:30:26 +0800 Subject: [PATCH 4/5] MCSI-2 refactor: move selected game version to installation bloc --- .../adapter/presentation/installation_state.dart | 6 +++++- lib/main/framework/ui/basic_configuration_tab.dart | 2 +- .../framework/ui/minecraft_server_installer.dart | 6 ++++-- lib/main/framework/ui/path_browsing_field.dart | 1 + lib/vanilla/adapter/presentation/vanilla_bloc.dart | 14 ++++++++------ .../adapter/presentation/vanilla_state.dart | 7 ------- .../framework/ui/game_version_dropdown.dart | 7 ++++--- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/main/adapter/presentation/installation_state.dart b/lib/main/adapter/presentation/installation_state.dart index c9767a6..9eb4d55 100644 --- a/lib/main/adapter/presentation/installation_state.dart +++ b/lib/main/adapter/presentation/installation_state.dart @@ -31,5 +31,9 @@ class InstallationState with EquatableMixin { savePath: savePath ?? this.savePath, ); - bool get canStartToInstall => gameVersion != null && savePath != null && savePath!.isNotEmpty; + bool get isGameVersionSelected => gameVersion != null; + + bool get isSavePathSelected => savePath != null && savePath!.isNotEmpty; + + bool get canStartToInstall => isGameVersionSelected && isSavePathSelected; } diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 4f6909a..2e1b07e 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -39,7 +39,7 @@ class _BasicConfigurationTabState extends State { const Gap(32), ElevatedButton.icon( style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))), - onPressed: state.isGameVersionSelected ? _downloadServerFile : null, + onPressed: context.watch().state.isGameVersionSelected ? _downloadServerFile : null, icon: const Icon(Icons.download), label: const Padding( padding: EdgeInsets.symmetric(vertical: 12), diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index 3e861c1..c1ae2bd 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -24,16 +24,18 @@ class MinecraftServerInstaller extends StatelessWidget { final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); final downloadServerFileUseCase = DownloadServerFileUseCase(gameVersionRepository); + final installationBloc = InstallationBloc(); + return MaterialApp( title: 'Minecraft Server Installer', theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)), home: MultiBlocProvider( providers: [ + BlocProvider.value(value: installationBloc), BlocProvider( - create: (_) => VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase) + create: (_) => VanillaBloc(installationBloc, getGameVersionListUseCase, downloadServerFileUseCase) ..add(VanillaGameVersionListLoadedEvent()), ), - BlocProvider(create: (_) => InstallationBloc()) ], child: Scaffold( body: BlocConsumer( diff --git a/lib/main/framework/ui/path_browsing_field.dart b/lib/main/framework/ui/path_browsing_field.dart index 6ef5727..eb43b10 100644 --- a/lib/main/framework/ui/path_browsing_field.dart +++ b/lib/main/framework/ui/path_browsing_field.dart @@ -36,6 +36,7 @@ class _PathBrowsingFieldState extends State { child: TextField( controller: _textEditingController, readOnly: true, + canRequestFocus: false, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), label: const Text('${Strings.fieldPath} *'), diff --git a/lib/vanilla/adapter/presentation/vanilla_bloc.dart b/lib/vanilla/adapter/presentation/vanilla_bloc.dart index 8e92690..6d7f317 100644 --- a/lib/vanilla/adapter/presentation/vanilla_bloc.dart +++ b/lib/vanilla/adapter/presentation/vanilla_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:minecraft_server_installer/main/adapter/presentation/installation_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/vanilla_state.dart'; @@ -7,10 +8,15 @@ import 'package:minecraft_server_installer/vanilla/application/use_case/get_game import 'package:path/path.dart' as path; class VanillaBloc extends Bloc { + final InstallationBloc _installationBloc; final GetGameVersionListUseCase _getGameVersionListUseCase; final DownloadServerFileUseCase _downloadServerFileUseCase; - VanillaBloc(this._getGameVersionListUseCase, this._downloadServerFileUseCase) : super(const VanillaState.empty()) { + VanillaBloc( + this._installationBloc, + this._getGameVersionListUseCase, + this._downloadServerFileUseCase, + ) : super(const VanillaState.empty()) { on((_, emit) async { try { final gameVersions = await _getGameVersionListUseCase(); @@ -24,12 +30,8 @@ class VanillaBloc extends Bloc { } }); - on((event, emit) { - emit(state.copyWith(selectedGameVersion: event.gameVersion)); - }); - on((event, emit) async { - final gameVersion = state.selectedGameVersion; + final gameVersion = _installationBloc.state.gameVersion; if (gameVersion == null) { return; } diff --git a/lib/vanilla/adapter/presentation/vanilla_state.dart b/lib/vanilla/adapter/presentation/vanilla_state.dart index f0185b2..697c4a4 100644 --- a/lib/vanilla/adapter/presentation/vanilla_state.dart +++ b/lib/vanilla/adapter/presentation/vanilla_state.dart @@ -5,13 +5,11 @@ class VanillaState with EquatableMixin { final bool isLocked; final double downloadProgress; final List gameVersions; - final GameVersionViewModel? selectedGameVersion; const VanillaState({ required this.isLocked, required this.downloadProgress, required this.gameVersions, - required this.selectedGameVersion, }); const VanillaState.empty() @@ -19,7 +17,6 @@ class VanillaState with EquatableMixin { isLocked: false, downloadProgress: 0, gameVersions: const [], - selectedGameVersion: null, ); @override @@ -27,11 +24,8 @@ class VanillaState with EquatableMixin { isLocked, downloadProgress, gameVersions, - selectedGameVersion, ]; - bool get isGameVersionSelected => selectedGameVersion != null; - bool get isDownloading => downloadProgress > 0 && downloadProgress < 1; VanillaState copyWith({ @@ -44,6 +38,5 @@ class VanillaState with EquatableMixin { isLocked: isLocked ?? this.isLocked, downloadProgress: downloadProgress ?? this.downloadProgress, gameVersions: gameVersions ?? this.gameVersions, - selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, ); } diff --git a/lib/vanilla/framework/ui/game_version_dropdown.dart b/lib/vanilla/framework/ui/game_version_dropdown.dart index 1c03072..7590b8c 100644 --- a/lib/vanilla/framework/ui/game_version_dropdown.dart +++ b/lib/vanilla/framework/ui/game_version_dropdown.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.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/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; @@ -11,15 +12,15 @@ class GameVersionDropdown extends StatelessWidget { @override Widget build(BuildContext context) => BlocConsumer( listener: (_, __) {}, - builder: (_, state) => DropdownMenu( - initialSelection: state.selectedGameVersion, + builder: (_, state) => DropdownMenu( + initialSelection: context.read().state.gameVersion, enabled: state.gameVersions.isNotEmpty, requestFocusOnTap: false, expandedInsets: EdgeInsets.zero, label: const Text('${Strings.fieldGameVersion} *'), onSelected: (value) { if (value != null) { - context.read().add(VanillaGameVersionSelectedEvent(value)); + context.read().add(InstallationConfigurationUpdatedEvent(gameVersion: value)); } }, dropdownMenuEntries: state.gameVersions From 20f902f0adb945ecc8b1e7c6299795c5705570d0 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Thu, 10 Jul 2025 22:28:29 +0800 Subject: [PATCH 5/5] MCSI-2 refactor: move downloading to installation bloc --- .../gateway/installation_api_service.dart | 7 +++ .../gateway/installation_file_storage.dart | 5 ++ .../gateway/installation_repository_impl.dart | 16 ++++++ .../presentation/installation_bloc.dart | 47 +++++++++++++++- .../presentation/installation_state.dart | 19 +++++-- .../presentation/progress_view_model.dart | 33 ++++++++++++ .../repository/installation_repository.dart | 5 ++ .../use_case/download_file_use_case.dart | 10 ++++ .../api/installation_api_service_impl.dart | 40 ++++++++++++++ .../installation_file_storage_impl.dart | 18 +++++++ .../framework/ui/basic_configuration_tab.dart | 13 +++-- .../ui/minecraft_server_installer.dart | 29 +++++----- .../adapter/gateway/vanilla_api_service.dart | 5 -- .../adapter/gateway/vanilla_file_storage.dart | 5 -- .../gateway/vanilla_repository_impl.dart | 18 +------ .../adapter/presentation/vanilla_bloc.dart | 53 +------------------ .../adapter/presentation/vanilla_state.dart | 12 ----- .../repository/vanilla_repository.dart | 7 --- .../download_server_file_use_case.dart | 21 -------- .../api/vanilla_api_service_impl.dart | 34 ------------ .../storage/vanilla_file_storage_impl.dart | 18 ------- 21 files changed, 221 insertions(+), 194 deletions(-) create mode 100644 lib/main/adapter/gateway/installation_api_service.dart create mode 100644 lib/main/adapter/gateway/installation_file_storage.dart create mode 100644 lib/main/adapter/gateway/installation_repository_impl.dart create mode 100644 lib/main/adapter/presentation/progress_view_model.dart create mode 100644 lib/main/application/repository/installation_repository.dart create mode 100644 lib/main/application/use_case/download_file_use_case.dart create mode 100644 lib/main/framework/api/installation_api_service_impl.dart create mode 100644 lib/main/framework/storage/installation_file_storage_impl.dart delete mode 100644 lib/vanilla/adapter/gateway/vanilla_file_storage.dart delete mode 100644 lib/vanilla/application/use_case/download_server_file_use_case.dart delete mode 100644 lib/vanilla/framework/storage/vanilla_file_storage_impl.dart diff --git a/lib/main/adapter/gateway/installation_api_service.dart b/lib/main/adapter/gateway/installation_api_service.dart new file mode 100644 index 0000000..9f8d547 --- /dev/null +++ b/lib/main/adapter/gateway/installation_api_service.dart @@ -0,0 +1,7 @@ +import 'dart:typed_data'; + +import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart'; + +abstract interface class InstallationApiService { + Future fetchRemoteFile(Uri url, {DownloadProgressCallback? onProgressChanged}); +} diff --git a/lib/main/adapter/gateway/installation_file_storage.dart b/lib/main/adapter/gateway/installation_file_storage.dart new file mode 100644 index 0000000..3f79ae6 --- /dev/null +++ b/lib/main/adapter/gateway/installation_file_storage.dart @@ -0,0 +1,5 @@ +import 'dart:typed_data'; + +abstract interface class InstallationFileStorage { + Future saveFile(Uint8List fileBytes, String path); +} diff --git a/lib/main/adapter/gateway/installation_repository_impl.dart b/lib/main/adapter/gateway/installation_repository_impl.dart new file mode 100644 index 0000000..7d35089 --- /dev/null +++ b/lib/main/adapter/gateway/installation_repository_impl.dart @@ -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 downloadServerFile(Uri url, String path, {DownloadProgressCallback? onProgressChanged}) async { + final fileBytes = await _apiService.fetchRemoteFile(url, onProgressChanged: onProgressChanged); + await _fileStorage.saveFile(fileBytes, path); + } +} diff --git a/lib/main/adapter/presentation/installation_bloc.dart b/lib/main/adapter/presentation/installation_bloc.dart index bfb9d47..316e529 100644 --- a/lib/main/adapter/presentation/installation_bloc.dart +++ b/lib/main/adapter/presentation/installation_bloc.dart @@ -1,9 +1,46 @@ 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 { - InstallationBloc() : super(const InstallationState.empty()) { + InstallationBloc(DownloadFileUseCase downloadFileUseCase) : super(const InstallationState.empty()) { + on((_, 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((event, emit) { final newState = state.copyWith( gameVersion: event.gameVersion, @@ -16,6 +53,14 @@ class InstallationBloc extends Bloc { 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; diff --git a/lib/main/adapter/presentation/installation_state.dart b/lib/main/adapter/presentation/installation_state.dart index 9eb4d55..e70e6be 100644 --- a/lib/main/adapter/presentation/installation_state.dart +++ b/lib/main/adapter/presentation/installation_state.dart @@ -1,39 +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({ - this.gameVersion, - this.savePath, + 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 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; + bool get canStartToInstall => isGameVersionSelected && isSavePathSelected && !isLocked; } diff --git a/lib/main/adapter/presentation/progress_view_model.dart b/lib/main/adapter/presentation/progress_view_model.dart new file mode 100644 index 0000000..e4f463f --- /dev/null +++ b/lib/main/adapter/presentation/progress_view_model.dart @@ -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 get props => [ + value, + isInProgress, + ]; + + ProgressViewModel copyWith({ + double? value, + bool? isInProgress, + }) => + ProgressViewModel( + value: value ?? this.value, + isInProgress: isInProgress ?? this.isInProgress, + ); +} diff --git a/lib/main/application/repository/installation_repository.dart b/lib/main/application/repository/installation_repository.dart new file mode 100644 index 0000000..e155472 --- /dev/null +++ b/lib/main/application/repository/installation_repository.dart @@ -0,0 +1,5 @@ +typedef DownloadProgressCallback = void Function(double progress); + +abstract interface class InstallationRepository { + Future downloadServerFile(Uri url, String path, {DownloadProgressCallback? onProgressChanged}); +} diff --git a/lib/main/application/use_case/download_file_use_case.dart b/lib/main/application/use_case/download_file_use_case.dart new file mode 100644 index 0000000..2afdbc0 --- /dev/null +++ b/lib/main/application/use_case/download_file_use_case.dart @@ -0,0 +1,10 @@ +import 'package:minecraft_server_installer/main/application/repository/installation_repository.dart'; + +class DownloadFileUseCase { + final InstallationRepository _installationRepository; + + DownloadFileUseCase(this._installationRepository); + + Future call(Uri url, String path, {DownloadProgressCallback? onProgressChanged}) => + _installationRepository.downloadServerFile(url, path, onProgressChanged: onProgressChanged); +} diff --git a/lib/main/framework/api/installation_api_service_impl.dart b/lib/main/framework/api/installation_api_service_impl.dart new file mode 100644 index 0000000..0edf58c --- /dev/null +++ b/lib/main/framework/api/installation_api_service_impl.dart @@ -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 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(); + final bytes = []; + 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; + } +} diff --git a/lib/main/framework/storage/installation_file_storage_impl.dart b/lib/main/framework/storage/installation_file_storage_impl.dart new file mode 100644 index 0000000..2089032 --- /dev/null +++ b/lib/main/framework/storage/installation_file_storage_impl.dart @@ -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 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); + } +} diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 2e1b07e..c24e4c9 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -2,11 +2,10 @@ 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/path_browsing_field.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/vanilla_state.dart'; import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart'; class BasicConfigurationTab extends StatefulWidget { @@ -30,16 +29,17 @@ class _BasicConfigurationTabState extends State { ], ); - Widget get _bottomControl => BlocConsumer( + Widget get _bottomControl => BlocConsumer( listener: (_, __) {}, builder: (_, state) => Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (state.isDownloading) Expanded(child: LinearProgressIndicator(value: state.downloadProgress)), + if (state.downloadProgress.isInProgress) + Expanded(child: LinearProgressIndicator(value: state.downloadProgress.value)), const Gap(32), ElevatedButton.icon( style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))), - onPressed: context.watch().state.isGameVersionSelected ? _downloadServerFile : null, + onPressed: context.watch().state.canStartToInstall ? _downloadServerFile : null, icon: const Icon(Icons.download), label: const Padding( padding: EdgeInsets.symmetric(vertical: 12), @@ -51,7 +51,6 @@ class _BasicConfigurationTabState extends State { ); void _downloadServerFile() { - final savePath = context.read().state.savePath; - context.read().add(VanillaServerFileDownloadedEvent(savePath!)); + context.read().add((InstallationStartedEvent())); } } diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index c1ae2bd..baa02c9 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.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/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_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/framework/api/vanilla_api_service_impl.dart'; -import 'package:minecraft_server_installer/vanilla/framework/storage/vanilla_file_storage_impl.dart'; class MinecraftServerInstaller extends StatelessWidget { const MinecraftServerInstaller({super.key}); @@ -18,27 +20,28 @@ class MinecraftServerInstaller extends StatelessWidget { @override Widget build(BuildContext context) { - final gameVersionApiService = VanillaApiServiceImpl(); - final gameVersionFileStorage = VanillaFileStorageImpl(); - final gameVersionRepository = VanillaRepositoryImpl(gameVersionApiService, gameVersionFileStorage); - final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); - final downloadServerFileUseCase = DownloadServerFileUseCase(gameVersionRepository); + final installationApiService = InstallationApiServiceImpl(); + final installationFileStorage = InstallationFileStorageImpl(); + final installationRepository = InstallationRepositoryImpl(installationApiService, installationFileStorage); - final installationBloc = InstallationBloc(); + final gameVersionApiService = VanillaApiServiceImpl(); + final gameVersionRepository = VanillaRepositoryImpl(gameVersionApiService); + + final downloadFileUseCase = DownloadFileUseCase(installationRepository); + final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); return MaterialApp( title: 'Minecraft Server Installer', theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)), home: MultiBlocProvider( providers: [ - BlocProvider.value(value: installationBloc), + BlocProvider(create: (_) => InstallationBloc(downloadFileUseCase)), BlocProvider( - create: (_) => VanillaBloc(installationBloc, getGameVersionListUseCase, downloadServerFileUseCase) - ..add(VanillaGameVersionListLoadedEvent()), + create: (_) => VanillaBloc(getGameVersionListUseCase)..add(VanillaGameVersionListLoadedEvent()), ), ], child: Scaffold( - body: BlocConsumer( + body: BlocConsumer( listener: (_, __) {}, builder: (_, state) { if (state.isLocked) { diff --git a/lib/vanilla/adapter/gateway/vanilla_api_service.dart b/lib/vanilla/adapter/gateway/vanilla_api_service.dart index 2c50673..37e50a8 100644 --- a/lib/vanilla/adapter/gateway/vanilla_api_service.dart +++ b/lib/vanilla/adapter/gateway/vanilla_api_service.dart @@ -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'; abstract interface class VanillaApiService { Future> fetchGameVersionList(); - - Future fetchServerFile(Uri url, {DownloadProgressCallback? onProgressChanged}); } diff --git a/lib/vanilla/adapter/gateway/vanilla_file_storage.dart b/lib/vanilla/adapter/gateway/vanilla_file_storage.dart deleted file mode 100644 index 3217984..0000000 --- a/lib/vanilla/adapter/gateway/vanilla_file_storage.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'dart:typed_data'; - -abstract interface class VanillaFileStorage { - Future saveFile(Uint8List fileBytes, String savePath); -} diff --git a/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart b/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart index a6e7782..e097bbb 100644 --- a/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart +++ b/lib/vanilla/adapter/gateway/vanilla_repository_impl.dart @@ -1,28 +1,12 @@ 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/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; class VanillaRepositoryImpl implements VanillaRepository { final VanillaApiService _gameVersionApiService; - final VanillaFileStorage _gameVersionFileStorage; - VanillaRepositoryImpl(this._gameVersionApiService, this._gameVersionFileStorage); + VanillaRepositoryImpl(this._gameVersionApiService); @override Future> getGameVersionList() => _gameVersionApiService.fetchGameVersionList(); - - @override - Future downloadServerFile( - GameVersion version, - String savePath, { - DownloadProgressCallback? onProgressChanged, - }) async { - final fileBytes = await _gameVersionApiService.fetchServerFile( - version.url, - onProgressChanged: onProgressChanged, - ); - await _gameVersionFileStorage.saveFile(fileBytes, savePath); - } } diff --git a/lib/vanilla/adapter/presentation/vanilla_bloc.dart b/lib/vanilla/adapter/presentation/vanilla_bloc.dart index 6d7f317..a2082e0 100644 --- a/lib/vanilla/adapter/presentation/vanilla_bloc.dart +++ b/lib/vanilla/adapter/presentation/vanilla_bloc.dart @@ -1,25 +1,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:minecraft_server_installer/main/adapter/presentation/installation_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/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:path/path.dart' as path; class VanillaBloc extends Bloc { - final InstallationBloc _installationBloc; - final GetGameVersionListUseCase _getGameVersionListUseCase; - final DownloadServerFileUseCase _downloadServerFileUseCase; - - VanillaBloc( - this._installationBloc, - this._getGameVersionListUseCase, - this._downloadServerFileUseCase, - ) : super(const VanillaState.empty()) { + VanillaBloc(GetGameVersionListUseCase getGameVersionListUseCase) : super(const VanillaState.empty()) { on((_, emit) async { try { - final gameVersions = await _getGameVersionListUseCase(); + final gameVersions = await getGameVersionListUseCase(); emit( const VanillaState.empty().copyWith( gameVersions: gameVersions.map((entity) => GameVersionViewModel.fromEntity(entity)).toList(), @@ -29,31 +17,6 @@ class VanillaBloc extends Bloc { emit(const VanillaState.empty()); } }); - - on((event, emit) async { - final gameVersion = _installationBloc.state.gameVersion; - if (gameVersion == null) { - return; - } - - emit(state.copyWith(isLocked: true)); - await _downloadServerFileUseCase( - gameVersion.toEntity(), - path.join(event.savePath, 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)); - } - }); } } @@ -66,15 +29,3 @@ class VanillaGameVersionSelectedEvent extends VanillaEvent { VanillaGameVersionSelectedEvent(this.gameVersion); } - -class VanillaServerFileDownloadedEvent extends VanillaEvent { - final String savePath; - - VanillaServerFileDownloadedEvent(this.savePath); -} - -class _VanillaDownloadProgressChangedEvent extends VanillaEvent { - final double progress; - - _VanillaDownloadProgressChangedEvent(this.progress); -} diff --git a/lib/vanilla/adapter/presentation/vanilla_state.dart b/lib/vanilla/adapter/presentation/vanilla_state.dart index 697c4a4..77b73cc 100644 --- a/lib/vanilla/adapter/presentation/vanilla_state.dart +++ b/lib/vanilla/adapter/presentation/vanilla_state.dart @@ -2,32 +2,22 @@ import 'package:equatable/equatable.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; class VanillaState with EquatableMixin { - final bool isLocked; - final double downloadProgress; final List gameVersions; const VanillaState({ - required this.isLocked, - required this.downloadProgress, required this.gameVersions, }); const VanillaState.empty() : this( - isLocked: false, - downloadProgress: 0, gameVersions: const [], ); @override List get props => [ - isLocked, - downloadProgress, gameVersions, ]; - bool get isDownloading => downloadProgress > 0 && downloadProgress < 1; - VanillaState copyWith({ bool? isLocked, double? downloadProgress, @@ -35,8 +25,6 @@ class VanillaState with EquatableMixin { GameVersionViewModel? selectedGameVersion, }) => VanillaState( - isLocked: isLocked ?? this.isLocked, - downloadProgress: downloadProgress ?? this.downloadProgress, gameVersions: gameVersions ?? this.gameVersions, ); } diff --git a/lib/vanilla/application/repository/vanilla_repository.dart b/lib/vanilla/application/repository/vanilla_repository.dart index d3e2ef6..af61651 100644 --- a/lib/vanilla/application/repository/vanilla_repository.dart +++ b/lib/vanilla/application/repository/vanilla_repository.dart @@ -1,12 +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'; abstract interface class VanillaRepository { Future> getGameVersionList(); - - Future downloadServerFile( - GameVersion version, - String savePath, { - DownloadProgressCallback? onProgressChanged, - }); } diff --git a/lib/vanilla/application/use_case/download_server_file_use_case.dart b/lib/vanilla/application/use_case/download_server_file_use_case.dart deleted file mode 100644 index c5e848d..0000000 --- a/lib/vanilla/application/use_case/download_server_file_use_case.dart +++ /dev/null @@ -1,21 +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 call( - GameVersion version, - String savePath, { - DownloadProgressCallback? onProgressChanged, - }) => - _gameVersionRepository.downloadServerFile( - version, - savePath, - onProgressChanged: onProgressChanged, - ); -} diff --git a/lib/vanilla/framework/api/vanilla_api_service_impl.dart b/lib/vanilla/framework/api/vanilla_api_service_impl.dart index d633312..23e1b62 100644 --- a/lib/vanilla/framework/api/vanilla_api_service_impl.dart +++ b/lib/vanilla/framework/api/vanilla_api_service_impl.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:http/http.dart' as http; 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/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanilla/domain/entity/game_version.dart'; class VanillaApiServiceImpl implements VanillaApiService { @@ -22,36 +20,4 @@ class VanillaApiServiceImpl implements VanillaApiService { return gameVersionList; } - - @override - Future 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(); - final bytes = []; - 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; - } } diff --git a/lib/vanilla/framework/storage/vanilla_file_storage_impl.dart b/lib/vanilla/framework/storage/vanilla_file_storage_impl.dart deleted file mode 100644 index 921aa92..0000000 --- a/lib/vanilla/framework/storage/vanilla_file_storage_impl.dart +++ /dev/null @@ -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 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); - } -}