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