правки по лаунчеру
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user