правки по лаунчеру

This commit is contained in:
2026-07-28 20:43:53 +04:00
parent 155922a3f1
commit 0b6f7fc647
51 changed files with 3158 additions and 437 deletions
+169 -56
View File
@@ -2,21 +2,39 @@ import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
enum MwAuthenticationMode { login, registration }
class MwAuthenticationForm extends StatefulWidget {
const MwAuthenticationForm({
super.key,
required this.usernameController,
required this.passwordController,
required this.onSubmit,
this.emailController,
this.passwordConfirmationController,
this.inviteCodeController,
this.mode = MwAuthenticationMode.login,
this.onModeChanged,
this.termsAccepted = false,
this.onTermsChanged,
this.loading = false,
this.error,
this.success,
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final TextEditingController? emailController;
final TextEditingController? passwordConfirmationController;
final TextEditingController? inviteCodeController;
final VoidCallback onSubmit;
final MwAuthenticationMode mode;
final ValueChanged<MwAuthenticationMode>? onModeChanged;
final bool termsAccepted;
final ValueChanged<bool>? onTermsChanged;
final bool loading;
final String? error;
final String? success;
@override
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
@@ -28,6 +46,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final registering = widget.mode == MwAuthenticationMode.registration;
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -35,13 +54,13 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
const MwBrandMark(width: 210, compact: true),
const SizedBox(height: 28),
Text(
'ВХОД',
registering ? 'РЕГИСТРАЦИЯ' : 'ВХОД',
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
),
const SizedBox(height: 10),
const Text(
'С возвращением',
style: TextStyle(
Text(
registering ? 'Создать аккаунт' : 'С возвращением',
style: const TextStyle(
fontFamily: 'Cinzel',
color: Color(0xFFECE4D0),
fontSize: 30,
@@ -49,9 +68,11 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
),
),
const SizedBox(height: 7),
const Text(
'Войди в свой аккаунт, чтобы продолжить путь.',
style: TextStyle(
Text(
registering
? 'Зарегистрируй игровой аккаунт MoonWell.'
: 'Войди в свой аккаунт, чтобы продолжить путь.',
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0x80ECE4D0),
@@ -61,12 +82,30 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
const SizedBox(height: 22),
Row(
children: [
_Tab(label: 'ВХОД', selected: true, accent: accent),
_Tab(label: 'РЕГИСТРАЦИЯ', accent: accent),
_Tab(
label: 'ВХОД',
selected: !registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.login,
),
),
_Tab(
label: 'РЕГИСТРАЦИЯ',
selected: registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.registration,
),
),
],
),
const SizedBox(height: 18),
_FieldLabel(text: 'ЛОГИН ИЛИ EMAIL'),
_FieldLabel(text: registering ? 'ЛОГИН' : 'ЛОГИН ИЛИ EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.usernameController,
@@ -75,6 +114,19 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
autofillHints: const [AutofillHints.username],
decoration: const InputDecoration(hintText: 'Aranthel'),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.emailController,
enabled: !widget.loading,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(hintText: 'player@example.com'),
),
],
const SizedBox(height: 14),
_FieldLabel(text: 'ПАРОЛЬ'),
const SizedBox(height: 7),
@@ -82,10 +134,35 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
controller: widget.passwordController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => widget.onSubmit(),
textInputAction: registering
? TextInputAction.next
: TextInputAction.done,
autofillHints: [
registering ? AutofillHints.newPassword : AutofillHints.password,
],
onSubmitted: registering ? null : (_) => widget.onSubmit(),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'ПОВТОРИТЕ ПАРОЛЬ'),
const SizedBox(height: 7),
TextField(
controller: widget.passwordConfirmationController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newPassword],
),
const SizedBox(height: 14),
const _FieldLabel(text: 'ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'),
const SizedBox(height: 7),
TextField(
controller: widget.inviteCodeController,
enabled: !widget.loading,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onSubmit(),
),
],
if (widget.error != null) ...[
const SizedBox(height: 10),
Text(
@@ -93,30 +170,61 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
),
],
if (widget.success != null) ...[
const SizedBox(height: 10),
Text(
widget.success!,
style: const TextStyle(color: Color(0xFF6FC28A), fontSize: 12),
),
],
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 24,
child: Checkbox(
value: _remember,
onChanged: widget.loading
? null
: (value) => setState(() => _remember = value ?? false),
if (registering)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: Checkbox(
value: widget.termsAccepted,
onChanged: widget.loading
? null
: (value) =>
widget.onTermsChanged?.call(value ?? false),
),
),
),
const SizedBox(width: 8),
const Text(
'Запомнить меня',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
const Spacer(),
Text(
'Забыли пароль?',
style: TextStyle(color: accent, fontSize: 11),
),
],
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Я принимаю правила сервера и пользовательское соглашение',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
),
],
)
else
Row(
children: [
SizedBox(
width: 24,
child: Checkbox(
value: _remember,
onChanged: widget.loading
? null
: (value) => setState(() => _remember = value ?? false),
),
),
const SizedBox(width: 8),
const Text(
'Запомнить меня',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
const Spacer(),
Text(
'Забыли пароль?',
style: TextStyle(color: accent, fontSize: 11),
),
],
),
const SizedBox(height: 18),
SizedBox(
height: 54,
@@ -134,18 +242,18 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Row(
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'ВОЙТИ',
style: TextStyle(
registering ? 'СОЗДАТЬ АККАУНТ' : 'ВОЙТИ',
style: const TextStyle(
fontFamily: 'Cinzel',
letterSpacing: 2.5,
),
),
SizedBox(width: 14),
Icon(Icons.arrow_forward, size: 18),
const SizedBox(width: 14),
const Icon(Icons.arrow_forward, size: 18),
],
),
),
@@ -153,7 +261,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
const SizedBox(height: 28),
const Center(
child: Text(
'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0),
@@ -187,30 +295,35 @@ class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.accent,
this.onTap,
this.selected = false,
});
final String label;
final Color accent;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? accent : const Color(0x247FB8D4),
child: InkWell(
onTap: onTap,
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? accent : const Color(0x247FB8D4),
),
),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
),
),
),
+180 -178
View File
@@ -99,7 +99,7 @@ class MwGameTile extends StatelessWidget {
],
),
),
if (!enabled)
if (!enabled && subtitle == null)
const Text(
'SOON',
style: TextStyle(
@@ -121,13 +121,24 @@ class MwGameTile extends StatelessWidget {
}
class MwGameRail extends StatelessWidget {
const MwGameRail({super.key, this.compact = false});
const MwGameRail({
super.key,
this.compact = false,
this.onSettings,
this.username,
});
final bool compact;
final VoidCallback? onSettings;
final String? username;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final normalizedUsername = username?.trim() ?? '';
final userInitial = normalizedUsername.isEmpty
? '?'
: normalizedUsername.characters.first.toUpperCase();
return Container(
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
decoration: const BoxDecoration(
@@ -154,7 +165,10 @@ class MwGameRail extends StatelessWidget {
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
],
),
child: const Text('A', style: TextStyle(fontFamily: 'Cinzel')),
child: Text(
userInitial,
style: const TextStyle(fontFamily: 'Cinzel'),
),
),
const SizedBox(height: 14),
const Divider(color: _border),
@@ -183,28 +197,13 @@ class MwGameRail extends StatelessWidget {
onPressed: () {},
),
const SizedBox(height: 8),
const MwGameTile(
MwGameTile(
title: 'MoonWell Karts',
subtitle: 'Closed Alpha',
subtitle: 'В разработке',
mark: 'K',
icon: Icons.sports_motorsports,
enabled: false,
),
const SizedBox(height: 8),
const MwGameTile(
title: 'Tides of Elune',
subtitle: 'В разработке',
mark: 'T',
icon: Icons.style,
enabled: false,
),
const SizedBox(height: 8),
const MwGameTile(
title: 'Project Verge',
subtitle: 'Анонс',
mark: 'V',
icon: Icons.auto_awesome,
enabled: false,
compact: compact,
),
const Spacer(),
const Divider(color: _border),
@@ -216,12 +215,15 @@ class MwGameRail extends StatelessWidget {
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
IconButton(
onPressed: onSettings,
tooltip: 'Настройки',
icon: Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
),
),
const SizedBox(width: 12),
],
),
],
@@ -236,11 +238,13 @@ class MwNewsItemData {
required this.body,
this.createdAt,
this.imageUrl,
this.onPressed,
});
final String title;
final String body;
final DateTime? createdAt;
final String? imageUrl;
final VoidCallback? onPressed;
}
class MwNewsItem extends StatelessWidget {
@@ -251,55 +255,64 @@ class MwNewsItem extends StatelessWidget {
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final date = item.createdAt?.toLocal();
return Padding(
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onPressed,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'НОВОСТЬ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 8,
letterSpacing: 1.5,
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'НОВОСТЬ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 8,
letterSpacing: 1.5,
),
),
),
),
const Spacer(),
Text(
date == null
? 'СЕГОДНЯ'
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
),
),
],
),
const Spacer(),
const SizedBox(height: 7),
Text(
date == null
? 'СЕГОДНЯ'
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
const SizedBox(height: 7),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
),
);
}
@@ -337,8 +350,6 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
_NewsTab(label: 'СОБЫТИЯ', accent: accent),
_NewsTab(label: 'ПАТЧИ', accent: accent),
],
),
),
@@ -353,7 +364,7 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
const Text(
'MOONWELL.GG',
'MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
@@ -455,6 +466,9 @@ class MwSyncActionArea extends StatelessWidget {
this.progress,
this.currentPath,
this.error,
this.realmName,
this.realmOnline,
this.realmGameBuild,
});
final MwSyncPresentation state;
final String status;
@@ -463,6 +477,9 @@ class MwSyncActionArea extends StatelessWidget {
final double? progress;
final String? currentPath;
final String? error;
final String? realmName;
final bool? realmOnline;
final int? realmGameBuild;
@override
Widget build(BuildContext context) {
@@ -477,6 +494,14 @@ class MwSyncActionArea extends StatelessWidget {
final value = ready || state == MwSyncPresentation.launched
? 1.0
: (progress ?? 0);
final realmStatus = switch (realmOnline) {
true => 'ONLINE',
false => 'OFFLINE',
null => 'STATUS UNKNOWN',
};
final realmMetadata = realmGameBuild != null && realmGameBuild! > 0
? '$realmStatus · BUILD $realmGameBuild'
: realmStatus;
final label = switch (state) {
MwSyncPresentation.install => 'УСТАНОВИТЬ',
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
@@ -503,26 +528,28 @@ class MwSyncActionArea extends StatelessWidget {
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(6),
),
child: const Row(
child: Row(
children: [
_PulseDot(),
SizedBox(width: 12),
_PulseDot(online: realmOnline),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"ELUNE'S GRACE",
style: TextStyle(
(realmName?.trim().isNotEmpty ?? false)
? realmName!.toUpperCase()
: 'REALM UNAVAILABLE',
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
SizedBox(height: 3),
const SizedBox(height: 3),
Text(
'ONLINE · EU',
style: TextStyle(
realmMetadata,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
@@ -592,14 +619,20 @@ class MwSyncActionArea extends StatelessWidget {
),
),
),
Text(
ready ? 'Последняя проверка: только что' : '',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
if (ready)
const Flexible(
child: Text(
'Последняя проверка: только что',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
),
),
],
),
],
@@ -647,17 +680,27 @@ class MwSyncActionArea extends StatelessWidget {
}
class _PulseDot extends StatelessWidget {
const _PulseDot();
const _PulseDot({required this.online});
final bool? online;
@override
Widget build(BuildContext context) => Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF6FC28A),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Color(0xAA6FC28A), blurRadius: 8)],
),
);
Widget build(BuildContext context) {
final color = switch (online) {
true => const Color(0xFF6FC28A),
false => const Color(0xFFD76A78),
null => _muted,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: color.withAlpha(170), blurRadius: 8)],
),
);
}
}
class MwLauncherStatusBar extends StatelessWidget {
@@ -709,87 +752,67 @@ class MwLauncherStatusBar extends StatelessWidget {
}
class MwAccountAffordance extends StatelessWidget {
const MwAccountAffordance({
super.key,
required this.onLogout,
this.onSettings,
});
const MwAccountAffordance({super.key, required this.onLogout, this.username});
final VoidCallback onLogout;
final VoidCallback? onSettings;
final String? username;
@override
Widget build(BuildContext context) => PopupMenuButton<String>(
tooltip: 'Аккаунт',
onSelected: (value) =>
value == 'settings' ? onSettings?.call() : onLogout(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'settings', child: Text('Настройки')),
PopupMenuItem(value: 'logout', child: Text('Выйти')),
],
child: Container(
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(99),
),
child: const Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: Color(0xFF5B3A8A),
child: Text(
'Æ',
style: TextStyle(fontFamily: 'Cinzel', fontSize: 12),
Widget build(BuildContext context) {
final displayUsername = (username?.trim().isNotEmpty ?? false)
? username!.trim()
: 'Аккаунт';
final initial = displayUsername.characters.first.toUpperCase();
return PopupMenuButton<String>(
tooltip: 'Аккаунт',
onSelected: (_) => onLogout(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'logout', child: Text('Выйти')),
],
child: Container(
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(99),
),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: const Color(0xFF5B3A8A),
child: Text(
initial,
style: const TextStyle(fontFamily: 'Cinzel', fontSize: 12),
),
),
),
SizedBox(width: 11),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aranthel',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
const SizedBox(width: 11),
Text(
displayUsername,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
SizedBox(height: 2),
Text(
'ХРАНИТЕЛЬ РОЩИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 7,
letterSpacing: 1.1,
),
),
],
),
],
),
],
),
),
),
);
);
}
}
class MwSettingsPanel extends StatelessWidget {
const MwSettingsPanel({
super.key,
required this.installationPath,
required this.themeVariant,
required this.onChooseDirectory,
required this.onThemeChanged,
required this.onVerify,
required this.onLogout,
this.syncActive = false,
});
final String? installationPath;
final MoonWellThemeVariant themeVariant;
final VoidCallback onChooseDirectory;
final ValueChanged<MoonWellThemeVariant> onThemeChanged;
final VoidCallback onVerify;
final VoidCallback onLogout;
final bool syncActive;
@override
Widget build(BuildContext context) => MwPanel(
@@ -812,32 +835,11 @@ class MwSettingsPanel extends StatelessWidget {
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 20),
SegmentedButton<MoonWellThemeVariant>(
segments: const [
ButtonSegment(
value: MoonWellThemeVariant.forest,
label: Text('Лес'),
),
ButtonSegment(
value: MoonWellThemeVariant.temple,
label: Text('Храм'),
),
],
selected: {themeVariant},
onSelectionChanged: (value) => onThemeChanged(value.single),
),
const SizedBox(height: 20),
MwButton(
label: 'Проверить файлы',
onPressed: syncActive ? null : onVerify,
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 8),
MwButton(
label: 'Выйти',
onPressed: onLogout,
variant: MwButtonVariant.destructive,
),
],
),
);
+165 -73
View File
@@ -20,40 +20,44 @@ class MwBrandMark extends StatelessWidget {
label: 'MoonWell',
child: SizedBox(
width: width,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MoonMark(size: compact ? 28 : 40, color: accent),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MOONWELL',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: compact ? 15 : 22,
height: 1,
letterSpacing: compact ? 4 : 6,
),
),
if (!compact) ...[
const SizedBox(height: 5),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MoonMark(size: compact ? 28 : 40, color: accent),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LUNAR · LAUNCHER',
'MOONWELL',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
fontFamily: 'Cinzel',
color: _ivory,
fontSize: compact ? 15 : 22,
height: 1,
letterSpacing: compact ? 4 : 6,
),
),
if (!compact) ...[
const SizedBox(height: 5),
Text(
'LUNAR · LAUNCHER',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
),
),
],
],
],
),
],
),
],
),
),
),
);
@@ -165,50 +169,89 @@ class MwLoginShell extends StatelessWidget {
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
child: Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'✦',
style: TextStyle(
color: Color(0xFF7FB8D4),
fontSize: 20,
),
),
SizedBox(height: 12),
Text(
'«Звёздный свет проведёт тебя сквозь самые '
'тёмные ночи — если ты готов идти туда, '
'куда он указывает.»',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 22,
height: 1.5,
),
),
SizedBox(height: 18),
Text(
'— МАЛФУРИОН ЯРОСТЬ БУРИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 10,
letterSpacing: 2,
),
),
],
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/login_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .52, 1],
colors: [
Color(0x00070A18),
Color(0x22070A18),
Color(0xF2070A18),
],
),
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x33070A18),
Color(0x00070A18),
Color(0x8F070A18),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
child: Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'✦',
style: TextStyle(
color: Color(0xFF7FB8D4),
fontSize: 20,
),
),
SizedBox(height: 12),
Text(
'«Звёздный свет проведёт тебя сквозь самые '
'тёмные ночи — если ты готов идти туда, '
'куда он указывает.»',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 22,
height: 1.5,
),
),
SizedBox(height: 18),
Text(
'— МАЛФУРИОН ЯРОСТЬ БУРИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 10,
letterSpacing: 2,
),
),
],
),
),
),
),
],
),
),
Container(
@@ -292,6 +335,8 @@ class MwLauncherShell extends StatelessWidget {
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.onSettings,
this.username,
this.assetPackage,
});
@@ -304,6 +349,8 @@ class MwLauncherShell extends StatelessWidget {
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final VoidCallback? onSettings;
final String? username;
final String? assetPackage;
@override
@@ -313,12 +360,57 @@ class MwLauncherShell extends StatelessWidget {
assetPackage: assetPackage,
child: Stack(
children: [
const Positioned(
Positioned(
left: 240,
right: 0,
top: 0,
bottom: 26,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/launcher_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
stops: [0, .3, .7, 1],
colors: [
Color(0xF2070A18),
Color(0xB8070A18),
Color(0x22070A18),
Color(0x73070A18),
],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .58, 1],
colors: [
Color(0x52070A18),
Color(0x00070A18),
Color(0xD9070A18),
],
),
),
),
],
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 240,
child: MwGameRail(),
child: MwGameRail(onSettings: onSettings, username: username),
),
Positioned(
left: 272,
+172 -2
View File
@@ -26,11 +26,14 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
required PreferencesRepository preferencesRepository,
required LauncherSession session,
required ClientManifest manifest,
Duration manifestRefreshInterval = const Duration(minutes: 5),
Duration realmRefreshInterval = const Duration(minutes: 1),
}) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService,
_launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository,
_session = session,
_startupManifest = manifest,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
@@ -41,14 +44,28 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenManifestRefreshRequested>(
_onHomeScreenManifestRefreshRequested,
);
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenGameExited>(_onHomeScreenGameExited);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
add(HomeScreenLoad());
_manifestRefreshTimer = Timer.periodic(
manifestRefreshInterval,
(_) => add(HomeScreenManifestRefreshRequested()),
);
_realmRefreshTimer = Timer.periodic(
realmRefreshInterval,
(_) => add(HomeScreenRealmRefreshRequested()),
);
}
final ClientSyncUseCase _clientSyncUseCase;
@@ -58,6 +75,10 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
StreamSubscription<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
Future<void> _onHomeScreenLoad(
@@ -82,6 +103,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
),
);
if (outputPath != null) {
add(HomeScreenSyncRequested(manifest: _startupManifest));
}
add(HomeScreenRealmRefreshRequested());
add(HomeScreenAccountLoadRequested());
try {
final newsItems = await _launcherApiClient.fetchNews(
_session.accessToken,
@@ -107,11 +134,101 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenAccountLoadRequested(
HomeScreenAccountLoadRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final account = await _launcherApiClient.fetchAccount(
_session.accessToken,
);
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
accountUsername: account.username.isEmpty ? null : account.username,
),
),
);
} catch (_) {
// Account metadata does not block patching or launching the game.
}
}
Future<void> _onHomeScreenRealmRefreshRequested(
HomeScreenRealmRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final realms = await _launcherApiClient.fetchRealms();
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
realm: realms.isEmpty ? null : realms.first,
),
),
);
} catch (_) {
// Keep the last known realm state and retry on the next timer tick.
}
}
Future<void> _onHomeScreenManifestRefreshRequested(
HomeScreenManifestRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated ||
state.model.isSyncing ||
state.model.isGameRunning ||
state.model.phase == HomeScreenPhase.paused ||
state.model.outputPath == null) {
return;
}
try {
final manifest = await _launcherApiClient.fetchManifest(
_session.accessToken,
);
if (state.model.isSyncing || !state.model.isAuthenticated) {
return;
}
if (manifest.buildHash == state.model.localBuildHash) {
if (manifest.buildHash != state.model.remoteBuildHash) {
emit(
HomeScreenState(
model: state.model.copyWith(remoteBuildHash: manifest.buildHash),
),
);
}
return;
}
add(HomeScreenSyncRequested(manifest: manifest));
} catch (_) {
// Retry on the next timer tick without replacing the stable UI state.
}
}
Future<void> _onHomeScreenSyncRequested(
HomeScreenSyncRequested event,
Emitter<HomeScreenState> emit,
) async {
if (state.model.isSyncing) {
if (state.model.isSyncing || state.model.isGameRunning) {
return;
}
@@ -145,6 +262,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -228,12 +346,30 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
try {
final installationDir = outputPath.toFilePath();
await _gameInstallationService.clearCache(installationDir);
await _gameInstallationService.launchGame(installationDir);
final process = await _gameInstallationService.launchGame(
installationDir,
);
_activeGamePid = process.pid;
unawaited(
process.exitCode.then<void>(
(exitCode) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode));
}
},
onError: (_) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: null));
}
},
),
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
isGameRunning: true,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
@@ -249,11 +385,39 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenGameExited(
HomeScreenGameExited event,
Emitter<HomeScreenState> emit,
) async {
if (_activeGamePid != event.pid) {
return;
}
_activeGamePid = null;
final hasClientExecutable = await _resolveExecutablePresence(
state.model.outputPath,
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: _stablePhase(hasClientExecutable),
hasClientExecutable: hasClientExecutable,
isGameRunning: false,
statusText: hasClientExecutable
? 'Игра закрыта. Клиент готов к запуску.'
: 'Игра закрыта, но Wow.exe больше не найден.',
errorMessage: null,
),
),
);
}
Future<void> _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> emit,
) async {
_pauseRequested = true;
_activeGamePid = null;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
@@ -263,6 +427,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
model: state.model.copyWith(
phase: HomeScreenPhase.idle,
isAuthenticated: false,
isGameRunning: false,
accountUsername: null,
progress: const DownloadProgress.initial(),
statusText: 'Сессия завершена. Войдите снова.',
currentPath: null,
@@ -274,6 +440,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
realm: null,
),
),
);
@@ -424,6 +591,9 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
@override
Future<void> close() async {
_pauseRequested = true;
_activeGamePid = null;
_manifestRefreshTimer?.cancel();
_realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
@@ -5,7 +5,17 @@ sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {
final ClientManifest? manifest;
HomeScreenSyncRequested({this.manifest});
}
final class HomeScreenManifestRefreshRequested extends HomeScreenEvent {}
final class HomeScreenRealmRefreshRequested extends HomeScreenEvent {}
final class HomeScreenAccountLoadRequested extends HomeScreenEvent {}
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
@@ -13,6 +23,13 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenPlayRequested extends HomeScreenEvent {}
final class HomeScreenGameExited extends HomeScreenEvent {
final int pid;
final int? exitCode;
HomeScreenGameExited({required this.pid, required this.exitCode});
}
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
enum HomeScreenPhase {
idle,
@@ -22,6 +23,8 @@ final class HomeScreenModel {
final HomeScreenPhase phase;
final bool isAuthenticated;
final bool hasClientExecutable;
final bool isGameRunning;
final String? accountUsername;
final String statusText;
final String? currentPath;
final String? errorMessage;
@@ -32,6 +35,7 @@ final class HomeScreenModel {
final List<LauncherNewsItem> newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
final LauncherRealm? realm;
final ClientSyncStage? syncStage;
const HomeScreenModel({
@@ -40,6 +44,8 @@ final class HomeScreenModel {
required this.phase,
required this.isAuthenticated,
required this.hasClientExecutable,
required this.isGameRunning,
required this.accountUsername,
required this.statusText,
required this.currentPath,
required this.errorMessage,
@@ -50,6 +56,7 @@ final class HomeScreenModel {
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
required this.realm,
required this.syncStage,
});
@@ -59,6 +66,8 @@ final class HomeScreenModel {
phase = HomeScreenPhase.idle,
isAuthenticated = false,
hasClientExecutable = false,
isGameRunning = false,
accountUsername = null,
statusText = 'Загрузка...',
currentPath = null,
errorMessage = null,
@@ -69,6 +78,7 @@ final class HomeScreenModel {
newsItems = const <LauncherNewsItem>[],
isLoadingNews = true,
newsErrorMessage = null,
realm = null,
syncStage = null;
bool get isBusy =>
@@ -77,7 +87,7 @@ final class HomeScreenModel {
bool get isSyncing => phase == HomeScreenPhase.syncing;
bool get canPlay => hasClientExecutable && !isBusy;
bool get canPlay => hasClientExecutable && !isBusy && !isGameRunning;
HomeScreenModel copyWith({
DownloadProgress? progress,
@@ -85,6 +95,8 @@ final class HomeScreenModel {
HomeScreenPhase? phase,
bool? isAuthenticated,
bool? hasClientExecutable,
bool? isGameRunning,
Object? accountUsername = _sentinel,
String? statusText,
Object? currentPath = _sentinel,
Object? errorMessage = _sentinel,
@@ -95,6 +107,7 @@ final class HomeScreenModel {
List<LauncherNewsItem>? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
Object? realm = _sentinel,
Object? syncStage = _sentinel,
}) {
return HomeScreenModel(
@@ -105,6 +118,10 @@ final class HomeScreenModel {
phase: phase ?? this.phase,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
isGameRunning: isGameRunning ?? this.isGameRunning,
accountUsername: accountUsername == _sentinel
? this.accountUsername
: accountUsername as String?,
statusText: statusText ?? this.statusText,
currentPath: currentPath == _sentinel
? this.currentPath
@@ -125,6 +142,7 @@ final class HomeScreenModel {
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
realm: realm == _sentinel ? this.realm : realm as LauncherRealm?,
syncStage: syncStage == _sentinel
? this.syncStage
: syncStage as ClientSyncStage?,
+53 -29
View File
@@ -6,8 +6,10 @@ import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreenContent extends StatelessWidget {
@@ -17,14 +19,16 @@ class HomeScreenContent extends StatelessWidget {
Widget build(BuildContext context) {
final model = context.watch<HomeScreenBloc>().state.model;
return MwLauncherShell(
news: _buildNews(model),
news: _buildNews(context, model),
syncActions: _buildSyncActions(context, model),
statusBar: _buildStatusBar(model),
account: MwAccountAffordance(
onSettings: () => _showSettings(context, model),
username: model.accountUsername,
onLogout: () =>
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
),
onSettings: () => _showSettings(context, model),
username: model.accountUsername,
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
@@ -33,7 +37,7 @@ class HomeScreenContent extends StatelessWidget {
);
}
Widget _buildNews(HomeScreenModel model) {
Widget _buildNews(BuildContext context, HomeScreenModel model) {
final state = model.isLoadingNews
? MwNewsListState.loading
: model.newsItems.isNotEmpty
@@ -51,11 +55,40 @@ class HomeScreenContent extends StatelessWidget {
body: item.body,
createdAt: item.createdAt,
imageUrl: item.imageUrl,
onPressed: () => unawaited(_openNews(context, item.id)),
),
],
);
}
Future<void> _openNews(BuildContext context, int newsId) async {
final newsUri = buildLauncherNewsUri(Config.launcherApiBaseUrl, newsId);
if (newsUri == null) {
_showLinkError(context);
return;
}
try {
final opened = await launchUrl(
newsUri,
mode: LaunchMode.externalApplication,
);
if (!opened && context.mounted) {
_showLinkError(context);
}
} catch (_) {
if (context.mounted) {
_showLinkError(context);
}
}
}
void _showLinkError(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось открыть новость в браузере.')),
);
}
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
final presentation = _syncPresentation(model);
final progress = model.progress.total > 0
@@ -67,6 +100,9 @@ class HomeScreenContent extends StatelessWidget {
currentPath: model.currentPath,
error: model.errorMessage,
progress: progress,
realmName: model.realm?.name,
realmOnline: model.realm?.online,
realmGameBuild: model.realm?.gameBuild,
onPrimary: () {
final bloc = context.read<HomeScreenBloc>();
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
@@ -89,7 +125,7 @@ class HomeScreenContent extends StatelessWidget {
return MwSyncPresentation.failure;
}
if (model.phase == HomeScreenPhase.readyToPlay) {
return model.statusText.startsWith('Игра запущена')
return model.isGameRunning
? MwSyncPresentation.launched
: MwSyncPresentation.ready;
}
@@ -125,35 +161,23 @@ class HomeScreenContent extends StatelessWidget {
HomeScreenModel model,
) async {
final bloc = context.read<HomeScreenBloc>();
final themeController = LauncherThemeScope.of(context);
await showDialog<void>(
context: context,
builder: (dialogContext) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
child: AnimatedBuilder(
animation: themeController,
builder: (context, _) => SingleChildScrollView(
child: MwSettingsPanel(
installationPath: model.outputPath?.toFilePath(),
themeVariant: themeController.variant,
syncActive: model.isSyncing,
onChooseDirectory: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenOutputDirRequested());
},
onThemeChanged: (variant) {
unawaited(themeController.setVariant(variant));
},
onVerify: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenSyncRequested());
},
onLogout: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenLogoutRequested());
},
),
child: SingleChildScrollView(
child: MwSettingsPanel(
installationPath: model.outputPath?.toFilePath(),
syncActive: model.isSyncing || model.isGameRunning,
onChooseDirectory: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenOutputDirRequested());
},
onVerify: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenSyncRequested());
},
),
),
),
+106 -1
View File
@@ -18,17 +18,30 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordConfirmationController = TextEditingController();
final _inviteCodeController = TextEditingController();
MwAuthenticationMode _mode = MwAuthenticationMode.login;
bool _termsAccepted = false;
bool _isLoading = false;
String? _error;
String? _success;
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_passwordConfirmationController.dispose();
_inviteCodeController.dispose();
super.dispose();
}
Future<void> _submit() {
return _mode == MwAuthenticationMode.login ? _login() : _register();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
@@ -41,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
@@ -71,6 +85,89 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
Future<void> _register() async {
final username = _usernameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
final passwordConfirmation = _passwordConfirmationController.text;
final validationError = _validateRegistration(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
);
if (validationError != null) {
setState(() => _error = validationError);
return;
}
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
final message = await getIt<LauncherApiClient>().register(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
terms: _termsAccepted,
inviteCode: _inviteCodeController.text,
);
if (!mounted) return;
setState(() {
_mode = MwAuthenticationMode.login;
_isLoading = false;
_termsAccepted = false;
_passwordConfirmationController.clear();
_inviteCodeController.clear();
_success = message;
});
} catch (error) {
if (!mounted) return;
setState(() {
_isLoading = false;
_error = _formatError(error);
});
}
}
String? _validateRegistration({
required String username,
required String email,
required String password,
required String passwordConfirmation,
}) {
if (!RegExp(r'^[A-Za-z0-9]{3,32}$').hasMatch(username)) {
return 'Логин должен содержать от 3 до 32 латинских букв или цифр.';
}
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
return 'Введите корректный email.';
}
if (password.length < 8 || password.length > 32) {
return 'Пароль должен содержать от 8 до 32 символов.';
}
if (password != passwordConfirmation) {
return 'Пароли не совпадают.';
}
if (!_termsAccepted) {
return 'Примите правила сервера и пользовательское соглашение.';
}
return null;
}
void _setMode(MwAuthenticationMode mode) {
setState(() {
_mode = mode;
_error = null;
_success = null;
});
}
String _formatError(Object error) {
final raw = error.toString();
if (raw.startsWith('Exception: ')) return raw.substring(11);
@@ -82,10 +179,18 @@ class _LoginScreenState extends State<LoginScreen> {
return MwLoginShell(
form: MwAuthenticationForm(
usernameController: _usernameController,
emailController: _emailController,
passwordController: _passwordController,
passwordConfirmationController: _passwordConfirmationController,
inviteCodeController: _inviteCodeController,
mode: _mode,
onModeChanged: _setMode,
termsAccepted: _termsAccepted,
onTermsChanged: (value) => setState(() => _termsAccepted = value),
loading: _isLoading,
error: _error,
onSubmit: _login,
success: _success,
onSubmit: _submit,
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
@@ -204,6 +204,7 @@ abstract final class MoonWellTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceHigh,
hintStyle: TextStyle(color: foreground.withAlpha(105)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,