Files
moonwell-launcher/lib/app/design_system/mw_launcher_components.dart
T
2026-07-28 21:25:06 +04:00

851 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.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 && subtitle == null)
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,
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(
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: Text(
userInitial,
style: const 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),
MwGameTile(
title: 'MoonWell Karts',
subtitle: 'В разработке',
mark: 'K',
icon: Icons.sports_motorsports,
enabled: false,
compact: compact,
),
const Spacer(),
const Divider(color: _border),
Row(
children: [
IconButton(
onPressed: null,
tooltip: 'Друзья',
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
IconButton(
onPressed: onSettings,
tooltip: 'Настройки',
icon: Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
),
),
],
),
],
),
);
}
}
class MwNewsItemData {
const MwNewsItemData({
required this.title,
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 {
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 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: [
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),
],
),
),
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(
'MOON-WELL.ONLINE',
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,
this.realmName,
this.realmOnline,
this.realmGameBuild,
});
final MwSyncPresentation state;
final String status;
final VoidCallback onPrimary;
final VoidCallback? onPause;
final double? progress;
final String? currentPath;
final String? error;
final String? realmName;
final bool? realmOnline;
final int? realmGameBuild;
@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 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 => 'ПРОДОЛЖИТЬ',
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: Row(
children: [
_PulseDot(online: realmOnline),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(realmName?.trim().isNotEmpty ?? false)
? realmName!.toUpperCase()
: 'REALM UNAVAILABLE',
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
const SizedBox(height: 3),
Text(
realmMetadata,
style: const 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,
),
),
),
if (ready)
const Flexible(
child: Text(
'Последняя проверка: только что',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: 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({required this.online});
final bool? online;
@override
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 {
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) {
final launcherVersion = LauncherVersionScope.of(context);
return 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),
Text('Лаунчер v$launcherVersion'),
],
),
),
);
}
}
class MwAccountAffordance extends StatelessWidget {
const MwAccountAffordance({super.key, required this.onLogout, this.username});
final VoidCallback onLogout;
final String? username;
@override
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),
),
),
const SizedBox(width: 11),
Text(
displayUsername,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
),
],
),
),
);
}
}
class MwSettingsPanel extends StatelessWidget {
const MwSettingsPanel({
super.key,
required this.installationPath,
required this.onChooseDirectory,
required this.onVerify,
this.syncActive = false,
});
final String? installationPath;
final VoidCallback onChooseDirectory;
final VoidCallback onVerify;
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),
MwButton(
label: 'Проверить файлы',
onPressed: syncActive ? null : onVerify,
variant: MwButtonVariant.secondary,
),
],
),
);
}