From e86781c9a3e37a06428430a621919f5a4e46271e Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Fri, 11 Jul 2025 01:07:50 +0800 Subject: [PATCH 1/4] MCSI-3 refactor: make path browsing field stateless --- .../framework/ui/path_browsing_field.dart | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/lib/main/framework/ui/path_browsing_field.dart b/lib/main/framework/ui/path_browsing_field.dart index eb43b10..ec86dad 100644 --- a/lib/main/framework/ui/path_browsing_field.dart +++ b/lib/main/framework/ui/path_browsing_field.dart @@ -6,35 +6,17 @@ import 'package:minecraft_server_installer/main/adapter/presentation/installatio 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 { +class PathBrowsingField extends StatelessWidget { const PathBrowsingField({super.key}); - @override - State createState() => _PathBrowsingFieldState(); -} - -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) { - if (state.savePath != null) { - _textEditingController.text = state.savePath!; - } - }, - builder: (_, __) => Row( + listener: (_, __) {}, + builder: (_, state) => Row( children: [ Expanded( child: TextField( - controller: _textEditingController, + controller: TextEditingController(text: state.savePath ?? ''), readOnly: true, canRequestFocus: false, decoration: InputDecoration( @@ -47,7 +29,7 @@ class _PathBrowsingFieldState extends State { SizedBox( height: 48, child: OutlinedButton( - onPressed: _browseDirectory, + onPressed: () => _browseDirectory(context, initialPath: state.savePath), style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), ), @@ -58,13 +40,14 @@ class _PathBrowsingFieldState extends State { ), ); - Future _browseDirectory() async { + Future _browseDirectory(BuildContext context, {String? initialPath}) async { + final hasInitialPath = initialPath?.isNotEmpty ?? false; final directory = await FilePicker.platform.getDirectoryPath( dialogTitle: Strings.dialogTitleSelectDirectory, - initialDirectory: _textEditingController.text.isNotEmpty ? _textEditingController.text : null, + initialDirectory: hasInitialPath ? initialPath : null, ); - if (!mounted || directory == null) { + if (!context.mounted || directory == null) { return; } -- 2.47.2 From 788eede242f91e1705e08b6ab52ba96eaa2d1a76 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Fri, 11 Jul 2025 01:12:45 +0800 Subject: [PATCH 2/4] MCSI-3 refactor: move `path_browsing_field` into `basic_configuration_tab` --- .../framework/ui/basic_configuration_tab.dart | 50 +++++++++++++++- .../framework/ui/path_browsing_field.dart | 58 ------------------- 2 files changed, 48 insertions(+), 60 deletions(-) delete mode 100644 lib/main/framework/ui/path_browsing_field.dart diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 7a8392a..8069e03 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,10 +1,10 @@ +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/constants.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/framework/ui/game_version_dropdown.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -17,7 +17,7 @@ class BasicConfigurationTab extends StatelessWidget { children: [ const GameVersionDropdown(), const Gap(16), - const PathBrowsingField(), + _pathBrowsingField, const Gap(16), _eulaCheckbox, const Spacer(), @@ -25,6 +25,36 @@ class BasicConfigurationTab extends StatelessWidget { ], ); + Widget get _pathBrowsingField => BlocConsumer( + listener: (_, __) {}, + builder: (context, state) => Row( + children: [ + Expanded( + child: TextField( + controller: TextEditingController(text: state.savePath ?? ''), + readOnly: true, + canRequestFocus: false, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + label: const Text('${Strings.fieldPath} *'), + ), + ), + ), + const Gap(8), + SizedBox( + height: 48, + child: OutlinedButton( + onPressed: () => _browseDirectory(context, initialPath: state.savePath), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: const Text(Strings.buttonBrowse), + ), + ), + ], + ), + ); + Widget get _eulaCheckbox => Row( children: [ Expanded( @@ -71,6 +101,22 @@ class BasicConfigurationTab extends StatelessWidget { ), ); + Future _browseDirectory(BuildContext context, {String? initialPath}) async { + final hasInitialPath = initialPath?.isNotEmpty ?? false; + final directory = await FilePicker.platform.getDirectoryPath( + dialogTitle: Strings.dialogTitleSelectDirectory, + initialDirectory: hasInitialPath ? initialPath : null, + ); + + if (!context.mounted || directory == null) { + return; + } + + context.read().add(InstallationConfigurationUpdatedEvent( + savePath: directory, + )); + } + void _downloadServerFile(BuildContext context) { context.read().add((InstallationStartedEvent())); } diff --git a/lib/main/framework/ui/path_browsing_field.dart b/lib/main/framework/ui/path_browsing_field.dart deleted file mode 100644 index ec86dad..0000000 --- a/lib/main/framework/ui/path_browsing_field.dart +++ /dev/null @@ -1,58 +0,0 @@ -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 StatelessWidget { - const PathBrowsingField({super.key}); - - @override - Widget build(BuildContext context) => BlocConsumer( - listener: (_, __) {}, - builder: (_, state) => Row( - children: [ - Expanded( - child: TextField( - controller: TextEditingController(text: state.savePath ?? ''), - readOnly: true, - canRequestFocus: false, - decoration: InputDecoration( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), - label: const Text('${Strings.fieldPath} *'), - ), - ), - ), - const Gap(8), - SizedBox( - height: 48, - child: OutlinedButton( - onPressed: () => _browseDirectory(context, initialPath: state.savePath), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), - ), - child: const Text(Strings.buttonBrowse), - ), - ), - ], - ), - ); - - Future _browseDirectory(BuildContext context, {String? initialPath}) async { - final hasInitialPath = initialPath?.isNotEmpty ?? false; - final directory = await FilePicker.platform.getDirectoryPath( - dialogTitle: Strings.dialogTitleSelectDirectory, - initialDirectory: hasInitialPath ? initialPath : null, - ); - - if (!context.mounted || directory == null) { - return; - } - - context.read().add(InstallationConfigurationUpdatedEvent( - savePath: directory, - )); - } -} -- 2.47.2 From 8d89246b1a0f59cc966edfdaa0ce86f02448e364 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Fri, 11 Jul 2025 01:28:36 +0800 Subject: [PATCH 3/4] MCSI-3 feat: range slider --- .../presentation/installation_bloc.dart | 11 +++ .../presentation/installation_state.dart | 19 ++++- .../presentation/range_view_model.dart | 19 +++++ .../framework/ui/basic_configuration_tab.dart | 73 +++++++++++++++++++ lib/main/framework/ui/strings.dart | 3 + 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 lib/main/adapter/presentation/range_view_model.dart diff --git a/lib/main/adapter/presentation/installation_bloc.dart b/lib/main/adapter/presentation/installation_bloc.dart index a6c5701..f57dec1 100644 --- a/lib/main/adapter/presentation/installation_bloc.dart +++ b/lib/main/adapter/presentation/installation_bloc.dart @@ -3,6 +3,7 @@ import 'dart:io'; 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/adapter/presentation/range_view_model.dart'; import 'package:minecraft_server_installer/main/application/use_case/download_file_use_case.dart'; import 'package:minecraft_server_installer/main/application/use_case/grant_file_permission_use_case.dart'; import 'package:minecraft_server_installer/main/application/use_case/write_file_use_case.dart'; @@ -59,10 +60,16 @@ class InstallationBloc extends Bloc { }); on((event, emit) { + if (event.customRamSize != null && !event.customRamSize!.isValid) { + return; + } + final newState = state.copyWith( gameVersion: event.gameVersion, savePath: event.savePath, isEulaAgreed: event.isEulaAgreed, + isCustomRamSizeEnabled: event.isCustomRamSizeEnabled, + customRamSize: event.customRamSize, ); emit(newState); }); @@ -83,10 +90,14 @@ class InstallationConfigurationUpdatedEvent extends InstallationEvent { final GameVersionViewModel? gameVersion; final String? savePath; final bool? isEulaAgreed; + final bool? isCustomRamSizeEnabled; + final RangeViewModel? customRamSize; InstallationConfigurationUpdatedEvent({ this.gameVersion, this.savePath, this.isEulaAgreed, + this.isCustomRamSizeEnabled, + this.customRamSize, }); } diff --git a/lib/main/adapter/presentation/installation_state.dart b/lib/main/adapter/presentation/installation_state.dart index 977b558..4a0a8c2 100644 --- a/lib/main/adapter/presentation/installation_state.dart +++ b/lib/main/adapter/presentation/installation_state.dart @@ -1,11 +1,16 @@ import 'package:equatable/equatable.dart'; import 'package:minecraft_server_installer/main/adapter/presentation/progress_view_model.dart'; +import 'package:minecraft_server_installer/main/adapter/presentation/range_view_model.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart'; class InstallationState with EquatableMixin { + static const _defaultRamSize = RangeViewModel(min: 2048, max: 2048); + final GameVersionViewModel? gameVersion; final String? savePath; final bool isEulaAgreed; + final bool isCustomRamSizeEnabled; + final RangeViewModel? _customRamSize; final ProgressViewModel downloadProgress; final bool isLocked; @@ -13,15 +18,19 @@ class InstallationState with EquatableMixin { required this.gameVersion, required this.savePath, required this.isEulaAgreed, + required this.isCustomRamSizeEnabled, + required RangeViewModel? customRamSize, required this.downloadProgress, required this.isLocked, - }); + }) : _customRamSize = customRamSize; const InstallationState.empty() : this( gameVersion: null, savePath: null, isEulaAgreed: false, + isCustomRamSizeEnabled: false, + customRamSize: _defaultRamSize, downloadProgress: const ProgressViewModel.zero(), isLocked: false, ); @@ -31,6 +40,8 @@ class InstallationState with EquatableMixin { gameVersion, savePath, isEulaAgreed, + isCustomRamSizeEnabled, + _customRamSize, downloadProgress, isLocked, ]; @@ -39,6 +50,8 @@ class InstallationState with EquatableMixin { GameVersionViewModel? gameVersion, String? savePath, bool? isEulaAgreed, + bool? isCustomRamSizeEnabled, + RangeViewModel? customRamSize, ProgressViewModel? downloadProgress, bool? isLocked, }) => @@ -46,10 +59,14 @@ class InstallationState with EquatableMixin { gameVersion: gameVersion ?? this.gameVersion, savePath: savePath ?? this.savePath, isEulaAgreed: isEulaAgreed ?? this.isEulaAgreed, + isCustomRamSizeEnabled: isCustomRamSizeEnabled ?? this.isCustomRamSizeEnabled, + customRamSize: customRamSize ?? _customRamSize, downloadProgress: downloadProgress ?? this.downloadProgress, isLocked: isLocked ?? this.isLocked, ); + RangeViewModel get ramSize => isCustomRamSizeEnabled ? _customRamSize ?? _defaultRamSize : _defaultRamSize; + bool get isGameVersionSelected => gameVersion != null; bool get isSavePathSelected => savePath != null && savePath!.isNotEmpty; diff --git a/lib/main/adapter/presentation/range_view_model.dart b/lib/main/adapter/presentation/range_view_model.dart new file mode 100644 index 0000000..2aa9a2e --- /dev/null +++ b/lib/main/adapter/presentation/range_view_model.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; + +class RangeViewModel with EquatableMixin { + final int min; + final int max; + + const RangeViewModel({ + required this.min, + required this.max, + }); + + @override + List get props => [ + min, + max, + ]; + + bool get isValid => min <= max; +} diff --git a/lib/main/framework/ui/basic_configuration_tab.dart b/lib/main/framework/ui/basic_configuration_tab.dart index 8069e03..f38736e 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -4,6 +4,7 @@ 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/adapter/presentation/range_view_model.dart'; import 'package:minecraft_server_installer/main/constants.dart'; import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart'; @@ -20,6 +21,8 @@ class BasicConfigurationTab extends StatelessWidget { _pathBrowsingField, const Gap(16), _eulaCheckbox, + _enableCustomRamSizeCheckbox, + _customRamSizeControl, const Spacer(), _bottomControl, ], @@ -79,6 +82,76 @@ class BasicConfigurationTab extends StatelessWidget { ], ); + Widget get _enableCustomRamSizeCheckbox => BlocConsumer( + listener: (_, __) {}, + builder: (context, state) => CheckboxListTile( + title: const Text(Strings.fieldCustomRamSize), + value: state.isCustomRamSizeEnabled, + onChanged: (value) => context + .read() + .add(InstallationConfigurationUpdatedEvent(isCustomRamSizeEnabled: value ?? false)), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + ); + + Widget get _customRamSizeControl => BlocConsumer( + listener: (_, __) {}, + builder: (context, state) { + if (!state.isCustomRamSizeEnabled) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + RangeSlider( + values: RangeValues(state.ramSize.min.toDouble(), state.ramSize.max.toDouble()), + min: 512, + max: 16384, + divisions: (16384 - 512) ~/ 128, + labels: RangeLabels( + '${state.ramSize.min} MB', + '${state.ramSize.max} MB', + ), + onChanged: (values) => context.read().add( + InstallationConfigurationUpdatedEvent( + customRamSize: RangeViewModel(min: values.start.toInt(), max: values.end.toInt()), + ), + ), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: TextEditingController(text: state.ramSize.min.toString()), + canRequestFocus: false, + readOnly: true, + decoration: InputDecoration( + label: const Text('${Strings.labelMinRamSize} (MB)'), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + ), + ), + ), + const Gap(16), + Expanded( + child: TextField( + controller: TextEditingController(text: state.ramSize.max.toString()), + canRequestFocus: false, + readOnly: true, + decoration: InputDecoration( + label: const Text('${Strings.labelMaxRamSize} (MB)'), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + ), + ), + ), + ], + ) + ], + ); + }, + ); + Widget get _bottomControl => BlocConsumer( listener: (_, __) {}, builder: (context, state) => Row( diff --git a/lib/main/framework/ui/strings.dart b/lib/main/framework/ui/strings.dart index 56292f6..8210f2d 100644 --- a/lib/main/framework/ui/strings.dart +++ b/lib/main/framework/ui/strings.dart @@ -2,8 +2,11 @@ abstract class Strings { static const fieldGameVersion = '遊戲版本'; static const fieldPath = '安裝路徑'; static const fieldEula = '我同意 EULA 條款'; + static const fieldCustomRamSize = '啟用自定義 RAM 大小'; static const buttonStartToInstall = '開始安裝'; static const buttonBrowse = '瀏覽'; + static const labelMinRamSize = '最小 RAM 大小'; + static const labelMaxRamSize = '最大 RAM 大小'; static const tooltipEulaInfo = '點擊查看 EULA 條款'; static const dialogTitleSelectDirectory = '選擇安裝目錄'; } -- 2.47.2 From 9dc11db1fd35f01e8cf597dca43064f5d9f770f2 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Fri, 11 Jul 2025 01:35:57 +0800 Subject: [PATCH 4/4] MCSI-3 feat: write the ram setting to the start script --- lib/main/adapter/presentation/installation_bloc.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/main/adapter/presentation/installation_bloc.dart b/lib/main/adapter/presentation/installation_bloc.dart index f57dec1..2d3a76b 100644 --- a/lib/main/adapter/presentation/installation_bloc.dart +++ b/lib/main/adapter/presentation/installation_bloc.dart @@ -34,9 +34,8 @@ class InstallationBloc extends Bloc { ); final startScriptFilePath = path.join(savePath, Constants.startScriptFileName); - final startScriptContent = Platform.isWindows - ? 'java -jar .\\${Constants.serverFileName}\r\n' - : 'java -jar ./${Constants.serverFileName}\n'; + final startScriptContent = + 'java -Xmx${state.ramSize.max}M -Xms${state.ramSize.min}M -jar ${Platform.isWindows ? '.${Constants.serverFileName}\r\n' : './${Constants.serverFileName}\n'}'; await writeFileUseCase(startScriptFilePath, startScriptContent); await grantFilePermissionUseCase(startScriptFilePath); -- 2.47.2