MCSI-8 feat: add server properties management UI and inline text field component
This commit is contained in:
parent
5396358687
commit
152e8aa239
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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<ServerPropertiesEvent, ServerPropertiesViewModel> {
|
||||
ServerPropertiesBloc() : super(const ServerPropertiesViewModel.defaultValue()) {
|
||||
ServerPropertiesBloc() : super(ServerPropertiesViewModel.defaultValue) {
|
||||
on<ServerPropertiesUpdatedEvent>((event, emit) => emit(
|
||||
state.copyWith(
|
||||
serverPort: event.serverPort ?? state.serverPort,
|
||||
|
@ -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;
|
||||
}
|
||||
|
89
lib/properties/framework/ui/inline_text_field.dart
Normal file
89
lib/properties/framework/ui/inline_text_field.dart
Normal file
@ -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<InlineTextField> createState() => _InlineTextFieldState();
|
||||
}
|
||||
|
||||
class _InlineTextFieldState extends State<InlineTextField> {
|
||||
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),
|
||||
)
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
152
lib/properties/framework/ui/server_properties_tab.dart
Normal file
152
lib/properties/framework/ui/server_properties_tab.dart
Normal file
@ -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<ServerPropertiesBloc, ServerPropertiesViewModel>(
|
||||
listener: (_, __) {},
|
||||
builder: (context, state) {
|
||||
final serverPropertiesBloc = context.read<ServerPropertiesBloc>();
|
||||
|
||||
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<bool>(
|
||||
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<GameMode>(
|
||||
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<Difficulty>(
|
||||
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<bool>(
|
||||
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<T>({
|
||||
required String labelText,
|
||||
required Map<T, String> items,
|
||||
required T defaultValue,
|
||||
required T value,
|
||||
required void Function(T) onChanged,
|
||||
}) =>
|
||||
Builder(
|
||||
builder: (context) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<T>(
|
||||
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<T>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user