diff --git a/lib/main/adapter/presentation/installation_bloc.dart b/lib/main/adapter/presentation/installation_bloc.dart index a6c5701..2d3a76b 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'; @@ -33,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); @@ -59,10 +59,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 +89,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 7a8392a..f38736e 100644 --- a/lib/main/framework/ui/basic_configuration_tab.dart +++ b/lib/main/framework/ui/basic_configuration_tab.dart @@ -1,10 +1,11 @@ +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/adapter/presentation/range_view_model.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,14 +18,46 @@ class BasicConfigurationTab extends StatelessWidget { children: [ const GameVersionDropdown(), const Gap(16), - const PathBrowsingField(), + _pathBrowsingField, const Gap(16), _eulaCheckbox, + _enableCustomRamSizeCheckbox, + _customRamSizeControl, const Spacer(), _bottomControl, ], ); + 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( @@ -49,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( @@ -71,6 +174,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 eb43b10..0000000 --- a/lib/main/framework/ui/path_browsing_field.dart +++ /dev/null @@ -1,75 +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 StatefulWidget { - 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( - children: [ - Expanded( - child: TextField( - controller: _textEditingController, - 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, - 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 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 = '選擇安裝目錄'; }