WIP: feat(ui): Migrate to new UI implementation

This commit is contained in:
2026-06-22 06:47:16 +04:00
parent 873aa8d7b5
commit bb4334f68d
109 changed files with 13499 additions and 1257 deletions
@@ -0,0 +1,218 @@
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';
class MwAuthenticationForm extends StatefulWidget {
const MwAuthenticationForm({
super.key,
required this.usernameController,
required this.passwordController,
required this.onSubmit,
this.loading = false,
this.error,
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final VoidCallback onSubmit;
final bool loading;
final String? error;
@override
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
}
class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
bool _remember = true;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const MwBrandMark(width: 210, compact: true),
const SizedBox(height: 28),
Text(
'ВХОД',
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
),
const SizedBox(height: 10),
const Text(
'С возвращением',
style: TextStyle(
fontFamily: 'Cinzel',
color: Color(0xFFECE4D0),
fontSize: 30,
letterSpacing: 1,
),
),
const SizedBox(height: 7),
const Text(
'Войди в свой аккаунт, чтобы продолжить путь.',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0x80ECE4D0),
fontSize: 15,
),
),
const SizedBox(height: 22),
Row(
children: [
_Tab(label: 'ВХОД', selected: true, accent: accent),
_Tab(label: 'РЕГИСТРАЦИЯ', accent: accent),
],
),
const SizedBox(height: 18),
_FieldLabel(text: 'ЛОГИН ИЛИ EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.usernameController,
enabled: !widget.loading,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
decoration: const InputDecoration(hintText: 'Aranthel'),
),
const SizedBox(height: 14),
_FieldLabel(text: 'ПАРОЛЬ'),
const SizedBox(height: 7),
TextField(
controller: widget.passwordController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => widget.onSubmit(),
),
if (widget.error != null) ...[
const SizedBox(height: 10),
Text(
widget.error!,
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
),
],
const SizedBox(height: 8),
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,
child: ElevatedButton(
onPressed: widget.loading ? null : widget.onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6D499E),
foregroundColor: const Color(0xFFECE4D0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: widget.loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'ВОЙТИ',
style: TextStyle(
fontFamily: 'Cinzel',
letterSpacing: 2.5,
),
),
SizedBox(width: 14),
Icon(Icons.arrow_forward, size: 18),
],
),
),
),
const SizedBox(height: 28),
const Center(
child: Text(
'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0),
fontSize: 8,
letterSpacing: 1.2,
),
),
),
],
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.text});
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
);
}
class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.accent,
this.selected = false,
});
final String label;
final Color accent;
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: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
),
),
);
}
@@ -0,0 +1,844 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
const _ivory = Color(0xFFECE4D0);
const _muted = Color(0x80ECE4D0);
const _border = Color(0x247FB8D4);
class MwGameTile extends StatelessWidget {
const MwGameTile({
super.key,
required this.title,
required this.icon,
this.subtitle,
this.selected = false,
this.enabled = true,
this.compact = false,
this.onPressed,
this.mark,
});
final String title;
final String? subtitle;
final IconData icon;
final bool selected;
final bool enabled;
final bool compact;
final VoidCallback? onPressed;
final String? mark;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Opacity(
opacity: enabled ? 1 : .58,
child: Material(
color: selected ? accent.withAlpha(25) : accent.withAlpha(8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: selected ? accent : _border),
),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(6),
child: SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [const Color(0xFF5B3A8A), accent],
),
borderRadius: BorderRadius.circular(6),
),
child: Text(
mark ?? title.characters.first,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
),
),
),
if (!compact) ...[
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 10,
letterSpacing: .8,
),
),
const SizedBox(height: 3),
Text(
(subtitle ?? (enabled ? 'OPEN BETA' : 'СКОРО'))
.toUpperCase(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: selected ? accent : _muted,
fontSize: 8,
letterSpacing: 1.1,
),
),
],
),
),
if (!enabled)
const Text(
'SOON',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 7,
letterSpacing: 1,
),
),
],
],
),
),
),
),
),
);
}
}
class MwGameRail extends StatelessWidget {
const MwGameRail({super.key, this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Container(
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xB5070A18), Color(0x8F070A18)],
),
border: Border(right: BorderSide(color: _border)),
),
child: Column(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [accent, const Color(0xFF5B3A8A)],
),
border: Border.all(color: accent),
boxShadow: [
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
],
),
child: const Text('A', style: TextStyle(fontFamily: 'Cinzel')),
),
const SizedBox(height: 14),
const Divider(color: _border),
const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 7, 0, 8),
child: Text(
'ИГРЫ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
letterSpacing: 2.2,
),
),
),
),
MwGameTile(
title: 'MoonWell · WoW',
subtitle: 'Open Beta',
mark: 'MW',
icon: Icons.nights_stay,
selected: true,
compact: compact,
onPressed: () {},
),
const SizedBox(height: 8),
const MwGameTile(
title: 'MoonWell Karts',
subtitle: 'Closed Alpha',
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,
),
const Spacer(),
const Divider(color: _border),
Row(
children: [
IconButton(
onPressed: null,
tooltip: 'Друзья',
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
),
const SizedBox(width: 12),
],
),
],
),
);
}
}
class MwNewsItemData {
const MwNewsItemData({
required this.title,
required this.body,
this.createdAt,
this.imageUrl,
});
final String title;
final String body;
final DateTime? createdAt;
final String? imageUrl;
}
class MwNewsItem extends StatelessWidget {
const MwNewsItem({super.key, required this.item});
final MwNewsItemData item;
@override
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(
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 SizedBox(height: 7),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
);
}
}
enum MwNewsListState { loading, empty, failure, populated }
class MwNewsList extends StatelessWidget {
const MwNewsList({
super.key,
required this.state,
this.items = const [],
this.error,
this.onRetry,
});
final MwNewsListState state;
final List<MwNewsItemData> items;
final String? error;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Container(
decoration: BoxDecoration(
color: const Color(0xD00A0D1F),
border: Border.all(color: const Color(0x477FB8D4)),
borderRadius: BorderRadius.circular(10),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
SizedBox(
height: 45,
child: Row(
children: [
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
_NewsTab(label: 'СОБЫТИЯ', accent: accent),
_NewsTab(label: 'ПАТЧИ', accent: accent),
],
),
),
const Divider(height: 1, color: _border),
Expanded(child: _content()),
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: _border)),
),
child: Row(
children: [
const Text(
'MOONWELL.GG',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
letterSpacing: 1.5,
),
),
const Spacer(),
Text(
'ВСЕ →',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 1.5,
),
),
],
),
),
],
),
);
}
Widget _content() => switch (state) {
MwNewsListState.loading => const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
MwNewsListState.empty => const Center(
child: Text('Новостей пока нет', style: TextStyle(color: _muted)),
),
MwNewsListState.failure => Center(
child: Text(
error ?? 'Не удалось загрузить новости',
style: const TextStyle(color: Color(0xFFD76A78)),
),
),
MwNewsListState.populated => ListView.separated(
padding: const EdgeInsets.only(left: 16, right: 4),
itemCount: items.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: _border),
itemBuilder: (_, index) => MwNewsItem(item: items[index]),
),
};
}
class _NewsTab extends StatelessWidget {
const _NewsTab({
required this.label,
required this.accent,
this.selected = false,
});
final String label;
final Color accent;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? accent.withAlpha(12) : Colors.transparent,
border: Border(
bottom: BorderSide(color: selected ? accent : Colors.transparent),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? _ivory : _muted,
fontSize: 9,
letterSpacing: 1.5,
),
),
),
);
}
enum MwSyncPresentation {
install,
scanning,
downloading,
pausing,
paused,
verifying,
ready,
launched,
failure,
}
class MwSyncActionArea extends StatelessWidget {
const MwSyncActionArea({
super.key,
required this.state,
required this.status,
required this.onPrimary,
this.onPause,
this.progress,
this.currentPath,
this.error,
});
final MwSyncPresentation state;
final String status;
final VoidCallback onPrimary;
final VoidCallback? onPause;
final double? progress;
final String? currentPath;
final String? error;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final ready = state == MwSyncPresentation.ready;
final active = {
MwSyncPresentation.scanning,
MwSyncPresentation.downloading,
MwSyncPresentation.pausing,
MwSyncPresentation.verifying,
}.contains(state);
final value = ready || state == MwSyncPresentation.launched
? 1.0
: (progress ?? 0);
final label = switch (state) {
MwSyncPresentation.install => 'УСТАНОВИТЬ',
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
MwSyncPresentation.ready => 'ИГРАТЬ',
MwSyncPresentation.launched => 'ИГРА ЗАПУЩЕНА',
MwSyncPresentation.failure => 'ПОВТОРИТЬ',
_ => 'ПАУЗА',
};
return Container(
padding: const EdgeInsets.fromLTRB(32, 18, 32, 20),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x0006091A), Color(0xF206091A)],
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(6),
),
child: const Row(
children: [
_PulseDot(),
SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"ELUNE'S GRACE",
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
SizedBox(height: 3),
Text(
'ONLINE · EU',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: 1.1,
),
),
],
),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
error ?? status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: error == null
? accent
: const Color(0xFFD76A78),
fontSize: 10,
letterSpacing: 1.1,
),
),
),
Text(
'${(value * 100).round()}%',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _ivory,
fontSize: 13,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: value,
minHeight: 4,
color: accent,
backgroundColor: accent.withAlpha(28),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
currentPath ??
(ready ? 'Все файлы целы' : 'Подготовка клиента'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
),
Text(
ready ? 'Последняя проверка: только что' : '',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
],
),
],
),
),
const SizedBox(width: 24),
SizedBox(
width: 220,
height: 64,
child: ElevatedButton.icon(
onPressed: active
? onPause
: (state == MwSyncPresentation.launched ? null : onPrimary),
icon: Icon(
ready
? Icons.play_arrow
: active
? Icons.pause
: Icons.refresh,
size: 20,
),
label: Text(label),
style: ElevatedButton.styleFrom(
backgroundColor: ready
? const Color(0xFF60B17F)
: state == MwSyncPresentation.failure
? const Color(0xFFD76A78)
: const Color(0xFF6D499E),
foregroundColor: ready ? const Color(0xFF07130F) : _ivory,
textStyle: const TextStyle(
fontFamily: 'Cinzel',
fontSize: 14,
letterSpacing: 2.4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
),
],
),
);
}
}
class _PulseDot extends StatelessWidget {
const _PulseDot();
@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)],
),
);
}
class MwLauncherStatusBar extends StatelessWidget {
const MwLauncherStatusBar({
super.key,
required this.status,
this.fileCount,
this.speed,
this.eta,
this.buildHash,
});
final String status;
final String? fileCount;
final String? speed;
final String? eta;
final String? buildHash;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: _border)),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: .8,
),
child: Row(
children: [
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (fileCount != null) Text(fileCount!),
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
if (buildHash != null) ...[
const SizedBox(width: 24),
Text(buildHash!),
],
const SizedBox(width: 24),
const Text('Лаунчер v1.0.0'),
],
),
),
);
}
class MwAccountAffordance extends StatelessWidget {
const MwAccountAffordance({
super.key,
required this.onLogout,
this.onSettings,
});
final VoidCallback onLogout;
final VoidCallback? onSettings;
@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),
),
),
SizedBox(width: 11),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aranthel',
style: 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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Настройки', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 20),
const Text('Папка установки'),
const SizedBox(height: 6),
MwStatusText(
text: installationPath ?? 'Папка не выбрана',
monospace: true,
),
const SizedBox(height: 8),
MwButton(
label: 'Выбрать папку',
icon: Icons.folder_open_outlined,
onPressed: syncActive ? null : onChooseDirectory,
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,
),
],
),
);
}
+448
View File
@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
enum MwButtonVariant { primary, secondary, ghost, destructive }
class MwButton extends StatelessWidget {
const MwButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.variant = MwButtonVariant.primary,
this.loading = false,
this.expand = false,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final MwButtonVariant variant;
final bool loading;
final bool expand;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final child = loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
],
);
final callback = loading ? null : onPressed;
final button = switch (variant) {
MwButtonVariant.primary => ElevatedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.secondary => OutlinedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.ghost => TextButton(onPressed: callback, child: child),
MwButtonVariant.destructive => ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
backgroundColor: scheme.error,
foregroundColor: scheme.onError,
disabledBackgroundColor: scheme.error.withAlpha(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMedium),
),
),
child: child,
),
};
return Semantics(
button: true,
enabled: callback != null,
child: SizedBox(width: expand ? double.infinity : null, child: button),
);
}
}
class MwIconButton extends StatelessWidget {
const MwIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.selected = false,
this.destructive = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool selected;
final bool destructive;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
selected: selected,
child: IconButton(
onPressed: onPressed,
isSelected: selected,
icon: Icon(icon),
color: destructive ? scheme.error : scheme.onSurfaceVariant,
style: IconButton.styleFrom(minimumSize: const Size(44, 44)),
),
),
);
}
}
class MwTextField extends StatelessWidget {
const MwTextField({
super.key,
required this.label,
this.controller,
this.hint,
this.errorText,
this.prefixIcon,
this.enabled = true,
this.obscureText = false,
this.textInputAction,
this.onSubmitted,
});
final String label;
final TextEditingController? controller;
final String? hint;
final String? errorText;
final IconData? prefixIcon;
final bool enabled;
final bool obscureText;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
obscureText: obscureText,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
prefixIcon: prefixIcon == null ? null : Icon(prefixIcon),
),
);
}
}
class MwCheckbox extends StatelessWidget {
const MwCheckbox({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool?>? onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: value,
onChanged: onChanged,
title: Text(label),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
);
}
}
class MwTabs extends StatelessWidget {
const MwTabs({
super.key,
required this.labels,
required this.selectedIndex,
required this.onSelected,
this.disabledIndices = const {},
});
final List<String> labels;
final int selectedIndex;
final ValueChanged<int> onSelected;
final Set<int> disabledIndices;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
children: [
for (var index = 0; index < labels.length; index++)
ChoiceChip(
label: Text(labels[index]),
selected: selectedIndex == index,
onSelected: disabledIndices.contains(index)
? null
: (_) => onSelected(index),
),
],
);
}
}
enum MwBadgeTone { neutral, success, warning, error, comingSoon }
class MwBadge extends StatelessWidget {
const MwBadge({
super.key,
required this.label,
this.tone = MwBadgeTone.neutral,
});
final String label;
final MwBadgeTone tone;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwBadgeTone.neutral => scheme.onSurfaceVariant,
MwBadgeTone.success => tokens.success,
MwBadgeTone.warning => tokens.warning,
MwBadgeTone.error => scheme.error,
MwBadgeTone.comingSoon => tokens.accentMuted,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(24),
border: Border.all(color: color.withAlpha(130)),
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
);
}
}
enum MwPanelLevel { low, regular, high }
class MwPanel extends StatelessWidget {
const MwPanel({
super.key,
required this.child,
this.level = MwPanelLevel.regular,
this.padding = const EdgeInsets.all(24),
});
final Widget child;
final MwPanelLevel level;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final color = switch (level) {
MwPanelLevel.low => tokens.surfaceLow,
MwPanelLevel.regular => tokens.surface,
MwPanelLevel.high => tokens.surfaceHigh,
};
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(238),
border: Border.fromBorderSide(tokens.border),
borderRadius: BorderRadius.circular(tokens.radiusLarge),
boxShadow: tokens.panelShadow,
),
child: Padding(padding: padding, child: child),
);
}
}
enum MwProgressState { active, paused, completed, failure }
class MwProgressBar extends StatelessWidget {
const MwProgressBar({
super.key,
this.value,
this.label,
this.state = MwProgressState.active,
});
final double? value;
final String? label;
final MwProgressState state;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (state) {
MwProgressState.active => scheme.primary,
MwProgressState.paused => tokens.warning,
MwProgressState.completed => tokens.success,
MwProgressState.failure => scheme.error,
};
return Semantics(
label: label ?? 'Прогресс',
value: value == null ? null : '${(value! * 100).round()}%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (label != null) ...[
Text(label!, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
],
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: value,
color: color,
backgroundColor: color.withAlpha(35),
minHeight: 7,
),
),
],
),
);
}
}
enum MwStatusTone { operational, success, warning, error }
class MwStatusText extends StatelessWidget {
const MwStatusText({
super.key,
required this.text,
this.tone = MwStatusTone.operational,
this.monospace = false,
});
final String text;
final MwStatusTone tone;
final bool monospace;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwStatusTone.operational => scheme.onSurfaceVariant,
MwStatusTone.success => tokens.success,
MwStatusTone.warning => tokens.warning,
MwStatusTone.error => scheme.error,
};
return Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: color,
fontFamily: monospace ? tokens.monospaceFontFamily : null,
),
);
}
}
enum MwStateKind { loading, empty, error }
class MwStateView extends StatelessWidget {
const MwStateView({
super.key,
required this.kind,
required this.title,
this.description,
this.onRetry,
});
final MwStateKind kind;
final String title;
final String? description;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final icon = switch (kind) {
MwStateKind.loading => null,
MwStateKind.empty => Icons.inbox_outlined,
MwStateKind.error => Icons.cloud_off_outlined,
};
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon == null)
const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(icon, size: 34),
const SizedBox(height: 12),
Text(title, textAlign: TextAlign.center),
if (description != null) ...[
const SizedBox(height: 6),
Text(
description!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 16),
MwButton(
label: 'Повторить',
onPressed: onRetry,
variant: MwButtonVariant.secondary,
),
],
],
),
),
);
}
}
class MwDisabledFeature extends StatelessWidget {
const MwDisabledFeature({
super.key,
required this.message,
required this.child,
});
final String message;
final Widget child;
@override
Widget build(BuildContext context) {
return Tooltip(message: message, child: child);
}
}
+447
View File
@@ -0,0 +1,447 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
const _ink = Color(0xFF06091A);
const _ivory = Color(0xFFECE4D0);
class MwBrandMark extends StatelessWidget {
const MwBrandMark({super.key, this.width = 220, this.compact = false});
final double width;
final bool compact;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Semantics(
image: true,
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),
Text(
'LUNAR · LAUNCHER',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
),
),
],
],
),
],
),
),
);
}
}
class _MoonMark extends StatelessWidget {
const _MoonMark({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color.withAlpha(90)),
boxShadow: [BoxShadow(color: color.withAlpha(70), blurRadius: 16)],
),
child: Icon(Icons.nightlight_round, color: color, size: size * .72),
),
);
}
}
class MwArtworkBackground extends StatelessWidget {
const MwArtworkBackground({
super.key,
required this.child,
this.assetPackage,
});
final Widget child;
final String? assetPackage;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: _ink),
// Image.asset(
// 'assets/background.png',
// package: assetPackage,
// fit: BoxFit.cover,
// alignment: Alignment.center,
// errorBuilder: (_, _, _) => const SizedBox.shrink(),
// ),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
_ink.withAlpha(235),
_ink.withAlpha(85),
_ink.withAlpha(165),
],
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(.35, -.5),
radius: .8,
colors: [accent.withAlpha(42), Colors.transparent],
),
),
),
child,
],
);
}
}
class MwLoginShell extends StatelessWidget {
const MwLoginShell({
super.key,
required this.form,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.assetPackage,
});
final Widget form;
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final String? assetPackage;
@override
Widget build(BuildContext context) {
return Scaffold(
body: MwArtworkBackground(
assetPackage: assetPackage,
child: Stack(
children: [
Positioned.fill(
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,
),
),
],
),
),
),
),
),
Container(
width: 480,
decoration: const BoxDecoration(
color: Color(0xF20A0D1F),
border: Border(
left: BorderSide(color: Color(0x247FB8D4)),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(56, 64, 56, 58),
child: form,
),
),
],
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: MwWindowChrome(
onDrag: onDrag,
onDoubleTap: onDoubleTap,
onMinimize: onMinimize,
onMaximizeRestore: onMaximizeRestore,
onClose: onClose,
showMaximize: false,
),
),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 26,
child: _LoginStatusBar(),
),
],
),
),
);
}
}
class _LoginStatusBar extends StatelessWidget {
const _LoginStatusBar();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
),
child: const Row(
children: [
Text('Версия лаунчера 1.0.0'),
SizedBox(width: 24),
Text('·'),
SizedBox(width: 24),
Text('Соединение защищено TLS'),
Spacer(),
Text('© 2026 MoonWell'),
],
),
);
}
}
class MwLauncherShell extends StatelessWidget {
const MwLauncherShell({
super.key,
required this.news,
required this.syncActions,
required this.statusBar,
required this.account,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.assetPackage,
});
final Widget news;
final Widget syncActions;
final Widget statusBar;
final Widget account;
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final String? assetPackage;
@override
Widget build(BuildContext context) {
return Scaffold(
body: MwArtworkBackground(
assetPackage: assetPackage,
child: Stack(
children: [
const Positioned(
left: 0,
top: 0,
bottom: 0,
width: 240,
child: MwGameRail(),
),
Positioned(
left: 272,
top: 24,
child: const MwBrandMark(width: 245),
),
Positioned(right: 28, top: 48, child: account),
const Positioned(left: 272, top: 160, child: _HeroCopy()),
Positioned(
right: 24,
top: 110,
bottom: 130,
width: 360,
child: news,
),
Positioned(
left: 240,
right: 0,
bottom: 26,
height: 110,
child: syncActions,
),
Positioned(
left: 240,
right: 0,
bottom: 0,
height: 26,
child: statusBar,
),
Positioned(
left: 0,
right: 0,
top: 0,
child: MwWindowChrome(
onDrag: onDrag,
onDoubleTap: onDoubleTap,
onMinimize: onMinimize,
onMaximizeRestore: onMaximizeRestore,
onClose: onClose,
showMaximize: false,
),
),
],
),
),
);
}
}
class _HeroCopy extends StatelessWidget {
const _HeroCopy();
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
return SizedBox(
width: 520,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(99),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Text(
'OPEN BETA',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 11,
letterSpacing: 2.4,
),
),
],
),
),
const SizedBox(height: 18),
const Text(
'Свет колодца',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 50,
height: 1.05,
letterSpacing: 1.5,
),
),
Text(
'зовёт тебя домой.',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: accent,
fontSize: 54,
height: 1.05,
),
),
const SizedBox(height: 14),
const Text(
'Wrath of the Lich King · приватный сервер',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 18,
),
),
],
),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
class MwWindowChrome extends StatelessWidget {
const MwWindowChrome({
super.key,
required this.onDrag,
required this.onDoubleTap,
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.showMaximize = true,
});
final VoidCallback onDrag;
final VoidCallback onDoubleTap;
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final bool showMaximize;
@override
Widget build(BuildContext context) {
return Container(
height: 40.0,
padding: .symmetric(horizontal: 12.0),
child: Row(
spacing: 4.0,
children: [
const Spacer(),
_WindowButton(
icon: Icons.remove,
label: 'Свернуть',
onPressed: onMinimize,
),
if (showMaximize)
_WindowButton(
icon: Icons.crop_square,
label: 'Развернуть или восстановить',
onPressed: onMaximizeRestore,
),
_WindowButton(
icon: Icons.close,
label: 'Закрыть',
destructive: true,
onPressed: onClose,
),
],
),
);
}
}
class _WindowButton extends StatelessWidget {
const _WindowButton({
required this.icon,
required this.label,
required this.onPressed,
this.destructive = false,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
final bool destructive;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: label,
child: Semantics(
button: true,
label: label,
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, size: 16),
color: scheme.onSurfaceVariant,
hoverColor: destructive
? scheme.error.withAlpha(180)
: scheme.onSurface.withAlpha(20),
focusColor: scheme.primary.withAlpha(50),
style: IconButton.styleFrom(
tapTargetSize: .shrinkWrap,
visualDensity: VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
fixedSize: const Size(36.0, 28.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.0),
),
),
),
),
);
}
}
@@ -136,6 +136,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
errorMessage: null,
processedFiles: 0,
totalFiles: 0,
syncStage: null,
),
),
);
@@ -306,6 +307,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
remoteBuildHash: event.status.remoteBuildHash,
processedFiles: event.status.processedFiles,
totalFiles: event.status.totalFiles,
syncStage: event.status.stage,
),
),
);
@@ -330,6 +332,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Обновление приостановлено.',
currentPath: null,
errorMessage: null,
syncStage: null,
),
),
);
@@ -344,6 +347,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
statusText: 'Не удалось завершить обновление клиента.',
currentPath: null,
errorMessage: _formatError(event.error),
syncStage: null,
),
),
);
@@ -1,5 +1,6 @@
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';
enum HomeScreenPhase {
@@ -31,6 +32,7 @@ final class HomeScreenModel {
final List<LauncherNewsItem> newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
final ClientSyncStage? syncStage;
const HomeScreenModel({
required this.progress,
@@ -48,6 +50,7 @@ final class HomeScreenModel {
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
required this.syncStage,
});
const HomeScreenModel.initial()
@@ -65,7 +68,8 @@ final class HomeScreenModel {
totalFiles = 0,
newsItems = const <LauncherNewsItem>[],
isLoadingNews = true,
newsErrorMessage = null;
newsErrorMessage = null,
syncStage = null;
bool get isBusy =>
phase == HomeScreenPhase.authenticating ||
@@ -91,6 +95,7 @@ final class HomeScreenModel {
List<LauncherNewsItem>? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
Object? syncStage = _sentinel,
}) {
return HomeScreenModel(
progress: progress ?? this.progress,
@@ -120,6 +125,9 @@ final class HomeScreenModel {
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
syncStage: syncStage == _sentinel
? this.syncStage
: syncStage as ClientSyncStage?,
);
}
}
+1 -34
View File
@@ -3,8 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/app/widgets/window_title_bar.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
@@ -44,38 +42,7 @@ class HomeScreen extends StatelessWidget {
),
);
},
child: Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(180),
MWColors.abyss.withAlpha(220),
MWColors.abyss.withAlpha(240),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
const Positioned.fill(child: HomeScreenContent()),
const Positioned(
top: 0,
left: 0,
right: 0,
child: WindowTitleBar(),
),
],
),
),
child: const HomeScreenContent(),
),
);
}
+152 -579
View File
@@ -1,618 +1,191 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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/home_screen/widgets/gilded_progress_bar.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:pixelarticons/pixel.dart';
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreenContent extends StatelessWidget {
const HomeScreenContent({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<HomeScreenBloc>().state;
final model = state.model;
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 36),
child: Column(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: _NewsPanel(model: model, cs: cs),
),
SizedBox(
width: 320,
child: _RightPanel(model: model, cs: cs),
),
],
),
),
_BottomBar(model: model, cs: cs),
],
),
);
}
}
class _NewsPanel extends StatelessWidget {
const _NewsPanel({required this.model, required this.cs});
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 24, top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Image.asset(
'assets/logo.png',
height: 120,
filterQuality: FilterQuality.high,
),
),
),
Text(
'\u041d\u043e\u0432\u043e\u0441\u0442\u0438',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 12),
Expanded(
child: ShaderMask(
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.white.withAlpha(0)],
stops: const [0.0, 0.85, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: _buildNewsBody(context),
),
),
],
final model = context.watch<HomeScreenBloc>().state.model;
return MwLauncherShell(
news: _buildNews(model),
syncActions: _buildSyncActions(context, model),
statusBar: _buildStatusBar(model),
account: MwAccountAffordance(
onSettings: () => _showSettings(context, model),
onLogout: () =>
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
onMaximizeRestore: () => unawaited(_toggleMaximized()),
onClose: () => unawaited(windowManager.destroy()),
);
}
Widget _buildNewsBody(BuildContext context) {
if (model.isLoadingNews) {
return Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.2, color: cs.primary),
),
);
}
if (model.newsItems.isEmpty && model.newsErrorMessage != null) {
return ListView(
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: [
_NewsStatusCard(
icon: Icons.cloud_off_rounded,
title:
'\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c '
'\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c '
'\u043d\u043e\u0432\u043e\u0441\u0442\u0438',
description: model.newsErrorMessage!,
Widget _buildNews(HomeScreenModel model) {
final state = model.isLoadingNews
? MwNewsListState.loading
: model.newsItems.isNotEmpty
? MwNewsListState.populated
: model.newsErrorMessage != null
? MwNewsListState.failure
: MwNewsListState.empty;
return MwNewsList(
state: state,
error: model.newsErrorMessage,
items: [
for (final item in model.newsItems)
MwNewsItemData(
title: item.title,
body: item.body,
createdAt: item.createdAt,
imageUrl: item.imageUrl,
),
],
);
}
],
);
}
if (model.newsItems.isEmpty) {
return ListView(
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: const [
_NewsStatusCard(
icon: Icons.inbox_outlined,
title:
'\u041d\u043e\u0432\u043e\u0441\u0442\u0435\u0439 '
'\u043f\u043e\u043a\u0430 \u043d\u0435\u0442',
description:
'\u041a\u043e\u0433\u0434\u0430 \u0432 API \u043f\u043e\u044f'
'\u0432\u044f\u0442\u0441\u044f \u043f\u0443\u0431\u043b\u0438'
'\u043a\u0430\u0446\u0438\u0438, \u043e\u043d\u0438 '
'\u043f\u043e\u044f\u0432\u044f\u0442\u0441\u044f \u0437\u0434'
'\u0435\u0441\u044c.',
),
],
);
}
return ListView.separated(
padding: const EdgeInsets.only(right: 16, bottom: 24),
itemCount: model.newsItems.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
return _NewsCard(item: model.newsItems[index]);
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
final presentation = _syncPresentation(model);
final progress = model.progress.total > 0
? (model.progress.downloaded / model.progress.total).clamp(0.0, 1.0)
: null;
return MwSyncActionArea(
state: presentation,
status: model.statusText,
currentPath: model.currentPath,
error: model.errorMessage,
progress: progress,
onPrimary: () {
final bloc = context.read<HomeScreenBloc>();
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
bloc.add(HomeScreenPlayRequested());
} else {
bloc.add(HomeScreenSyncRequested());
}
},
);
}
}
class _NewsStatusCard extends StatelessWidget {
const _NewsStatusCard({
required this.icon,
required this.title,
required this.description,
});
final IconData icon;
final String title;
final String description;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface.withAlpha(140),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withAlpha(100)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: cs.tertiary),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: cs.tertiary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface.withAlpha(200),
height: 1.4,
),
),
],
),
);
}
}
class _NewsCard extends StatelessWidget {
const _NewsCard({required this.item});
final LauncherNewsItem item;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface.withAlpha(140),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withAlpha(100)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: cs.tertiary,
fontWeight: FontWeight.w700,
),
),
if (item.createdAt != null) ...[
const SizedBox(height: 6),
Text(
_formatCreatedAt(item.createdAt!),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
if (item.imageUrl != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 7,
child: Image.network(
item.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: cs.surface.withAlpha(120),
alignment: Alignment.center,
child: Icon(
Icons.broken_image_outlined,
color: cs.onSurfaceVariant,
),
),
),
),
),
],
const SizedBox(height: 8),
Text(
item.body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface.withAlpha(200),
height: 1.4,
),
),
],
),
onPause: model.isSyncing
? () => context.read<HomeScreenBloc>().add(HomeScreenPauseRequested())
: null,
);
}
String _formatCreatedAt(DateTime value) {
final localValue = value.toLocal();
final day = localValue.day.toString().padLeft(2, '0');
final month = localValue.month.toString().padLeft(2, '0');
final year = localValue.year.toString();
return '$day.$month.$year';
}
}
class _RightPanel extends StatelessWidget {
const _RightPanel({required this.model, required this.cs});
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: MWColors.abyss.withAlpha(200),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: deco.cardGlow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => context.read<HomeScreenBloc>().add(
HomeScreenLogoutRequested(),
),
icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('\u0412\u044b\u0439\u0442\u0438'),
style: TextButton.styleFrom(
foregroundColor: cs.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
const SizedBox(height: 12),
const Spacer(),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context.read<HomeScreenBloc>().add(
HomeScreenOutputDirRequested(),
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(80)),
),
child: Row(
children: [
Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(
model.outputPath?.toFilePath() ??
'\u0412\u044b\u0431\u0440\u0430\u0442\u044c '
'\u043f\u0430\u043f\u043a\u0443',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
),
Icon(
Icons.edit,
size: 14,
color: cs.onSurfaceVariant.withAlpha(120),
),
],
),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: cs.surface.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(60)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
model.statusText,
style: TextStyle(
color: cs.onSurface.withAlpha(180),
fontSize: 13,
),
),
if (model.currentPath != null) ...[
const SizedBox(height: 6),
Text(
model.currentPath!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
if (model.totalFiles > 0) ...[
const SizedBox(height: 6),
Text(
'\u0424\u0430\u0439\u043b\u044b: '
'${model.processedFiles}/${model.totalFiles}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
if (model.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
model.errorMessage!,
style: TextStyle(color: cs.error, fontSize: 12),
),
],
],
),
),
const Spacer(),
if (model.remoteBuildHash != null || model.localBuildHash != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Build: ${_shortHash(model.localBuildHash)} / '
'${_shortHash(model.remoteBuildHash)}',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
),
),
],
),
);
}
String _shortHash(String? hash) {
if (hash == null || hash.isEmpty) {
return '-';
MwSyncPresentation _syncPresentation(HomeScreenModel model) {
if (model.phase == HomeScreenPhase.paused) {
return MwSyncPresentation.paused;
}
if (hash.length <= 12) {
return hash;
if (model.phase == HomeScreenPhase.failure) {
return MwSyncPresentation.failure;
}
return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
if (model.phase == HomeScreenPhase.readyToPlay) {
return model.statusText.startsWith('Игра запущена')
? MwSyncPresentation.launched
: MwSyncPresentation.ready;
}
if (model.phase != HomeScreenPhase.syncing) {
return MwSyncPresentation.install;
}
return switch (model.syncStage) {
ClientSyncStage.downloadingFiles => MwSyncPresentation.downloading,
ClientSyncStage.verifyingInstallation => MwSyncPresentation.verifying,
ClientSyncStage.completed => MwSyncPresentation.ready,
_ => MwSyncPresentation.scanning,
};
}
}
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.model, required this.cs});
Widget _buildStatusBar(HomeScreenModel model) {
return MwLauncherStatusBar(
status: model.outputPath?.toFilePath() ?? 'Папка установки не выбрана',
fileCount: model.totalFiles > 0
? '${model.processedFiles}/${model.totalFiles}'
: null,
speed: model.progress.speed > 0
? _formatSpeed(model.progress.speed)
: null,
eta: model.progress.eta > Duration.zero
? _formatDuration(model.progress.eta)
: null,
buildHash: _shortHash(model.localBuildHash ?? model.remoteBuildHash),
);
}
final HomeScreenModel model;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final progressValue = model.progress.total == 0
? 0.0
: model.progress.downloaded / model.progress.total;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [MWColors.abyss.withAlpha(0), MWColors.abyss.withAlpha(230)],
Future<void> _showSettings(
BuildContext context,
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (model.isSyncing) ...[
Row(
children: [
Expanded(child: GildedProgressBar(value: progressValue)),
const SizedBox(width: 12),
Text(
'${(progressValue * 100).clamp(0, 100).toStringAsFixed(1)}%',
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Text(
_formatSpeed(model.progress.speed),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
),
const SizedBox(height: 8),
],
Row(
children: [
SizedBox(
height: 44,
child: ElevatedButton.icon(
icon: Icon(_resolvePrimaryIcon(model), size: 20),
label: Text(_resolvePrimaryLabel(model)),
onPressed: model.isBusy
? null
: () => _onPrimaryAction(context, model),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 28),
),
),
),
if (model.canPlay || model.isSyncing) ...[
const SizedBox(width: 8),
SizedBox(
height: 44,
child: OutlinedButton.icon(
icon: Icon(_resolveSecondaryIcon(model), size: 18),
label: Text(_resolveSecondaryLabel(model)),
onPressed: () => _onSecondaryAction(context, model),
),
),
],
const SizedBox(width: 16),
Expanded(
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(60)),
),
alignment: Alignment.centerLeft,
child: Text(
model.currentPath ?? model.statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface.withAlpha(160),
fontSize: 12,
),
),
),
),
],
),
],
),
);
}
void _onPrimaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.canPlay) {
bloc.add(HomeScreenPlayRequested());
return;
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
bloc.add(HomeScreenSyncRequested());
}
void _onSecondaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.isSyncing) {
bloc.add(HomeScreenPauseRequested());
return;
}
if (model.canPlay) {
bloc.add(HomeScreenSyncRequested());
return;
}
bloc.add(HomeScreenOutputDirRequested());
}
String _resolvePrimaryLabel(HomeScreenModel model) {
if (model.isSyncing) {
return '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435...';
}
if (model.canPlay) {
return '\u0418\u0433\u0440\u0430\u0442\u044c';
}
return model.hasClientExecutable
? '\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c'
: '\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c';
}
IconData _resolvePrimaryIcon(HomeScreenModel model) {
if (model.canPlay) {
return Icons.play_arrow_rounded;
}
return Icons.download_rounded;
}
String _resolveSecondaryLabel(HomeScreenModel model) {
if (model.isSyncing) {
return '\u041f\u0430\u0443\u0437\u0430';
}
if (model.canPlay) {
return '\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c';
}
return '\u041f\u0430\u043f\u043a\u0430';
}
IconData _resolveSecondaryIcon(HomeScreenModel model) {
if (model.isSyncing) {
return Pixel.pause;
}
if (model.canPlay) {
return Icons.refresh_rounded;
}
return Pixel.folder;
}
String _formatSpeed(int bytesPerSecond) {
if (bytesPerSecond <= 0) {
return '-';
const megabyte = 1024 * 1024;
if (bytesPerSecond >= megabyte) {
return '${(bytesPerSecond / megabyte).toStringAsFixed(1)} MB/s';
}
return '${(bytesPerSecond / 1024).toStringAsFixed(0)} KB/s';
}
if (bytesPerSecond >= 1024 * 1024) {
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
}
String _formatDuration(Duration duration) {
final hours = duration.inHours.toString().padLeft(2, '0');
final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
if (bytesPerSecond >= 1024) {
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
}
return '$bytesPerSecond B/s';
String? _shortHash(String? hash) {
if (hash == null || hash.isEmpty) return null;
return hash.length <= 8 ? hash : hash.substring(0, 8);
}
}
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
class GildedProgressBar extends StatelessWidget {
const GildedProgressBar({super.key, required this.value});
final double value;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
height: 20,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Subtle top highlight
Positioned(
top: 0.0,
height: 6.0,
left: 0.0,
right: 0.0,
child: ColoredBox(color: Colors.white.withAlpha(10)),
),
Positioned.fill(
child: FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: (deco.goldBevel as LinearGradient),
),
),
),
),
],
),
);
}
}
+25 -109
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/app/widgets/window_title_bar.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:window_manager/window_manager.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@@ -76,114 +79,27 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
),
),
),
const Positioned(top: 0, left: 0, right: 0, child: WindowTitleBar()),
Center(
child: SizedBox(
width: 340,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: MWColors.abyss.withAlpha(210),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: [
BoxShadow(
color: MWColors.primary.withAlpha(30),
blurRadius: 40,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameController,
enabled: !_isLoading,
decoration: const InputDecoration(
labelText: 'Логин',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
enabled: !_isLoading,
obscureText: true,
onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: 'Пароль',
prefixIcon: Icon(Icons.lock_outline),
),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Войти'),
),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: cs.error, fontSize: 13),
),
],
],
),
),
],
),
),
),
],
return MwLoginShell(
form: MwAuthenticationForm(
usernameController: _usernameController,
passwordController: _passwordController,
loading: _isLoading,
error: _error,
onSubmit: _login,
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
onMaximizeRestore: () => unawaited(_toggleMaximized()),
onClose: () => unawaited(windowManager.destroy()),
);
}
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
}
}
+62 -50
View File
@@ -1,25 +1,68 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
class MoonWellApp extends StatelessWidget {
class MoonWellApp extends StatefulWidget {
const MoonWellApp({super.key});
@override
State<MoonWellApp> createState() => _MoonWellAppState();
}
class _MoonWellAppState extends State<MoonWellApp> {
late final LauncherThemeController _themeController;
@override
void initState() {
super.initState();
_themeController = LauncherThemeController(getIt<PreferencesRepository>());
_themeController.load();
}
@override
void dispose() {
_themeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MoonWell',
home: const _LauncherBootstrapScreen(),
theme: moonWellTheme(),
return AnimatedBuilder(
animation: _themeController,
builder: (context, _) => LauncherThemeScope(
controller: _themeController,
child: MaterialApp(
title: 'MoonWell',
debugShowCheckedModeBanner: false,
home: const _LauncherBootstrapScreen(),
theme: MoonWellTheme.create(_themeController.variant),
),
),
);
}
}
class LauncherThemeScope extends InheritedNotifier<LauncherThemeController> {
const LauncherThemeScope({
super.key,
required LauncherThemeController controller,
required super.child,
}) : super(notifier: controller);
static LauncherThemeController of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<LauncherThemeScope>();
assert(scope != null, 'LauncherThemeScope is missing');
return scope!.notifier!;
}
}
class _LauncherBootstrapScreen extends StatefulWidget {
const _LauncherBootstrapScreen();
@@ -48,7 +91,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
if (snapshot.connectionState != ConnectionState.done) {
return const _LauncherBootstrapLoadingScreen();
}
final result =
snapshot.data ??
const RestoreLauncherSessionResult.unauthenticated();
@@ -58,7 +100,6 @@ class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
manifest: result.manifest!,
);
}
return const LoginScreen();
},
);
@@ -70,50 +111,21 @@ class _LauncherBootstrapLoadingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
return const Scaffold(
body: MwArtworkBackground(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MwBrandMark(width: 220),
SizedBox(height: 24),
SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 24),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: cs.primary,
),
),
],
),
),
],
),
),
);
}
+308
View File
@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
export 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
@immutable
class MoonWellDesignTokens extends ThemeExtension<MoonWellDesignTokens> {
const MoonWellDesignTokens({
required this.background,
required this.surfaceLow,
required this.surface,
required this.surfaceHigh,
required this.accent,
required this.accentMuted,
required this.success,
required this.warning,
required this.border,
required this.focusRing,
required this.spacing,
required this.radiusSmall,
required this.radiusMedium,
required this.radiusLarge,
required this.panelShadow,
required this.motionFast,
required this.motionStandard,
required this.brandFontFamily,
required this.monospaceFontFamily,
});
final Color background;
final Color surfaceLow;
final Color surface;
final Color surfaceHigh;
final Color accent;
final Color accentMuted;
final Color success;
final Color warning;
final BorderSide border;
final Color focusRing;
final double spacing;
final double radiusSmall;
final double radiusMedium;
final double radiusLarge;
final List<BoxShadow> panelShadow;
final Duration motionFast;
final Duration motionStandard;
final String brandFontFamily;
final String monospaceFontFamily;
static MoonWellDesignTokens of(BuildContext context) =>
Theme.of(context).extension<MoonWellDesignTokens>()!;
@override
MoonWellDesignTokens copyWith({
Color? background,
Color? surfaceLow,
Color? surface,
Color? surfaceHigh,
Color? accent,
Color? accentMuted,
Color? success,
Color? warning,
BorderSide? border,
Color? focusRing,
double? spacing,
double? radiusSmall,
double? radiusMedium,
double? radiusLarge,
List<BoxShadow>? panelShadow,
Duration? motionFast,
Duration? motionStandard,
String? brandFontFamily,
String? monospaceFontFamily,
}) {
return MoonWellDesignTokens(
background: background ?? this.background,
surfaceLow: surfaceLow ?? this.surfaceLow,
surface: surface ?? this.surface,
surfaceHigh: surfaceHigh ?? this.surfaceHigh,
accent: accent ?? this.accent,
accentMuted: accentMuted ?? this.accentMuted,
success: success ?? this.success,
warning: warning ?? this.warning,
border: border ?? this.border,
focusRing: focusRing ?? this.focusRing,
spacing: spacing ?? this.spacing,
radiusSmall: radiusSmall ?? this.radiusSmall,
radiusMedium: radiusMedium ?? this.radiusMedium,
radiusLarge: radiusLarge ?? this.radiusLarge,
panelShadow: panelShadow ?? this.panelShadow,
motionFast: motionFast ?? this.motionFast,
motionStandard: motionStandard ?? this.motionStandard,
brandFontFamily: brandFontFamily ?? this.brandFontFamily,
monospaceFontFamily: monospaceFontFamily ?? this.monospaceFontFamily,
);
}
@override
MoonWellDesignTokens lerp(covariant MoonWellDesignTokens? other, double t) {
if (other == null) return this;
return MoonWellDesignTokens(
background: Color.lerp(background, other.background, t)!,
surfaceLow: Color.lerp(surfaceLow, other.surfaceLow, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surfaceHigh: Color.lerp(surfaceHigh, other.surfaceHigh, t)!,
accent: Color.lerp(accent, other.accent, t)!,
accentMuted: Color.lerp(accentMuted, other.accentMuted, t)!,
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
border: BorderSide.lerp(border, other.border, t),
focusRing: Color.lerp(focusRing, other.focusRing, t)!,
spacing: lerpDouble(spacing, other.spacing, t),
radiusSmall: lerpDouble(radiusSmall, other.radiusSmall, t),
radiusMedium: lerpDouble(radiusMedium, other.radiusMedium, t),
radiusLarge: lerpDouble(radiusLarge, other.radiusLarge, t),
panelShadow: t < .5 ? panelShadow : other.panelShadow,
motionFast: t < .5 ? motionFast : other.motionFast,
motionStandard: t < .5 ? motionStandard : other.motionStandard,
brandFontFamily: t < .5 ? brandFontFamily : other.brandFontFamily,
monospaceFontFamily: t < .5
? monospaceFontFamily
: other.monospaceFontFamily,
);
}
static double lerpDouble(double a, double b, double t) => a + (b - a) * t;
}
abstract final class MoonWellTheme {
static ThemeData create(MoonWellThemeVariant variant, {String? fontPackage}) {
final isForest = variant == MoonWellThemeVariant.forest;
const background = Color(0xFF070A18);
const surfaceLow = Color(0xFF0A0D1F);
const surface = Color(0xFF10142A);
const surfaceHigh = Color(0xFF1A1F3A);
final accent = isForest ? const Color(0xFF7FB8D4) : const Color(0xFF8A5CC8);
const accentMuted = Color(0xFF5B3A8A);
const outline = Color(0xFF34455E);
const foreground = Color(0xFFECE4D0);
const error = Color(0xFFFF8A86);
const success = Color(0xFF70D7A5);
const warning = Color(0xFFFFC865);
final scheme = ColorScheme.dark(
primary: accent,
onPrimary: background,
secondary: accentMuted,
onSecondary: foreground,
tertiary: const Color(0xFF91C7D0),
onTertiary: background,
error: error,
onError: background,
surface: surface,
onSurface: foreground,
outline: outline,
shadow: Colors.black,
scrim: Colors.black87,
);
String font(String family) =>
fontPackage == null ? family : 'packages/$fontPackage/$family';
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: background,
canvasColor: background,
fontFamily: font('Inter'),
focusColor: accent.withAlpha(60),
visualDensity: VisualDensity.standard,
);
final radius = BorderRadius.circular(10);
final textTheme = base.textTheme
.apply(bodyColor: foreground, displayColor: foreground)
.copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
displayMedium: base.textTheme.displayMedium?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
headlineLarge: base.textTheme.headlineLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w600,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontFamily: font('Cormorant Garamond'),
fontWeight: FontWeight.w700,
),
labelLarge: base.textTheme.labelLarge?.copyWith(
fontFamily: font('Inter'),
fontWeight: FontWeight.w700,
),
);
return base.copyWith(
textTheme: textTheme,
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceHigh,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
enabledBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: accent, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: radius,
borderSide: const BorderSide(color: error),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(44, 44),
backgroundColor: accent,
foregroundColor: background,
disabledBackgroundColor: outline.withAlpha(90),
disabledForegroundColor: foreground.withAlpha(120),
shape: RoundedRectangleBorder(borderRadius: radius),
textStyle: TextStyle(
fontFamily: font('Inter'),
fontWeight: FontWeight.w700,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 44),
foregroundColor: foreground,
side: BorderSide(color: outline),
shape: RoundedRectangleBorder(borderRadius: radius),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(44, 44),
foregroundColor: accent,
),
),
checkboxTheme: CheckboxThemeData(
side: BorderSide(color: outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
extensions: [
MoonWellDesignTokens(
background: background,
surfaceLow: surfaceLow,
surface: surface,
surfaceHigh: surfaceHigh,
accent: accent,
accentMuted: accentMuted,
success: success,
warning: warning,
border: BorderSide(color: outline.withAlpha(190)),
focusRing: accent,
spacing: 8,
radiusSmall: 6,
radiusMedium: 10,
radiusLarge: 16,
panelShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
motionFast: const Duration(milliseconds: 120),
motionStandard: const Duration(milliseconds: 220),
brandFontFamily: font('Cinzel'),
monospaceFontFamily: font('JetBrains Mono'),
),
],
);
}
}
class LauncherThemeController extends ChangeNotifier {
LauncherThemeController(this._preferencesRepository);
final PreferencesRepository _preferencesRepository;
MoonWellThemeVariant _variant = MoonWellThemeVariant.forest;
MoonWellThemeVariant get variant => _variant;
Future<void> load() async {
_variant = await _preferencesRepository.getThemeVariant();
notifyListeners();
}
Future<void> setVariant(MoonWellThemeVariant variant) async {
if (_variant == variant) return;
_variant = variant;
notifyListeners();
await _preferencesRepository.setThemeVariant(variant);
}
}
-22
View File
@@ -1,22 +0,0 @@
part of 'mw_theme.dart';
/// Core palette
class MWColors {
// Surface & background
static const Color abyss = Color(0xFF090F2B); // page bg
static const Color deepNavy = Color(0xFF111840); // surfaces
static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces
// Primary & secondary
static const Color primary = Color(0xFF5460A2);
static const Color secondary = Color(0xFF5F6BD2);
static const Color tertiary = Color(0xFF6494EB);
// Lines & states
static const Color outline = Color(0xFF4B4E6E);
// Semantic
static const Color success = Color(0xFF3DDC97);
static const Color warning = Color(0xFFF0B429);
static const Color error = Color(0xFFD86A8A);
}
-40
View File
@@ -1,40 +0,0 @@
part of 'mw_theme.dart';
class MoonWellDecorations extends ThemeExtension<MoonWellDecorations> {
final Gradient goldBevel; // for gilded headers/buttons
final Shadow textGlow; // subtle moonlight glow
final List<BoxShadow> cardGlow;
const MoonWellDecorations({
required this.goldBevel,
required this.textGlow,
required this.cardGlow,
});
@override
MoonWellDecorations copyWith({
Gradient? goldBevel,
Shadow? textGlow,
List<BoxShadow>? cardGlow,
}) => MoonWellDecorations(
goldBevel: goldBevel ?? this.goldBevel,
textGlow: textGlow ?? this.textGlow,
cardGlow: cardGlow ?? this.cardGlow,
);
@override
ThemeExtension<MoonWellDecorations> lerp(
ThemeExtension<MoonWellDecorations>? other,
double t,
) {
if (other is! MoonWellDecorations) return this;
return MoonWellDecorations(
goldBevel: Gradient.lerp(goldBevel, other.goldBevel, t)!,
textGlow: Shadow.lerp(textGlow, other.textGlow, t)!,
cardGlow: [
for (int i = 0; i < cardGlow.length; i++)
BoxShadow.lerp(cardGlow[i], other.cardGlow[i], t)!,
],
);
}
}
-220
View File
@@ -1,220 +0,0 @@
import 'package:flutter/material.dart';
part 'mw_colors.dart';
part 'mw_decorations.dart';
ThemeData moonWellTheme() {
const cs = ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5460A2),
onPrimary: Color(0xFFDDE0FB),
secondary: Color(0xFF5F6BD2),
onSecondary: Color(0xFFDDE0FB),
tertiary: Color(0xFF6494EB),
onTertiary: Color(0xFF090F2B),
error: Color(0xFFD86A8A),
onError: Color(0xFF090F2B),
surface: Color(0xFF1A2759),
onSurface: Color(0xFFDDE0FB),
outline: Color(0xFF4B4E6E),
shadow: Color(0xFF000000),
scrim: Color(0xCC000000),
);
final base = ThemeData(
useMaterial3: true,
colorScheme: cs,
scaffoldBackgroundColor: cs.surface,
canvasColor: cs.surface,
);
final text = base.textTheme
.apply(
fontFamily: 'Cinzel',
bodyColor: cs.onSurface,
displayColor: cs.onSurface,
)
.copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(
letterSpacing: 0.5,
fontWeight: FontWeight.w700,
shadows: const [Shadow(blurRadius: 10, color: Color(0x336BA3FF))],
),
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
);
return base.copyWith(
textTheme: text,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: cs.onSurface,
centerTitle: true,
titleTextStyle: text.titleLarge,
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: cs.surface.withAlpha(50),
elevation: 0,
margin: const EdgeInsets.all(12),
shape: RoundedRectangleBorder(
side: BorderSide(color: MWColors.outline.withAlpha(150)),
borderRadius: BorderRadius.circular(20),
),
shadowColor: MWColors.tertiary.withAlpha(63),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
textStyle: WidgetStatePropertyAll(
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
),
elevation: const WidgetStatePropertyAll(6),
shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return MWColors.primary.withAlpha(115);
}
return cs.primary;
}),
foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)),
),
),
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(cs.tertiary),
overlayColor: WidgetStatePropertyAll(cs.tertiary.withAlpha(10)),
),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(cs.tertiaryContainer),
foregroundColor: WidgetStatePropertyAll(cs.onTertiaryContainer),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: WidgetStatePropertyAll(
BorderSide(color: MWColors.outline.withAlpha(230)),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return cs.primary.withAlpha(100);
}
return cs.primary;
}),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return MWColors.stormNavy.withAlpha(100);
}
return MWColors.stormNavy;
}),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cs.surfaceContainerHighest,
hintStyle: TextStyle(color: cs.onSurfaceVariant.withAlpha(179)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: MWColors.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.primary, width: 1.6),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.error),
),
prefixIconColor: cs.onSurfaceVariant,
suffixIconColor: cs.onSurfaceVariant,
),
chipTheme: base.chipTheme.copyWith(
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide(color: MWColors.outline),
selectedColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onSurface),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
sliderTheme: base.sliderTheme.copyWith(
activeTrackColor: cs.primary,
inactiveTrackColor: cs.primary.withAlpha(63),
thumbColor: cs.primary,
),
dividerTheme: DividerThemeData(
color: MWColors.outline,
thickness: 1,
space: 24,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withAlpha(153),
elevation: 8,
type: BottomNavigationBarType.fixed,
),
extensions: <ThemeExtension<dynamic>>[
const MoonWellDecorations(
goldBevel: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color(0xFF7A85CC), // highlight
Color(0xFF5460A2), // body
Color(0xFF3E4880), // edge
],
stops: [0.0, 0.55, 1.0],
),
textGlow: Shadow(
color: Color(0x446494EB),
blurRadius: 14,
offset: Offset(0, 0),
),
cardGlow: [
BoxShadow(
color: Color(0x33143666), // cool rim light
blurRadius: 28,
spreadRadius: 2,
offset: Offset(0, 8),
),
],
),
],
);
}
-77
View File
@@ -1,77 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:window_manager/window_manager.dart';
class WindowTitleBar extends StatelessWidget {
const WindowTitleBar({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: SizedBox(
height: 36,
child: Row(
children: [
const Spacer(),
_TitleBarButton(
icon: Icons.remove,
onTap: () => windowManager.minimize(),
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.crop_square,
onTap: () {
unawaited(_toggleMaximized());
},
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.close,
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
),
),
);
}
Future<void> _toggleMaximized() async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
}
}
class _TitleBarButton extends StatelessWidget {
const _TitleBarButton({
required this.icon,
required this.onTap,
required this.color,
});
final IconData icon;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: SizedBox(
width: 40,
height: 36,
child: Icon(icon, size: 16, color: color.withAlpha(180)),
),
);
}
}