diff --git a/assets/img/mcsi_logo.png b/assets/img/mcsi_logo.png new file mode 100644 index 0000000..d9fbc3f Binary files /dev/null and b/assets/img/mcsi_logo.png differ diff --git a/lib/main/adapter/presentation/navigation_bloc.dart b/lib/main/adapter/presentation/navigation_bloc.dart new file mode 100644 index 0000000..4aa0cde --- /dev/null +++ b/lib/main/adapter/presentation/navigation_bloc.dart @@ -0,0 +1,22 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NavigationBloc extends Bloc { + NavigationBloc() : super(NavigationItem.basicConfiguration) { + on((event, emit) => emit(event.item)); + } +} + +sealed class NavigationEvent {} + +class NavigationChangedEvent extends NavigationEvent { + final NavigationItem item; + + NavigationChangedEvent(this.item); +} + +enum NavigationItem { + basicConfiguration, + modConfiguration, + serverProperties, + about, +} diff --git a/lib/main/constants.dart b/lib/main/constants.dart index 5c8610b..f7345ff 100644 --- a/lib/main/constants.dart +++ b/lib/main/constants.dart @@ -1,6 +1,7 @@ import 'dart:io'; abstract class Constants { + static const appName = 'Minecraft Server Installer'; static const gameVersionListUrl = 'https://www.dropbox.com/s/mtz3moc9dpjtz7s/GameVersions.txt?dl=1'; static const serverFileName = 'server.jar'; static const eulaFileName = 'eula.txt'; diff --git a/lib/main/framework/ui/minecraft_server_installer.dart b/lib/main/framework/ui/minecraft_server_installer.dart index e8c3d17..944e569 100644 --- a/lib/main/framework/ui/minecraft_server_installer.dart +++ b/lib/main/framework/ui/minecraft_server_installer.dart @@ -2,13 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:minecraft_server_installer/main/adapter/gateway/installation_repository_impl.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/navigation_bloc.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'; +import 'package:minecraft_server_installer/main/constants.dart'; import 'package:minecraft_server_installer/main/framework/api/installation_api_service_impl.dart'; import 'package:minecraft_server_installer/main/framework/storage/installation_file_storage_impl.dart'; import 'package:minecraft_server_installer/main/framework/ui/basic_configuration_tab.dart'; +import 'package:minecraft_server_installer/main/framework/ui/side_navigation_bar.dart'; import 'package:minecraft_server_installer/vanilla/adapter/gateway/vanilla_repository_impl.dart'; import 'package:minecraft_server_installer/vanilla/adapter/presentation/vanilla_bloc.dart'; import 'package:minecraft_server_installer/vanilla/application/use_case/get_game_version_list_use_case.dart'; @@ -17,9 +19,6 @@ import 'package:minecraft_server_installer/vanilla/framework/api/vanilla_api_ser class MinecraftServerInstaller extends StatelessWidget { const MinecraftServerInstaller({super.key}); - Widget get _body => - const Padding(padding: EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: BasicConfigurationTab()); - @override Widget build(BuildContext context) { final installationApiService = InstallationApiServiceImpl(); @@ -35,10 +34,16 @@ class MinecraftServerInstaller extends StatelessWidget { final getGameVersionListUseCase = GetGameVersionListUseCase(gameVersionRepository); return MaterialApp( - title: 'Minecraft Server Installer', - theme: ThemeData.light().copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue.shade900)), + title: Constants.appName, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.light, + seedColor: Colors.blue.shade900, + ), + ), home: MultiBlocProvider( providers: [ + BlocProvider(create: (_) => NavigationBloc()), BlocProvider( create: (_) => InstallationBloc( downloadFileUseCase, @@ -51,18 +56,52 @@ class MinecraftServerInstaller extends StatelessWidget { ), ], child: Scaffold( - body: BlocConsumer( - listener: (_, __) {}, - builder: (_, state) { - if (state.isLocked) { - return MouseRegion(cursor: SystemMouseCursors.forbidden, child: AbsorbPointer(child: _body)); - } + body: Row( + children: [ + const SideNavigationBar(), + Expanded( + child: Builder( + builder: (context) { + if (context.watch().state.isLocked) { + return MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: AbsorbPointer(child: _body), + ); + } - return _body; - }, + return _body; + }, + ), + ), + ], ), ), ), ); } + + Widget get _body => BlocConsumer( + listener: (_, __) {}, + builder: (_, state) => Padding( + padding: const EdgeInsets.all(32), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: SizedBox( + key: ValueKey('tab${state.toString()}'), + child: _tabContent(state), + ), + ), + ), + ); + + Widget _tabContent(NavigationItem navigationItem) { + switch (navigationItem) { + case NavigationItem.basicConfiguration: + return const BasicConfigurationTab(); + case NavigationItem.modConfiguration: + case NavigationItem.serverProperties: + case NavigationItem.about: + return const Placeholder(); + } + } } diff --git a/lib/main/framework/ui/side_navigation_bar.dart b/lib/main/framework/ui/side_navigation_bar.dart new file mode 100644 index 0000000..b75cefb --- /dev/null +++ b/lib/main/framework/ui/side_navigation_bar.dart @@ -0,0 +1,202 @@ +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/navigation_bloc.dart'; +import 'package:minecraft_server_installer/main/constants.dart'; +import 'package:minecraft_server_installer/main/framework/ui/strings.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class SideNavigationBar extends StatefulWidget { + const SideNavigationBar({super.key}); + + @override + State createState() => _SideNavigationBarState(); +} + +class _SideNavigationBarState extends State { + bool _isExpanded = false; + PackageInfo? _packageInfo; + + double get width => _isExpanded ? 360 : 80; + + @override + void initState() { + super.initState(); + PackageInfo.fromPlatform().then((packageInfo) => + WidgetsBinding.instance.addPostFrameCallback((_) => setState(() => _packageInfo = packageInfo))); + } + + @override + Widget build(BuildContext context) => AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: width, + padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + boxShadow: [ + const BoxShadow(color: Colors.black26, offset: Offset(0, 0), blurRadius: 8), + ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _animatedText( + text: Constants.appName, + leading: SizedBox.square( + dimension: 36, + child: Image.asset('assets/img/mcsi_logo.png', width: 2048, height: 2048), + ), + padding: const EdgeInsets.only(left: 4), + expandedKey: const ValueKey('expandedTitle'), + collapsedKey: const ValueKey('collapsedTitle'), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w900, color: Colors.blueGrey.shade900), + ), + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: InkWell( + key: ValueKey(_isExpanded), + borderRadius: BorderRadius.circular(8), + onTap: _toggleIsExpanded, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(_isExpanded ? Icons.menu_open : Icons.menu, color: Colors.blueGrey.shade600), + ), + ), + ), + ), + ], + ), + const Gap(32), + _navigationButton(NavigationItem.basicConfiguration), + const Gap(8), + _navigationButton(NavigationItem.modConfiguration), + const Gap(8), + _navigationButton(NavigationItem.serverProperties), + const Gap(8), + _navigationButton(NavigationItem.about), + const Spacer(), + _animatedText( + text: 'Version ${_packageInfo?.version ?? ''}', + padding: EdgeInsets.zero, + expandedKey: const ValueKey('expandedVersion'), + collapsedKey: const ValueKey('collapsedVersion'), + alignment: Alignment.bottomCenter, + ), + ], + ), + ); + + void _toggleIsExpanded() => setState(() => _isExpanded = !_isExpanded); + + Widget _animatedText({ + required String text, + required Key expandedKey, + required Key collapsedKey, + EdgeInsetsGeometry padding = const EdgeInsets.only(left: 8), + TextStyle? style, + AlignmentGeometry alignment = Alignment.centerLeft, + Widget? leading, + }) => + Expanded( + child: ClipRect( + child: Container( + alignment: alignment, + padding: padding, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _isExpanded + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) + Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: leading, + ), + ), + Flexible( + child: Text( + text, + key: expandedKey, + style: style, + softWrap: false, + overflow: TextOverflow.visible, + ), + ), + ], + ) + : SizedBox.shrink(key: collapsedKey), + ), + ), + ), + ); + + Widget _navigationButton(NavigationItem navigationItem) { + final selectedNavigationItem = context.watch().state; + final isSelected = selectedNavigationItem == navigationItem; + final color = isSelected ? Colors.blue.shade900 : Colors.blueGrey.shade600; + return Material( + color: isSelected ? Colors.blueGrey.shade100 : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => context.read().add(NavigationChangedEvent(navigationItem)), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Icon(navigationItem.iconData, color: color), + ), + _animatedText( + text: navigationItem.title, + expandedKey: ValueKey('expanded${navigationItem.toString()}'), + collapsedKey: ValueKey('collapsed${navigationItem.toString()}'), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500, color: color), + ), + ], + ), + ), + ); + } +} + +extension _NavigationItemContent on NavigationItem { + String get title { + switch (this) { + case NavigationItem.basicConfiguration: + return Strings.tabBasicConfiguration; + case NavigationItem.modConfiguration: + return Strings.tabModConfiguration; + case NavigationItem.serverProperties: + return Strings.tabServerProperties; + case NavigationItem.about: + return Strings.tabAbout; + } + } + + IconData get iconData { + switch (this) { + case NavigationItem.basicConfiguration: + return Icons.dashboard_rounded; + case NavigationItem.modConfiguration: + return Icons.extension; + case NavigationItem.serverProperties: + return Icons.settings; + case NavigationItem.about: + return Icons.info; + } + } +} diff --git a/lib/main/framework/ui/strings.dart b/lib/main/framework/ui/strings.dart index 85205b0..68da21d 100644 --- a/lib/main/framework/ui/strings.dart +++ b/lib/main/framework/ui/strings.dart @@ -8,6 +8,11 @@ abstract class Strings { static const fieldMaxRamSize = '最大 RAM 大小'; static const buttonStartToInstall = '開始安裝'; static const buttonBrowse = '瀏覽'; + static const tabBasicConfiguration = '基本設定'; + static const tabModConfiguration = '模組設定'; + static const tabServerProperties = '伺服器選項'; + static const tabAbout = '關於與說明'; + static const tabInstallationProgress = '安裝進度'; static const tooltipEulaInfo = '點擊查看 EULA 條款'; static const dialogTitleSelectDirectory = '選擇安裝目錄'; } diff --git a/lib/main/main.dart b/lib/main/main.dart index b3aa558..289987e 100644 --- a/lib/main/main.dart +++ b/lib/main/main.dart @@ -7,8 +7,8 @@ Future main() async { await windowManager.ensureInitialized(); final windowOptions = const WindowOptions( - size: Size(400, 600), - minimumSize: Size(400, 600), + size: Size(800, 600), + minimumSize: Size(800, 600), center: true, backgroundColor: Colors.transparent, skipTaskbar: false, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index b1cd8f2..32ffeca 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -22,7 +22,7 @@ static void my_application_activate(GApplication* application) { gtk_window_set_decorated(window, FALSE); - gtk_window_set_default_size(window, 400, 600); + gtk_window_set_default_size(window, 800, 600); gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new(); diff --git a/pubspec.lock b/pubspec.lock index f159f56..0ea9c0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -232,6 +232,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b9b2101..396905c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: "6.0.0-pre.1" environment: sdk: ">=3.6.0 <4.0.0" @@ -39,6 +39,7 @@ dependencies: flutter_bloc: ^9.1.1 gap: ^3.0.1 http: ^1.4.0 + package_info_plus: ^8.3.0 path: ^1.9.1 url_launcher: ^6.3.1 window_manager: ^0.5.0 @@ -68,6 +69,8 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - assets/img/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images