diff --git a/.vscode/settings.json b/.vscode/settings.json index f4607f2..146b1f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { + "dart.flutterSdkPath": ".fvm/versions/3.29.3", "dart.lineLength": 120 } diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index eece9cf..24b4d15 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; +import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_bloc.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanila/framework/ui/game_version_dropdown.dart'; @@ -14,6 +17,20 @@ class _BasicConfigurationTabState extends State { @override Widget build(BuildContext context) { - return const Column(children: [GameVersionDropdown(onChanged: print)]); + return Column( + children: [ + const GameVersionDropdown(), + const Spacer(), + ElevatedButton.icon( + onPressed: context.watch().state.isGameVersionSelected ? _downloadServerFile : null, + icon: const Icon(Icons.download), + label: const Text(Strings.buttonStartToInstall), + ), + ], + ); + } + + void _downloadServerFile() { + context.read().add(VanilaServerFileDownloadedEvent()); } } diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index 82957ff..55bb6ce 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -3,8 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart'; import 'package:minecraft_server_installer/vanila/adapter/gateway/game_version_repository_impl.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_bloc.dart'; +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/application/use_case/get_game_version_list_use_case.dart'; import 'package:minecraft_server_installer/vanila/framework/api/game_version_api_service_impl.dart'; +import 'package:minecraft_server_installer/vanila/framework/storage/game_version_file_storage_impl.dart'; class MinecraftServerInstaller extends StatelessWidget { const MinecraftServerInstaller({super.key}); @@ -12,14 +14,20 @@ class MinecraftServerInstaller extends StatelessWidget { @override Widget build(BuildContext context) { final gameVersionApiService = GameVersionApiServiceImpl(); - final gameVersionRepository = GameVersionRepositoryImpl(gameVersionApiService); + final gameVersionFileStorage = GameVersionFileStorageImpl(); + final gameVersionRepository = GameVersionRepositoryImpl(gameVersionApiService, gameVersionFileStorage); final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); + final downloadServerFileUseCase = DownloadServerFileUseCase(gameVersionRepository); return MaterialApp( title: 'Minecraft Server Installer', theme: ThemeData(primarySwatch: Colors.blue), home: MultiBlocProvider( - providers: [BlocProvider(create: (context) => GameVersionBloc(getGameVersionListUseCase))], + providers: [ + BlocProvider( + create: (context) => GameVersionBloc(getGameVersionListUseCase, downloadServerFileUseCase), + ), + ], child: const Scaffold( body: Padding(padding: EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: BasicConfigurationTab()), ), diff --git a/lib/main/framework/ui/strings.dart b/lib/main/framework/ui/strings.dart index c7085f4..25acf60 100644 --- a/lib/main/framework/ui/strings.dart +++ b/lib/main/framework/ui/strings.dart @@ -1,3 +1,4 @@ abstract class Strings { static const fieldGameVersion = '遊戲版本'; + static const buttonStartToInstall = '開始安裝'; } diff --git a/lib/vanila/adapter/gateway/game_version_api_service.dart b/lib/vanila/adapter/gateway/game_version_api_service.dart index 4fbe643..9e3dc1c 100644 --- a/lib/vanila/adapter/gateway/game_version_api_service.dart +++ b/lib/vanila/adapter/gateway/game_version_api_service.dart @@ -1,5 +1,9 @@ +import 'dart:typed_data'; + import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; abstract interface class GameVersionApiService { Future> fetchGameVersionList(); + + Future fetchServerFile(Uri url); } diff --git a/lib/vanila/adapter/gateway/game_version_file_storage.dart b/lib/vanila/adapter/gateway/game_version_file_storage.dart new file mode 100644 index 0000000..d4eac33 --- /dev/null +++ b/lib/vanila/adapter/gateway/game_version_file_storage.dart @@ -0,0 +1,5 @@ +import 'dart:typed_data'; + +abstract interface class GameVersionFileStorage { + Future saveFile(Uint8List fileBytes, String savePath); +} diff --git a/lib/vanila/adapter/gateway/game_version_repository_impl.dart b/lib/vanila/adapter/gateway/game_version_repository_impl.dart index 758105a..12bb1a2 100644 --- a/lib/vanila/adapter/gateway/game_version_repository_impl.dart +++ b/lib/vanila/adapter/gateway/game_version_repository_impl.dart @@ -1,12 +1,20 @@ import 'package:minecraft_server_installer/vanila/adapter/gateway/game_version_api_service.dart'; +import 'package:minecraft_server_installer/vanila/adapter/gateway/game_version_file_storage.dart'; import 'package:minecraft_server_installer/vanila/application/repository/game_version_repository.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; class GameVersionRepositoryImpl implements GameVersionRepository { final GameVersionApiService _gameVersionApiService; + final GameVersionFileStorage _gameVersionFileStorage; - GameVersionRepositoryImpl(this._gameVersionApiService); + GameVersionRepositoryImpl(this._gameVersionApiService, this._gameVersionFileStorage); @override Future> getGameVersionList() => _gameVersionApiService.fetchGameVersionList(); + + @override + Future downloadServerFile(GameVersion version, String savePath) async { + final fileBytes = await _gameVersionApiService.fetchServerFile(version.url); + await _gameVersionFileStorage.saveFile(fileBytes, savePath); + } } diff --git a/lib/vanila/adapter/presentation/game_version_bloc.dart b/lib/vanila/adapter/presentation/game_version_bloc.dart index f89019e..597f957 100644 --- a/lib/vanila/adapter/presentation/game_version_bloc.dart +++ b/lib/vanila/adapter/presentation/game_version_bloc.dart @@ -1,22 +1,71 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/application/use_case/get_game_version_list_use_case.dart'; -class GameVersionBloc extends Bloc> { +class GameVersionBloc extends Bloc { final GetGameVersionListUseCase _getGameVersionListUseCase; + final DownloadServerFileUseCase _downloadServerFileUseCase; - GameVersionBloc(this._getGameVersionListUseCase) : super(const []) { - on((_, emit) async { + GameVersionBloc(this._getGameVersionListUseCase, this._downloadServerFileUseCase) : super(const VanilaState.empty()) { + on((_, emit) async { try { final gameVersions = await _getGameVersionListUseCase(); - emit(gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList()); + emit( + VanilaState( + gameVersions: gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList(), + selectedGameVersion: null, + ), + ); } on Exception { - emit(const []); + emit(const VanilaState.empty()); } }); + + on((event, emit) { + emit(state.copyWith(selectedGameVersion: event.gameVersion)); + }); + + on((_, emit) async { + final gameVersion = state.selectedGameVersion; + if (gameVersion == null) { + return; + } + + await _downloadServerFileUseCase(gameVersion.toEntity(), './server.jar'); + }); } } -sealed class GameVersionEvent {} +sealed class VanilaEvent {} -class GameVersionLoadedEvent extends GameVersionEvent {} +class VanilaGameVersionListLoadedEvent extends VanilaEvent {} + +class VanilaGameVersionSelectedEvent extends VanilaEvent { + final GameVersionViewModel gameVersion; + + VanilaGameVersionSelectedEvent(this.gameVersion); +} + +class VanilaServerFileDownloadedEvent extends VanilaEvent {} + +class VanilaState with EquatableMixin { + final List gameVersions; + final GameVersionViewModel? selectedGameVersion; + + const VanilaState({required this.gameVersions, required this.selectedGameVersion}); + + const VanilaState.empty() : this(gameVersions: const [], selectedGameVersion: null); + + @override + List get props => [gameVersions, selectedGameVersion]; + + bool get isGameVersionSelected => selectedGameVersion != null; + + VanilaState copyWith({List? gameVersions, GameVersionViewModel? selectedGameVersion}) => + VanilaState( + gameVersions: gameVersions ?? this.gameVersions, + selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, + ); +} diff --git a/lib/vanila/adapter/presentation/game_version_view_model.dart b/lib/vanila/adapter/presentation/game_version_view_model.dart index 5f9607b..de8a870 100644 --- a/lib/vanila/adapter/presentation/game_version_view_model.dart +++ b/lib/vanila/adapter/presentation/game_version_view_model.dart @@ -9,6 +9,10 @@ class GameVersionViewModel with EquatableMixin { GameVersionViewModel.from(GameVersion gameVersion) : name = gameVersion.name, url = gameVersion.url; + GameVersion toEntity() { + return GameVersion(name: name, url: url); + } + @override List get props => [name, url]; } diff --git a/lib/vanila/application/repository/game_version_repository.dart b/lib/vanila/application/repository/game_version_repository.dart index 836f45f..bf9d2d5 100644 --- a/lib/vanila/application/repository/game_version_repository.dart +++ b/lib/vanila/application/repository/game_version_repository.dart @@ -2,4 +2,6 @@ import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dar abstract interface class GameVersionRepository { Future> getGameVersionList(); + + Future downloadServerFile(GameVersion version, String savePath); } diff --git a/lib/vanila/application/use_case/download_server_file_use_case.dart b/lib/vanila/application/use_case/download_server_file_use_case.dart new file mode 100644 index 0000000..02b5a34 --- /dev/null +++ b/lib/vanila/application/use_case/download_server_file_use_case.dart @@ -0,0 +1,10 @@ +import 'package:minecraft_server_installer/vanila/application/repository/game_version_repository.dart'; +import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; + +class DownloadServerFileUseCase { + final GameVersionRepository _gameVersionRepository; + + DownloadServerFileUseCase(this._gameVersionRepository); + + Future call(GameVersion version, String savePath) => _gameVersionRepository.downloadServerFile(version, savePath); +} diff --git a/lib/vanila/framework/api/game_version_api_service_impl.dart b/lib/vanila/framework/api/game_version_api_service_impl.dart index 8762e79..5cfc416 100644 --- a/lib/vanila/framework/api/game_version_api_service_impl.dart +++ b/lib/vanila/framework/api/game_version_api_service_impl.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:http/http.dart' as http; import 'package:minecraft_server_installer/vanila/adapter/gateway/game_version_api_service.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; @@ -17,4 +19,10 @@ class GameVersionApiServiceImpl implements GameVersionApiService { return gameVersionList; } + + @override + Future fetchServerFile(Uri url) async { + final response = await http.get(url); + return response.bodyBytes; + } } diff --git a/lib/vanila/framework/storage/game_version_file_storage_impl.dart b/lib/vanila/framework/storage/game_version_file_storage_impl.dart new file mode 100644 index 0000000..73c116a --- /dev/null +++ b/lib/vanila/framework/storage/game_version_file_storage_impl.dart @@ -0,0 +1,18 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:minecraft_server_installer/vanila/adapter/gateway/game_version_file_storage.dart'; + +class GameVersionFileStorageImpl implements GameVersionFileStorage { + @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); + } +} diff --git a/lib/vanila/framework/ui/game_version_dropdown.dart b/lib/vanila/framework/ui/game_version_dropdown.dart index f3128fa..4ab1c5f 100644 --- a/lib/vanila/framework/ui/game_version_dropdown.dart +++ b/lib/vanila/framework/ui/game_version_dropdown.dart @@ -5,9 +5,7 @@ import 'package:minecraft_server_installer/vanila/adapter/presentation/game_vers import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; class GameVersionDropdown extends StatefulWidget { - const GameVersionDropdown({super.key, required this.onChanged}); - - final void Function(GameVersionViewModel?) onChanged; + const GameVersionDropdown({super.key}); @override State createState() => _GameVersionDropdownState(); @@ -17,21 +15,25 @@ class _GameVersionDropdownState extends State { @override void initState() { super.initState(); - context.read().add(GameVersionLoadedEvent()); + context.read().add(VanilaGameVersionListLoadedEvent()); } @override - Widget build(BuildContext context) => BlocConsumer>( + Widget build(BuildContext context) => BlocConsumer( listener: (_, __) {}, builder: - (_, gameVersions) => DropdownMenu( - enabled: gameVersions.isNotEmpty, + (_, state) => DropdownMenu( + enabled: state.gameVersions.isNotEmpty, requestFocusOnTap: false, expandedInsets: EdgeInsets.zero, label: const Text(Strings.fieldGameVersion), - onSelected: widget.onChanged, + onSelected: (value) { + if (value != null) { + context.read().add(VanilaGameVersionSelectedEvent(value)); + } + }, dropdownMenuEntries: - gameVersions + state.gameVersions .map( (gameVersion) => DropdownMenuEntry(value: gameVersion, label: gameVersion.name),