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 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( 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, ), ], ), ); }