MCSI-2 Browse installing directory #19

Merged
squid merged 5 commits from MCSI-2_browse_installing_path into main 2025-07-10 22:38:48 +08:00
19 changed files with 284 additions and 66 deletions
Showing only changes of commit 8f233ea552 - Show all commits

View File

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

View File

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

3
devtools_options.yaml Normal file
View File

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

View File

@ -0,0 +1,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<InstallationEvent, InstallationState> {
InstallationBloc() : super(const InstallationState.empty()) {
on<InstallationConfigurationUpdatedEvent>((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,
});
}

View File

@ -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<Object?> 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;
}

View File

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

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/installation_bloc.dart';
import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart'; import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart';
import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart';
import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart';
@ -29,11 +30,10 @@ class MinecraftServerInstaller extends StatelessWidget {
home: MultiBlocProvider( home: MultiBlocProvider(
providers: [ providers: [
BlocProvider<VanillaBloc>( BlocProvider<VanillaBloc>(
create: create: (_) => VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase)
(context) =>
VanillaBloc(getGameVersionListUseCase, downloadServerFileUseCase)
..add(VanillaGameVersionListLoadedEvent()), ..add(VanillaGameVersionListLoadedEvent()),
), ),
BlocProvider(create: (_) => InstallationBloc())
], ],
child: Scaffold( child: Scaffold(
body: BlocConsumer<VanillaBloc, VanillaState>( body: BlocConsumer<VanillaBloc, VanillaState>(

View File

@ -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<PathBrowsingField> createState() => _PathBrowsingFieldState();
}
class _PathBrowsingFieldState extends State<PathBrowsingField> {
final _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) => BlocConsumer<InstallationBloc, InstallationState>(
listener: (_, state) {
if (state.savePath != null) {
_textEditingController.text = state.savePath!;
}
},
builder: (_, __) => Row(
children: [
Expanded(
child: TextField(
controller: _textEditingController,
readOnly: true,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)),
label: const Text('${Strings.fieldPath} *'),
),
),
),
const Gap(8),
SizedBox(
height: 48,
child: OutlinedButton(
onPressed: _browseDirectory,
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: const Text(Strings.buttonBrowse),
),
),
],
),
);
Future<void> _browseDirectory() async {
final directory = await FilePicker.platform.getDirectoryPath(
dialogTitle: Strings.dialogTitleSelectDirectory,
initialDirectory: _textEditingController.text.isNotEmpty ? _textEditingController.text : null,
);
if (!mounted || directory == null) {
return;
}
context.read<InstallationBloc>().add(InstallationConfigurationUpdatedEvent(
savePath: directory,
));
}
}

View File

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

View File

@ -19,7 +19,10 @@ class VanillaRepositoryImpl implements VanillaRepository {
String savePath, { String savePath, {
DownloadProgressCallback? onProgressChanged, DownloadProgressCallback? onProgressChanged,
}) async { }) async {
final fileBytes = await _gameVersionApiService.fetchServerFile(version.url, onProgressChanged: onProgressChanged); final fileBytes = await _gameVersionApiService.fetchServerFile(
version.url,
onProgressChanged: onProgressChanged,
);
await _gameVersionFileStorage.saveFile(fileBytes, savePath); await _gameVersionFileStorage.saveFile(fileBytes, savePath);
} }
} }

View File

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

View File

@ -16,7 +16,7 @@ class VanillaBloc extends Bloc<VanillaEvent, VanillaState> {
final gameVersions = await _getGameVersionListUseCase(); final gameVersions = await _getGameVersionListUseCase();
emit( emit(
const VanillaState.empty().copyWith( const VanillaState.empty().copyWith(
gameVersions: gameVersions.map((entity) => GameVersionViewModel.from(entity)).toList(), gameVersions: gameVersions.map((entity) => GameVersionViewModel.fromEntity(entity)).toList(),
), ),
); );
} on Exception { } on Exception {

View File

@ -15,10 +15,20 @@ class VanillaState with EquatableMixin {
}); });
const VanillaState.empty() const VanillaState.empty()
: this(isLocked: false, downloadProgress: 0, gameVersions: const [], selectedGameVersion: null); : this(
isLocked: false,
downloadProgress: 0,
gameVersions: const [],
selectedGameVersion: null,
);
@override @override
List<Object?> get props => [isLocked, downloadProgress, gameVersions, selectedGameVersion]; List<Object?> get props => [
isLocked,
downloadProgress,
gameVersions,
selectedGameVersion,
];
bool get isGameVersionSelected => selectedGameVersion != null; bool get isGameVersionSelected => selectedGameVersion != null;
@ -29,7 +39,8 @@ class VanillaState with EquatableMixin {
double? downloadProgress, double? downloadProgress,
List<GameVersionViewModel>? gameVersions, List<GameVersionViewModel>? gameVersions,
GameVersionViewModel? selectedGameVersion, GameVersionViewModel? selectedGameVersion,
}) => VanillaState( }) =>
VanillaState(
isLocked: isLocked ?? this.isLocked, isLocked: isLocked ?? this.isLocked,
downloadProgress: downloadProgress ?? this.downloadProgress, downloadProgress: downloadProgress ?? this.downloadProgress,
gameVersions: gameVersions ?? this.gameVersions, gameVersions: gameVersions ?? this.gameVersions,

View File

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

View File

@ -8,6 +8,14 @@ class DownloadServerFileUseCase {
DownloadServerFileUseCase(this._gameVersionRepository); DownloadServerFileUseCase(this._gameVersionRepository);
Future<void> call(GameVersion version, String savePath, {DownloadProgressCallback? onProgressChanged}) => Future<void> call(
_gameVersionRepository.downloadServerFile(version, savePath, onProgressChanged: onProgressChanged); GameVersion version,
String savePath, {
DownloadProgressCallback? onProgressChanged,
}) =>
_gameVersionRepository.downloadServerFile(
version,
savePath,
onProgressChanged: onProgressChanged,
);
} }

View File

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

View File

@ -11,8 +11,7 @@ class GameVersionDropdown extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocConsumer<VanillaBloc, VanillaState>( Widget build(BuildContext context) => BlocConsumer<VanillaBloc, VanillaState>(
listener: (_, __) {}, listener: (_, __) {},
builder: builder: (_, state) => DropdownMenu(
(_, state) => DropdownMenu(
initialSelection: state.selectedGameVersion, initialSelection: state.selectedGameVersion,
enabled: state.gameVersions.isNotEmpty, enabled: state.gameVersions.isNotEmpty,
requestFocusOnTap: false, requestFocusOnTap: false,
@ -23,12 +22,11 @@ class GameVersionDropdown extends StatelessWidget {
context.read<VanillaBloc>().add(VanillaGameVersionSelectedEvent(value)); context.read<VanillaBloc>().add(VanillaGameVersionSelectedEvent(value));
} }
}, },
dropdownMenuEntries: dropdownMenuEntries: state.gameVersions
state.gameVersions .map((gameVersion) => DropdownMenuEntry<GameVersionViewModel>(
.map( value: gameVersion,
(gameVersion) => label: gameVersion.name,
DropdownMenuEntry<GameVersionViewModel>(value: gameVersion, label: gameVersion.name), ))
)
.toList(), .toList(),
), ),
); );

View File

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

View File

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