MCSI-3 Custom RAM size option #24

Merged
squid merged 4 commits from MCSI-3_custom_ram_size_option into main 2025-07-11 01:39:54 +08:00
6 changed files with 174 additions and 81 deletions

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart'; 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/installation_state.dart';
import 'package:minecraft_server_installer/main/adapter/presentation/progress_view_model.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/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/grant_file_permission_use_case.dart';
import 'package:minecraft_server_installer/main/application/use_case/write_file_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<InstallationEvent, InstallationState> {
); );
final startScriptFilePath = path.join(savePath, Constants.startScriptFileName); final startScriptFilePath = path.join(savePath, Constants.startScriptFileName);
final startScriptContent = Platform.isWindows final startScriptContent =
? 'java -jar .\\${Constants.serverFileName}\r\n' 'java -Xmx${state.ramSize.max}M -Xms${state.ramSize.min}M -jar ${Platform.isWindows ? '.${Constants.serverFileName}\r\n' : './${Constants.serverFileName}\n'}';
: 'java -jar ./${Constants.serverFileName}\n';
await writeFileUseCase(startScriptFilePath, startScriptContent); await writeFileUseCase(startScriptFilePath, startScriptContent);
await grantFilePermissionUseCase(startScriptFilePath); await grantFilePermissionUseCase(startScriptFilePath);
@ -59,10 +59,16 @@ class InstallationBloc extends Bloc<InstallationEvent, InstallationState> {
}); });
on<InstallationConfigurationUpdatedEvent>((event, emit) { on<InstallationConfigurationUpdatedEvent>((event, emit) {
if (event.customRamSize != null && !event.customRamSize!.isValid) {
return;
}
final newState = state.copyWith( final newState = state.copyWith(
gameVersion: event.gameVersion, gameVersion: event.gameVersion,
savePath: event.savePath, savePath: event.savePath,
isEulaAgreed: event.isEulaAgreed, isEulaAgreed: event.isEulaAgreed,
isCustomRamSizeEnabled: event.isCustomRamSizeEnabled,
customRamSize: event.customRamSize,
); );
emit(newState); emit(newState);
}); });
@ -83,10 +89,14 @@ class InstallationConfigurationUpdatedEvent extends InstallationEvent {
final GameVersionViewModel? gameVersion; final GameVersionViewModel? gameVersion;
final String? savePath; final String? savePath;
final bool? isEulaAgreed; final bool? isEulaAgreed;
final bool? isCustomRamSizeEnabled;
final RangeViewModel? customRamSize;
InstallationConfigurationUpdatedEvent({ InstallationConfigurationUpdatedEvent({
this.gameVersion, this.gameVersion,
this.savePath, this.savePath,
this.isEulaAgreed, this.isEulaAgreed,
this.isCustomRamSizeEnabled,
this.customRamSize,
}); });
} }

View File

@ -1,11 +1,16 @@
import 'package:equatable/equatable.dart'; 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/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'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/game_version_view_model.dart';
class InstallationState with EquatableMixin { class InstallationState with EquatableMixin {
static const _defaultRamSize = RangeViewModel(min: 2048, max: 2048);
final GameVersionViewModel? gameVersion; final GameVersionViewModel? gameVersion;
final String? savePath; final String? savePath;
final bool isEulaAgreed; final bool isEulaAgreed;
final bool isCustomRamSizeEnabled;
final RangeViewModel? _customRamSize;
final ProgressViewModel downloadProgress; final ProgressViewModel downloadProgress;
final bool isLocked; final bool isLocked;
@ -13,15 +18,19 @@ class InstallationState with EquatableMixin {
required this.gameVersion, required this.gameVersion,
required this.savePath, required this.savePath,
required this.isEulaAgreed, required this.isEulaAgreed,
required this.isCustomRamSizeEnabled,
required RangeViewModel? customRamSize,
required this.downloadProgress, required this.downloadProgress,
required this.isLocked, required this.isLocked,
}); }) : _customRamSize = customRamSize;
const InstallationState.empty() const InstallationState.empty()
: this( : this(
gameVersion: null, gameVersion: null,
savePath: null, savePath: null,
isEulaAgreed: false, isEulaAgreed: false,
isCustomRamSizeEnabled: false,
customRamSize: _defaultRamSize,
downloadProgress: const ProgressViewModel.zero(), downloadProgress: const ProgressViewModel.zero(),
isLocked: false, isLocked: false,
); );
@ -31,6 +40,8 @@ class InstallationState with EquatableMixin {
gameVersion, gameVersion,
savePath, savePath,
isEulaAgreed, isEulaAgreed,
isCustomRamSizeEnabled,
_customRamSize,
downloadProgress, downloadProgress,
isLocked, isLocked,
]; ];
@ -39,6 +50,8 @@ class InstallationState with EquatableMixin {
GameVersionViewModel? gameVersion, GameVersionViewModel? gameVersion,
String? savePath, String? savePath,
bool? isEulaAgreed, bool? isEulaAgreed,
bool? isCustomRamSizeEnabled,
RangeViewModel? customRamSize,
ProgressViewModel? downloadProgress, ProgressViewModel? downloadProgress,
bool? isLocked, bool? isLocked,
}) => }) =>
@ -46,10 +59,14 @@ class InstallationState with EquatableMixin {
gameVersion: gameVersion ?? this.gameVersion, gameVersion: gameVersion ?? this.gameVersion,
savePath: savePath ?? this.savePath, savePath: savePath ?? this.savePath,
isEulaAgreed: isEulaAgreed ?? this.isEulaAgreed, isEulaAgreed: isEulaAgreed ?? this.isEulaAgreed,
isCustomRamSizeEnabled: isCustomRamSizeEnabled ?? this.isCustomRamSizeEnabled,
customRamSize: customRamSize ?? _customRamSize,
downloadProgress: downloadProgress ?? this.downloadProgress, downloadProgress: downloadProgress ?? this.downloadProgress,
isLocked: isLocked ?? this.isLocked, isLocked: isLocked ?? this.isLocked,
); );
RangeViewModel get ramSize => isCustomRamSizeEnabled ? _customRamSize ?? _defaultRamSize : _defaultRamSize;
bool get isGameVersionSelected => gameVersion != null; bool get isGameVersionSelected => gameVersion != null;
bool get isSavePathSelected => savePath != null && savePath!.isNotEmpty; bool get isSavePathSelected => savePath != null && savePath!.isNotEmpty;

View File

@ -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<Object?> get props => [
min,
max,
];
bool get isValid => min <= max;
}

View File

@ -1,10 +1,11 @@
import 'package:file_picker/file_picker.dart';
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/adapter/presentation/installation_bloc.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/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/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/main/framework/ui/strings.dart';
import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart'; import 'package:minecraft_server_installer/vanilla/framework/ui/game_version_dropdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -17,14 +18,46 @@ class BasicConfigurationTab extends StatelessWidget {
children: [ children: [
const GameVersionDropdown(), const GameVersionDropdown(),
const Gap(16), const Gap(16),
const PathBrowsingField(), _pathBrowsingField,
const Gap(16), const Gap(16),
_eulaCheckbox, _eulaCheckbox,
_enableCustomRamSizeCheckbox,
_customRamSizeControl,
const Spacer(), const Spacer(),
_bottomControl, _bottomControl,
], ],
); );
Widget get _pathBrowsingField => BlocConsumer<InstallationBloc, InstallationState>(
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( Widget get _eulaCheckbox => Row(
children: [ children: [
Expanded( Expanded(
@ -49,6 +82,76 @@ class BasicConfigurationTab extends StatelessWidget {
], ],
); );
Widget get _enableCustomRamSizeCheckbox => BlocConsumer<InstallationBloc, InstallationState>(
listener: (_, __) {},
builder: (context, state) => CheckboxListTile(
title: const Text(Strings.fieldCustomRamSize),
value: state.isCustomRamSizeEnabled,
onChanged: (value) => context
.read<InstallationBloc>()
.add(InstallationConfigurationUpdatedEvent(isCustomRamSizeEnabled: value ?? false)),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
);
Widget get _customRamSizeControl => BlocConsumer<InstallationBloc, InstallationState>(
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<InstallationBloc>().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<InstallationBloc, InstallationState>( Widget get _bottomControl => BlocConsumer<InstallationBloc, InstallationState>(
listener: (_, __) {}, listener: (_, __) {},
builder: (context, state) => Row( builder: (context, state) => Row(
@ -71,6 +174,22 @@ class BasicConfigurationTab extends StatelessWidget {
), ),
); );
Future<void> _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<InstallationBloc>().add(InstallationConfigurationUpdatedEvent(
savePath: directory,
));
}
void _downloadServerFile(BuildContext context) { void _downloadServerFile(BuildContext context) {
context.read<InstallationBloc>().add((InstallationStartedEvent())); context.read<InstallationBloc>().add((InstallationStartedEvent()));
} }

View File

@ -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<PathBrowsingField> createState() => _PathBrowsingFieldState();
}
class _PathBrowsingFieldState extends State<PathBrowsingField> {
final _textEditingController = TextEditingController();
@override
void initState() {
super.initState();
_textEditingController.text = context.read<InstallationBloc>().state.savePath ?? '';
}
@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,
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<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

@ -2,8 +2,11 @@ abstract class Strings {
static const fieldGameVersion = '遊戲版本'; static const fieldGameVersion = '遊戲版本';
static const fieldPath = '安裝路徑'; static const fieldPath = '安裝路徑';
static const fieldEula = '我同意 EULA 條款'; static const fieldEula = '我同意 EULA 條款';
static const fieldCustomRamSize = '啟用自定義 RAM 大小';
static const buttonStartToInstall = '開始安裝'; static const buttonStartToInstall = '開始安裝';
static const buttonBrowse = '瀏覽'; static const buttonBrowse = '瀏覽';
static const labelMinRamSize = '最小 RAM 大小';
static const labelMaxRamSize = '最大 RAM 大小';
static const tooltipEulaInfo = '點擊查看 EULA 條款'; static const tooltipEulaInfo = '點擊查看 EULA 條款';
static const dialogTitleSelectDirectory = '選擇安裝目錄'; static const dialogTitleSelectDirectory = '選擇安裝目錄';
} }