From 152e8aa2391b93a45cb936af9b3d14210cd06f31 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Sun, 13 Jul 2025 03:27:19 +0800 Subject: [PATCH] MCSI-8 feat: add server properties management UI and inline text field component --- .../use_case/install_server_use_case.dart | 4 +- .../ui/minecraft_server_installer.dart | 12 +- lib/main/framework/ui/strings.dart | 22 +++ .../presenter/server_properties_bloc.dart | 2 +- .../server_properties_view_model.dart | 30 ++-- .../framework/ui/inline_text_field.dart | 89 ++++++++++ .../framework/ui/server_properties_tab.dart | 152 ++++++++++++++++++ 7 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 lib/properties/framework/ui/inline_text_field.dart create mode 100644 lib/properties/framework/ui/server_properties_tab.dart diff --git a/lib/main/application/use_case/install_server_use_case.dart b/lib/main/application/use_case/install_server_use_case.dart index 512ca4b..a9d2c7b 100644 --- a/lib/main/application/use_case/install_server_use_case.dart +++ b/lib/main/application/use_case/install_server_use_case.dart @@ -1,4 +1,3 @@ - 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'; @@ -45,7 +44,8 @@ class InstallServerUseCase { await _grantFilePermissionUseCase(startScriptFilePath); // 3. Write EULA file - await _writeFileUseCase(path.join(savePath, Constants.eulaFileName), Constants.eulaFileContent); + final eulaFilePath = path.join(savePath, Constants.eulaFileName); + await _writeFileUseCase(eulaFilePath, Constants.eulaFileContent); // 4. Write server.properties file await _writeServerPropertiesUseCase(serverProperties, savePath); diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index e662388..b28d4bd 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -17,6 +17,7 @@ import 'package:minecraft_server_installer/properties/adapter/gateway/server_pro import 'package:minecraft_server_installer/properties/adapter/presenter/server_properties_bloc.dart'; import 'package:minecraft_server_installer/properties/application/use_case/write_server_properties_use_case.dart'; import 'package:minecraft_server_installer/properties/framework/storage/server_properties_file_storage_impl.dart'; +import 'package:minecraft_server_installer/properties/framework/ui/server_properties_tab.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presenter/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart'; @@ -123,21 +124,24 @@ class MinecraftServerInstaller extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.all(32), - child: _tabContent(state), + child: state.tabContent, ), ), ], ), ), ); +} - Widget _tabContent(NavigationItem navigationItem) { - switch (navigationItem) { +extension _NavigationItemExtension on NavigationItem { + Widget get tabContent { + switch (this) { case NavigationItem.basicConfiguration: return const BasicConfigurationTab(); case NavigationItem.modConfiguration: - case NavigationItem.serverProperties: return const Placeholder(); + case NavigationItem.serverProperties: + return const ServerPropertiesTab(); case NavigationItem.about: return const AboutTab(); } diff --git a/lib/main/framework/ui/strings.dart b/lib/main/framework/ui/strings.dart index f045224..6f3968e 100644 --- a/lib/main/framework/ui/strings.dart +++ b/lib/main/framework/ui/strings.dart @@ -6,6 +6,15 @@ abstract class Strings { static const fieldCustomRamSize = '啟用自定義 RAM 大小'; static const fieldMinRamSize = '最小 RAM 大小'; static const fieldMaxRamSize = '最大 RAM 大小'; + static const fieldServerPort = '伺服器連接埠'; + static const fieldMaxPlayers = '玩家人數上限'; + static const fieldSpawnProtection = '重生點保護'; + static const fieldViewDistance = '最大視野距離'; + static const fieldPvp = '玩家間傷害'; + static const fieldGameMode = '預設遊戲模式'; + static const fieldDifficulty = '遊戲難度'; + static const fieldEnableCommandBlock = '啟用指令方塊'; + static const fieldMotd = '伺服器描述'; static const buttonStartToInstall = '開始安裝'; static const buttonBrowse = '瀏覽'; static const buttonTutorialVideo = '教學影片'; @@ -18,7 +27,20 @@ abstract class Strings { static const tabAbout = '關於與說明'; static const tabInstallationProgress = '安裝進度'; static const tooltipEulaInfo = '點擊查看 EULA 條款'; + static const tooltipResetToDefault = '重置為預設值'; + static const tooltipRestoreChanges = '取消變更'; + static const tooltipApplyChanges = '套用變更'; static const dialogTitleSelectDirectory = '選擇安裝目錄'; + static const gamemodeSurvival = '生存'; + static const gamemodeCreative = '創造'; + static const gamemodeAdventure = '冒險'; + static const gamemodeSpectator = '觀察者'; + static const difficultyPeaceful = '和平'; + static const difficultyEasy = '簡單'; + static const difficultyNormal = '普通'; + static const difficultyHard = '困難'; + static const textEnable = '啟用'; + static const textDisable = '禁用'; static const textSlogen = '讓 Minecraft 伺服器安裝變得更簡單!'; static const textCopyright = 'Copyright © 2025 SquidSpirit'; } diff --git a/lib/properties/adapter/presenter/server_properties_bloc.dart b/lib/properties/adapter/presenter/server_properties_bloc.dart index 1a3c799..f0ee646 100644 --- a/lib/properties/adapter/presenter/server_properties_bloc.dart +++ b/lib/properties/adapter/presenter/server_properties_bloc.dart @@ -4,7 +4,7 @@ import 'package:minecraft_server_installer/properties/domain/enum/difficulty.dar import 'package:minecraft_server_installer/properties/domain/enum/game_mode.dart'; class ServerPropertiesBloc extends Bloc { - ServerPropertiesBloc() : super(const ServerPropertiesViewModel.defaultValue()) { + ServerPropertiesBloc() : super(ServerPropertiesViewModel.defaultValue) { on((event, emit) => emit( state.copyWith( serverPort: event.serverPort ?? state.serverPort, diff --git a/lib/properties/adapter/presenter/server_properties_view_model.dart b/lib/properties/adapter/presenter/server_properties_view_model.dart index bf129dc..bc0fd0a 100644 --- a/lib/properties/adapter/presenter/server_properties_view_model.dart +++ b/lib/properties/adapter/presenter/server_properties_view_model.dart @@ -28,19 +28,18 @@ class ServerPropertiesViewModel with EquatableMixin { required this.motd, }); - const ServerPropertiesViewModel.defaultValue() - : this( - serverPort: 25565, - maxPlayers: 20, - spawnProtection: 16, - viewDistance: 10, - pvp: true, - gameMode: GameMode.survival, - difficulty: Difficulty.normal, - enableCommandBlock: false, - onlineMode: true, - motd: 'A Minecraft Server', - ); + static const defaultValue = ServerPropertiesViewModel( + serverPort: 25565, + maxPlayers: 20, + spawnProtection: 16, + viewDistance: 10, + pvp: true, + gameMode: GameMode.survival, + difficulty: Difficulty.normal, + enableCommandBlock: false, + onlineMode: true, + motd: 'A Minecraft Server', + ); ServerProperties toEntity() => ServerProperties( serverPort: serverPort, @@ -93,4 +92,9 @@ class ServerPropertiesViewModel with EquatableMixin { onlineMode, motd, ]; + + bool get isServerPortValid => serverPort > 0 && serverPort <= 65535; + bool get isMaxPlayersValid => maxPlayers > 0; + bool get isSpawnProtectionValid => spawnProtection >= 0; + bool get isViewDistanceValid => viewDistance > 0; } diff --git a/lib/properties/framework/ui/inline_text_field.dart b/lib/properties/framework/ui/inline_text_field.dart new file mode 100644 index 0000000..110cfa2 --- /dev/null +++ b/lib/properties/framework/ui/inline_text_field.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; + +class InlineTextField extends StatefulWidget { + const InlineTextField({ + super.key, + required this.labelText, + required this.defaultValue, + required this.value, + required this.onChanged, + }); + + final String labelText; + final String defaultValue; + final String value; + final void Function(String) onChanged; + + @override + State createState() => _InlineTextFieldState(); +} + +class _InlineTextFieldState extends State { + final _textEditingController = TextEditingController(); + final _focusNode = FocusNode(); + + bool _isEditing = false; + + @override + void initState() { + super.initState(); + + _textEditingController.text = widget.value; + } + + @override + Widget build(BuildContext context) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + focusNode: _focusNode, + decoration: InputDecoration( + labelText: widget.labelText, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onChanged: (newValue) { + if (newValue != widget.value) { + setState(() => _isEditing = true); + } + }, + ), + ), + if (_isEditing) ...[ + const Gap(8), + IconButton.filledTonal( + tooltip: Strings.tooltipRestoreChanges, + onPressed: () { + _focusNode.unfocus(); + _textEditingController.text = widget.value; + setState(() => _isEditing = false); + }, + icon: const Icon(Icons.cancel_outlined)), + const Gap(8), + IconButton.filled( + tooltip: Strings.tooltipApplyChanges, + onPressed: () { + _focusNode.unfocus(); + widget.onChanged(_textEditingController.text); + setState(() => _isEditing = false); + }, + icon: const Icon(Icons.check_circle_outline), + ), + ], + if (!_isEditing && widget.value != widget.defaultValue) ...[ + const Gap(8), + IconButton.filledTonal( + tooltip: Strings.tooltipResetToDefault, + onPressed: () { + _focusNode.unfocus(); + _textEditingController.text = widget.defaultValue; + widget.onChanged(widget.defaultValue); + }, + icon: const Icon(Icons.refresh), + ) + ], + ], + ); +} diff --git a/lib/properties/framework/ui/server_properties_tab.dart b/lib/properties/framework/ui/server_properties_tab.dart new file mode 100644 index 0000000..ce6a1d5 --- /dev/null +++ b/lib/properties/framework/ui/server_properties_tab.dart @@ -0,0 +1,152 @@ +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/strings.dart'; +import 'package:minecraft_server_installer/properties/adapter/presenter/server_properties_bloc.dart'; +import 'package:minecraft_server_installer/properties/adapter/presenter/server_properties_view_model.dart'; +import 'package:minecraft_server_installer/properties/domain/enum/difficulty.dart'; +import 'package:minecraft_server_installer/properties/domain/enum/game_mode.dart'; +import 'package:minecraft_server_installer/properties/framework/ui/inline_text_field.dart'; + +class ServerPropertiesTab extends StatelessWidget { + const ServerPropertiesTab({super.key}); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: BlocConsumer( + listener: (_, __) {}, + builder: (context, state) { + final serverPropertiesBloc = context.read(); + + return Column( + children: [ + InlineTextField( + labelText: 'server-port | ${Strings.fieldServerPort}', + defaultValue: ServerPropertiesViewModel.defaultValue.serverPort.toString(), + value: state.serverPort.toString(), + onChanged: (value) => + serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(serverPort: int.tryParse(value))), + ), + const Gap(16), + InlineTextField( + labelText: 'max-player | ${Strings.fieldMaxPlayers}', + defaultValue: ServerPropertiesViewModel.defaultValue.maxPlayers.toString(), + value: state.maxPlayers.toString(), + onChanged: (value) => + serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(maxPlayers: int.tryParse(value))), + ), + const Gap(16), + InlineTextField( + labelText: 'spawn-protection | ${Strings.fieldSpawnProtection}', + defaultValue: ServerPropertiesViewModel.defaultValue.spawnProtection.toString(), + value: state.spawnProtection.toString(), + onChanged: (value) => + serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(spawnProtection: int.tryParse(value))), + ), + const Gap(16), + InlineTextField( + labelText: 'view-distance | ${Strings.fieldViewDistance}', + defaultValue: ServerPropertiesViewModel.defaultValue.viewDistance.toString(), + value: state.viewDistance.toString(), + onChanged: (value) => + serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(viewDistance: int.tryParse(value))), + ), + const Gap(16), + _dropdown( + labelText: 'pvp | ${Strings.fieldPvp}', + defaultValue: ServerPropertiesViewModel.defaultValue.pvp, + value: state.pvp, + onChanged: (value) => serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(pvp: value)), + items: {true: Strings.textEnable, false: Strings.textDisable}, + ), + const Gap(16), + _dropdown( + labelText: 'gamemode | ${Strings.fieldGameMode}', + defaultValue: ServerPropertiesViewModel.defaultValue.gameMode, + value: state.gameMode, + onChanged: (value) => serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(gameMode: value)), + items: { + GameMode.survival: Strings.gamemodeSurvival, + GameMode.creative: Strings.gamemodeCreative, + GameMode.adventure: Strings.gamemodeAdventure, + GameMode.spectator: Strings.gamemodeSpectator, + }, + ), + const Gap(16), + _dropdown( + labelText: 'difficulty | ${Strings.fieldDifficulty}', + defaultValue: ServerPropertiesViewModel.defaultValue.difficulty, + value: state.difficulty, + onChanged: (value) => serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(difficulty: value)), + items: { + Difficulty.peaceful: Strings.difficultyPeaceful, + Difficulty.easy: Strings.difficultyEasy, + Difficulty.normal: Strings.difficultyNormal, + Difficulty.hard: Strings.difficultyHard, + }, + ), + const Gap(16), + _dropdown( + labelText: 'enable-command-block | ${Strings.fieldEnableCommandBlock}', + defaultValue: ServerPropertiesViewModel.defaultValue.enableCommandBlock, + value: state.enableCommandBlock, + onChanged: (value) => + serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(enableCommandBlock: value)), + items: {true: Strings.textEnable, false: Strings.textDisable}, + ), + const Gap(16), + InlineTextField( + labelText: 'motd | ${Strings.fieldMotd}', + defaultValue: ServerPropertiesViewModel.defaultValue.motd, + value: state.motd, + onChanged: (value) => serverPropertiesBloc.add(ServerPropertiesUpdatedEvent(motd: value)), + ), + ], + ); + }), + ); + + Widget _dropdown({ + required String labelText, + required Map items, + required T defaultValue, + required T value, + required void Function(T) onChanged, + }) => + Builder( + builder: (context) => Row( + children: [ + Expanded( + child: DropdownMenu( + requestFocusOnTap: false, + expandedInsets: EdgeInsets.zero, + inputDecorationTheme: Theme.of(context) + .inputDecorationTheme + .copyWith(border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))), + label: Text(labelText), + initialSelection: value, + onSelected: (value) { + if (value != null) { + onChanged(value); + } + }, + dropdownMenuEntries: items.entries + .map((item) => DropdownMenuEntry( + value: item.key, + label: item.value, + )) + .toList(), + ), + ), + if (value != defaultValue) ...[ + const Gap(8), + IconButton.filledTonal( + tooltip: Strings.tooltipResetToDefault, + onPressed: () => onChanged(defaultValue), + icon: const Icon(Icons.refresh), + ), + ], + ], + ), + ); +}