From f1716b05050bb3b3252ff93b74765d6540df98e2 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Sun, 29 Jun 2025 17:23:14 +0900 Subject: [PATCH] MCSI-1 feat: download progress --- lib/main/constants.dart | 1 + .../framework/ui/basic_configuration_tab.dart | 27 ++++++++++++-- .../ui/minecraft_server_installer.dart | 24 ++++++++++-- .../adapter/gateway/vanila_api_service.dart | 3 +- .../gateway/vanila_repository_impl.dart | 9 ++++- .../adapter/presentation/vanila_bloc.dart | 27 ++++++++++++-- .../adapter/presentation/vanila_state.dart | 35 +++++++++++++----- .../repository/vanila_repository.dart | 3 +- .../download_server_file_use_case.dart | 5 ++- .../api/vanila_api_service_impl.dart | 37 +++++++++++++++++-- .../framework/ui/game_version_dropdown.dart | 13 +------ pubspec.lock | 8 ++++ pubspec.yaml | 4 +- 13 files changed, 152 insertions(+), 44 deletions(-) diff --git a/lib/main/constants.dart b/lib/main/constants.dart index 34de687..1226125 100644 --- a/lib/main/constants.dart +++ b/lib/main/constants.dart @@ -1,3 +1,4 @@ abstract class Constants { + static const gameVersionListUrl = 'https://www.dropbox.com/s/mtz3moc9dpjtz7s/GameVersions.txt?dl=1'; static const serverFileName = 'server.jar'; } diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index ca145a0..c29173b 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,8 +1,10 @@ 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/strings.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_bloc.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; +import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_state.dart'; import 'package:minecraft_server_installer/vanila/framework/ui/game_version_dropdown.dart'; class BasicConfigurationTab extends StatefulWidget { @@ -21,10 +23,27 @@ class _BasicConfigurationTabState extends State { children: [ const GameVersionDropdown(), const Spacer(), - ElevatedButton.icon( - onPressed: context.watch().state.isGameVersionSelected ? _downloadServerFile : null, - icon: const Icon(Icons.download), - label: const Text(Strings.buttonStartToInstall), + 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), + ), + ), + ], + ), ), ], ); diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index 5913c58..3151b1b 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -3,6 +3,7 @@ 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/vanila_repository_impl.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_bloc.dart'; +import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_state.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/vanila_api_service_impl.dart'; @@ -11,6 +12,9 @@ import 'package:minecraft_server_installer/vanila/framework/storage/vanila_file_ class MinecraftServerInstaller extends StatelessWidget { const MinecraftServerInstaller({super.key}); + Widget get _body => + const Padding(padding: EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: BasicConfigurationTab()); + @override Widget build(BuildContext context) { final gameVersionApiService = VanilaApiServiceImpl(); @@ -21,15 +25,27 @@ class MinecraftServerInstaller extends StatelessWidget { return MaterialApp( title: 'Minecraft Server Installer', - theme: ThemeData(primarySwatch: Colors.blue), + theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)), home: MultiBlocProvider( providers: [ BlocProvider( - create: (context) => VanilaBloc(getGameVersionListUseCase, downloadServerFileUseCase), + create: + (context) => + VanilaBloc(getGameVersionListUseCase, downloadServerFileUseCase) + ..add(VanilaGameVersionListLoadedEvent()), ), ], - child: const Scaffold( - body: Padding(padding: EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: BasicConfigurationTab()), + child: Scaffold( + body: BlocConsumer( + listener: (_, __) {}, + builder: (_, state) { + if (state.isLocked) { + return MouseRegion(cursor: SystemMouseCursors.forbidden, child: AbsorbPointer(child: _body)); + } + + return _body; + }, + ), ), ), ); diff --git a/lib/vanila/adapter/gateway/vanila_api_service.dart b/lib/vanila/adapter/gateway/vanila_api_service.dart index a3fa6fb..0e7d0a4 100644 --- a/lib/vanila/adapter/gateway/vanila_api_service.dart +++ b/lib/vanila/adapter/gateway/vanila_api_service.dart @@ -1,9 +1,10 @@ import 'dart:typed_data'; +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; abstract interface class VanilaApiService { Future> fetchGameVersionList(); - Future fetchServerFile(Uri url); + Future fetchServerFile(Uri url, {DownloadProgressCallback? onProgressChanged}); } diff --git a/lib/vanila/adapter/gateway/vanila_repository_impl.dart b/lib/vanila/adapter/gateway/vanila_repository_impl.dart index f60e44e..4edcdd0 100644 --- a/lib/vanila/adapter/gateway/vanila_repository_impl.dart +++ b/lib/vanila/adapter/gateway/vanila_repository_impl.dart @@ -1,6 +1,7 @@ import 'package:minecraft_server_installer/vanila/adapter/gateway/vanila_api_service.dart'; import 'package:minecraft_server_installer/vanila/adapter/gateway/vanila_file_storage.dart'; import 'package:minecraft_server_installer/vanila/application/repository/vanila_repository.dart'; +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; class VanilaRepositoryImpl implements VanilaRepository { @@ -13,8 +14,12 @@ class VanilaRepositoryImpl implements VanilaRepository { Future> getGameVersionList() => _gameVersionApiService.fetchGameVersionList(); @override - Future downloadServerFile(GameVersion version, String savePath) async { - final fileBytes = await _gameVersionApiService.fetchServerFile(version.url); + 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/vanila/adapter/presentation/vanila_bloc.dart b/lib/vanila/adapter/presentation/vanila_bloc.dart index 65be91d..f436bda 100644 --- a/lib/vanila/adapter/presentation/vanila_bloc.dart +++ b/lib/vanila/adapter/presentation/vanila_bloc.dart @@ -15,9 +15,8 @@ class VanilaBloc extends Bloc { try { final gameVersions = await _getGameVersionListUseCase(); emit( - VanilaState( + const VanilaState.empty().copyWith( gameVersions: gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList(), - selectedGameVersion: null, ), ); } on Exception { @@ -35,7 +34,23 @@ class VanilaBloc extends Bloc { return; } - await _downloadServerFileUseCase(gameVersion.toEntity(), path.join('.', Constants.serverFileName)); + emit(state.copyWith(isLocked: true)); + await _downloadServerFileUseCase( + gameVersion.toEntity(), + path.join('.', Constants.serverFileName), + onProgressChanged: (progress) => add(_VanilaDownloadProgressChangedEvent(progress)), + ); + emit(state.copyWith(isLocked: false)); + }); + + on<_VanilaDownloadProgressChangedEvent>((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)); + } }); } } @@ -51,3 +66,9 @@ class VanilaGameVersionSelectedEvent extends VanilaEvent { } class VanilaServerFileDownloadedEvent extends VanilaEvent {} + +class _VanilaDownloadProgressChangedEvent extends VanilaEvent { + final double progress; + + _VanilaDownloadProgressChangedEvent(this.progress); +} diff --git a/lib/vanila/adapter/presentation/vanila_state.dart b/lib/vanila/adapter/presentation/vanila_state.dart index ee1ca90..0c46e45 100644 --- a/lib/vanila/adapter/presentation/vanila_state.dart +++ b/lib/vanila/adapter/presentation/vanila_state.dart @@ -1,23 +1,38 @@ - import 'package:equatable/equatable.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; class VanilaState with EquatableMixin { + final bool isLocked; + final double downloadProgress; final List gameVersions; final GameVersionViewModel? selectedGameVersion; - const VanilaState({required this.gameVersions, required this.selectedGameVersion}); + const VanilaState({ + required this.isLocked, + required this.downloadProgress, + required this.gameVersions, + required this.selectedGameVersion, + }); - const VanilaState.empty() : this(gameVersions: const [], selectedGameVersion: null); + const VanilaState.empty() + : this(isLocked: false, downloadProgress: 0, gameVersions: const [], selectedGameVersion: null); @override - List get props => [gameVersions, selectedGameVersion]; + List get props => [isLocked, downloadProgress, gameVersions, selectedGameVersion]; bool get isGameVersionSelected => selectedGameVersion != null; - VanilaState copyWith({List? gameVersions, GameVersionViewModel? selectedGameVersion}) => - VanilaState( - gameVersions: gameVersions ?? this.gameVersions, - selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, - ); -} \ No newline at end of file + bool get isDownloading => downloadProgress > 0 && downloadProgress < 1; + + VanilaState copyWith({ + bool? isLocked, + double? downloadProgress, + List? gameVersions, + GameVersionViewModel? selectedGameVersion, + }) => VanilaState( + isLocked: isLocked ?? this.isLocked, + downloadProgress: downloadProgress ?? this.downloadProgress, + gameVersions: gameVersions ?? this.gameVersions, + selectedGameVersion: selectedGameVersion ?? this.selectedGameVersion, + ); +} diff --git a/lib/vanila/application/repository/vanila_repository.dart b/lib/vanila/application/repository/vanila_repository.dart index faa8b62..436f145 100644 --- a/lib/vanila/application/repository/vanila_repository.dart +++ b/lib/vanila/application/repository/vanila_repository.dart @@ -1,7 +1,8 @@ +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; abstract interface class VanilaRepository { Future> getGameVersionList(); - Future downloadServerFile(GameVersion version, String savePath); + Future downloadServerFile(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}); } 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 index e01d2a8..89ebf4a 100644 --- a/lib/vanila/application/use_case/download_server_file_use_case.dart +++ b/lib/vanila/application/use_case/download_server_file_use_case.dart @@ -1,10 +1,13 @@ import 'package:minecraft_server_installer/vanila/application/repository/vanila_repository.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; +typedef DownloadProgressCallback = void Function(double progress); + class DownloadServerFileUseCase { final VanilaRepository _gameVersionRepository; DownloadServerFileUseCase(this._gameVersionRepository); - Future call(GameVersion version, String savePath) => _gameVersionRepository.downloadServerFile(version, savePath); + Future call(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}) => + _gameVersionRepository.downloadServerFile(version, savePath, onProgressChanged: onProgressChanged); } diff --git a/lib/vanila/framework/api/vanila_api_service_impl.dart b/lib/vanila/framework/api/vanila_api_service_impl.dart index 19fd8c1..d288ab0 100644 --- a/lib/vanila/framework/api/vanila_api_service_impl.dart +++ b/lib/vanila/framework/api/vanila_api_service_impl.dart @@ -1,13 +1,16 @@ +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/vanila/adapter/gateway/vanila_api_service.dart'; +import 'package:minecraft_server_installer/vanila/application/use_case/download_server_file_use_case.dart'; import 'package:minecraft_server_installer/vanila/domain/entity/game_version.dart'; class VanilaApiServiceImpl implements VanilaApiService { @override Future> fetchGameVersionList() async { - final sourceUrl = Uri.parse('https://www.dropbox.com/s/mtz3moc9dpjtz7s/GameVersions.txt?dl=1'); + final sourceUrl = Uri.parse(Constants.gameVersionListUrl); final response = await http.get(sourceUrl); final rawGameVersionList = response.body.split('\n'); @@ -21,8 +24,34 @@ class VanilaApiServiceImpl implements VanilaApiService { } @override - Future fetchServerFile(Uri url) async { - final response = await http.get(url); - return response.bodyBytes; + 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/vanila/framework/ui/game_version_dropdown.dart b/lib/vanila/framework/ui/game_version_dropdown.dart index 7f5b8af..500800b 100644 --- a/lib/vanila/framework/ui/game_version_dropdown.dart +++ b/lib/vanila/framework/ui/game_version_dropdown.dart @@ -5,20 +5,9 @@ import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_bl import 'package:minecraft_server_installer/vanila/adapter/presentation/game_version_view_model.dart'; import 'package:minecraft_server_installer/vanila/adapter/presentation/vanila_state.dart'; -class GameVersionDropdown extends StatefulWidget { +class GameVersionDropdown extends StatelessWidget { const GameVersionDropdown({super.key}); - @override - State createState() => _GameVersionDropdownState(); -} - -class _GameVersionDropdownState extends State { - @override - void initState() { - super.initState(); - context.read().add(VanilaGameVersionListLoadedEvent()); - } - @override Widget build(BuildContext context) => BlocConsumer( listener: (_, __) {}, diff --git a/pubspec.lock b/pubspec.lock index fe23d2a..f8cf391 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -99,6 +99,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" http: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7c90d05..8c714c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: minecraft_server_installer description: "A tool that makes installing a Minecraft Server easier." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 equatable: ^2.0.7 flutter_bloc: ^9.1.1 + gap: ^3.0.1 http: ^1.4.0 path: ^1.9.1 @@ -55,7 +56,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class.