новости

This commit is contained in:
2026-03-22 17:36:24 +04:00
parent 4edfb52396
commit 48ff9b63d8
9 changed files with 495 additions and 65 deletions
+8
View File
@@ -31,6 +31,7 @@ The launcher uses these endpoints from `openapi.json`:
1. `POST /api/launcher/login` 1. `POST /api/launcher/login`
2. `GET /api/launcher/manifest` 2. `GET /api/launcher/manifest`
3. `GET /api/launcher/download/{path}` 3. `GET /api/launcher/download/{path}`
4. `GET /api/launcher/news`
Authentication sequence: Authentication sequence:
@@ -58,6 +59,13 @@ File download sequence:
6. Downloaded file is verified against manifest `sha256`. 6. Downloaded file is verified against manifest `sha256`.
7. Temporary file replaces the destination file only after successful verify. 7. Temporary file replaces the destination file only after successful verify.
News sequence:
1. After the launcher reaches the authenticated home screen, it requests `GET /api/launcher/news`.
2. The response payload is read from the top-level `data` array.
3. Each news item provides `id`, `title`, `body`, optional `image_url`, and `created_at`.
4. News failures do not block patching or play flow; the launcher shows a local error state only inside the news panel.
## Installation Directory Rules ## Installation Directory Rules
The user selects a single installation root directory. The user selects a single installation root directory.
+32 -2
View File
@@ -8,6 +8,7 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_e
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.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/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
@@ -21,11 +22,13 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
HomeScreenBloc({ HomeScreenBloc({
required ClientSyncUseCase clientSyncUseCase, required ClientSyncUseCase clientSyncUseCase,
required GameInstallationService gameInstallationService, required GameInstallationService gameInstallationService,
required LauncherApiClient launcherApiClient,
required PreferencesRepository preferencesRepository, required PreferencesRepository preferencesRepository,
required LauncherSession session, required LauncherSession session,
required ClientManifest manifest, required ClientManifest manifest,
}) : _clientSyncUseCase = clientSyncUseCase, }) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService, _gameInstallationService = gameInstallationService,
_launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository, _preferencesRepository = preferencesRepository,
_session = session, _session = session,
_manifest = manifest, _manifest = manifest,
@@ -51,6 +54,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
final ClientSyncUseCase _clientSyncUseCase; final ClientSyncUseCase _clientSyncUseCase;
final GameInstallationService _gameInstallationService; final GameInstallationService _gameInstallationService;
final LauncherApiClient _launcherApiClient;
final PreferencesRepository _preferencesRepository; final PreferencesRepository _preferencesRepository;
StreamSubscription<ClientSyncStatus>? _syncSubscription; StreamSubscription<ClientSyncStatus>? _syncSubscription;
@@ -79,6 +83,30 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
), ),
), ),
); );
try {
final newsItems = await _launcherApiClient.fetchNews(
_session.accessToken,
);
emit(
HomeScreenState(
model: state.model.copyWith(
newsItems: newsItems,
isLoadingNews: false,
newsErrorMessage: null,
),
),
);
} catch (error) {
emit(
HomeScreenState(
model: state.model.copyWith(
isLoadingNews: false,
newsErrorMessage: _formatError(error),
),
),
);
}
} }
Future<void> _onHomeScreenSyncRequested( Future<void> _onHomeScreenSyncRequested(
@@ -238,14 +266,16 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
phase: HomeScreenPhase.idle, phase: HomeScreenPhase.idle,
isAuthenticated: false, isAuthenticated: false,
progress: const DownloadProgress.initial(), progress: const DownloadProgress.initial(),
statusText: statusText: 'Сессия завершена. Войдите снова.',
'Сессия завершена. Войдите снова.',
currentPath: null, currentPath: null,
errorMessage: null, errorMessage: null,
localBuildHash: null, localBuildHash: null,
remoteBuildHash: null, remoteBuildHash: null,
processedFiles: 0, processedFiles: 0,
totalFiles: 0, totalFiles: 0,
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
), ),
), ),
); );
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
enum HomeScreenPhase { enum HomeScreenPhase {
idle, idle,
@@ -27,6 +28,9 @@ final class HomeScreenModel {
final String? remoteBuildHash; final String? remoteBuildHash;
final int processedFiles; final int processedFiles;
final int totalFiles; final int totalFiles;
final List<LauncherNewsItem> newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
const HomeScreenModel({ const HomeScreenModel({
required this.progress, required this.progress,
@@ -41,6 +45,9 @@ final class HomeScreenModel {
required this.remoteBuildHash, required this.remoteBuildHash,
required this.processedFiles, required this.processedFiles,
required this.totalFiles, required this.totalFiles,
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
}); });
const HomeScreenModel.initial() const HomeScreenModel.initial()
@@ -55,7 +62,10 @@ final class HomeScreenModel {
localBuildHash = null, localBuildHash = null,
remoteBuildHash = null, remoteBuildHash = null,
processedFiles = 0, processedFiles = 0,
totalFiles = 0; totalFiles = 0,
newsItems = const <LauncherNewsItem>[],
isLoadingNews = true,
newsErrorMessage = null;
bool get isBusy => bool get isBusy =>
phase == HomeScreenPhase.authenticating || phase == HomeScreenPhase.authenticating ||
@@ -78,6 +88,9 @@ final class HomeScreenModel {
Object? remoteBuildHash = _sentinel, Object? remoteBuildHash = _sentinel,
int? processedFiles, int? processedFiles,
int? totalFiles, int? totalFiles,
List<LauncherNewsItem>? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
}) { }) {
return HomeScreenModel( return HomeScreenModel(
progress: progress ?? this.progress, progress: progress ?? this.progress,
@@ -102,6 +115,11 @@ final class HomeScreenModel {
: remoteBuildHash as String?, : remoteBuildHash as String?,
processedFiles: processedFiles ?? this.processedFiles, processedFiles: processedFiles ?? this.processedFiles,
totalFiles: totalFiles ?? this.totalFiles, totalFiles: totalFiles ?? this.totalFiles,
newsItems: newsItems ?? this.newsItems,
isLoadingNews: isLoadingNews ?? this.isLoadingNews,
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
); );
} }
} }
+2
View File
@@ -8,6 +8,7 @@ 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/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.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/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
@@ -26,6 +27,7 @@ class HomeScreen extends StatelessWidget {
create: (context) => HomeScreenBloc( create: (context) => HomeScreenBloc(
clientSyncUseCase: getIt<ClientSyncUseCase>(), clientSyncUseCase: getIt<ClientSyncUseCase>(),
gameInstallationService: getIt<GameInstallationService>(), gameInstallationService: getIt<GameInstallationService>(),
launcherApiClient: getIt<LauncherApiClient>(),
preferencesRepository: getIt<PreferencesRepository>(), preferencesRepository: getIt<PreferencesRepository>(),
session: session, session: session,
manifest: manifest, manifest: manifest,
+209 -62
View File
@@ -4,6 +4,7 @@ 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/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.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/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:pixelarticons/pixel.dart'; import 'package:pixelarticons/pixel.dart';
class HomeScreenContent extends StatelessWidget { class HomeScreenContent extends StatelessWidget {
@@ -19,14 +20,14 @@ class HomeScreenContent extends StatelessWidget {
padding: const EdgeInsets.only(top: 36), padding: const EdgeInsets.only(top: 36),
child: Column( child: Column(
children: [ children: [
// Main content area
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Left panel — News Expanded(
Expanded(flex: 3, child: _NewsPanel(cs: cs)), flex: 3,
// Right panel — Status & Controls child: _NewsPanel(model: model, cs: cs),
),
SizedBox( SizedBox(
width: 320, width: 320,
child: _RightPanel(model: model, cs: cs), child: _RightPanel(model: model, cs: cs),
@@ -34,7 +35,6 @@ class HomeScreenContent extends StatelessWidget {
], ],
), ),
), ),
// Bottom bar
_BottomBar(model: model, cs: cs), _BottomBar(model: model, cs: cs),
], ],
), ),
@@ -42,11 +42,10 @@ class HomeScreenContent extends StatelessWidget {
} }
} }
// ---------------------------------------------------------------------------
// News panel (left side)
// ---------------------------------------------------------------------------
class _NewsPanel extends StatelessWidget { class _NewsPanel extends StatelessWidget {
const _NewsPanel({required this.cs}); const _NewsPanel({required this.model, required this.cs});
final HomeScreenModel model;
final ColorScheme cs; final ColorScheme cs;
@override @override
@@ -56,7 +55,6 @@ class _NewsPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Logo
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -68,13 +66,12 @@ class _NewsPanel extends StatelessWidget {
), ),
), ),
Text( Text(
'Новости', '\u041d\u043e\u0432\u043e\u0441\u0442\u0438',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(color: cs.onSurface), ).textTheme.titleLarge?.copyWith(color: cs.onSurface),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// News list
Expanded( Expanded(
child: ShaderMask( child: ShaderMask(
shaderCallback: (bounds) => LinearGradient( shaderCallback: (bounds) => LinearGradient(
@@ -84,35 +81,80 @@ class _NewsPanel extends StatelessWidget {
stops: const [0.0, 0.85, 1.0], stops: const [0.0, 0.85, 1.0],
).createShader(bounds), ).createShader(bounds),
blendMode: BlendMode.dstIn, blendMode: BlendMode.dstIn,
child: ListView( child: _buildNewsBody(context),
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: const [
_NewsCard(
title: 'Добро пожаловать в MoonWell!',
description:
'Мы рады приветствовать вас на нашем сервере. '
'Установите клиент, авторизуйтесь и окунитесь '
'в мир приключений!',
),
SizedBox(height: 10),
_NewsCard(
title: 'Обновление клиента',
description:
'Лаунчер автоматически проверит и загрузит '
'последние обновления игрового клиента при входе.',
),
],
),
), ),
), ),
], ],
), ),
); );
} }
Widget _buildNewsBody(BuildContext context) {
if (model.isLoadingNews) {
return Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.2, color: cs.primary),
),
);
} }
class _NewsCard extends StatelessWidget { if (model.newsItems.isEmpty && model.newsErrorMessage != null) {
const _NewsCard({required this.title, required this.description}); 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!,
),
],
);
}
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]);
},
);
}
}
class _NewsStatusCard extends StatelessWidget {
const _NewsStatusCard({
required this.icon,
required this.title,
required this.description,
});
final IconData icon;
final String title; final String title;
final String description; final String description;
@@ -130,6 +172,8 @@ class _NewsCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, color: cs.tertiary),
const SizedBox(height: 12),
Text( Text(
title, title,
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -151,9 +195,84 @@ class _NewsCard extends StatelessWidget {
} }
} }
// --------------------------------------------------------------------------- class _NewsCard extends StatelessWidget {
// Right panel (status & controls) 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,
),
),
],
),
);
}
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 { class _RightPanel extends StatelessWidget {
const _RightPanel({required this.model, required this.cs}); const _RightPanel({required this.model, required this.cs});
@@ -183,7 +302,7 @@ class _RightPanel extends StatelessWidget {
HomeScreenLogoutRequested(), HomeScreenLogoutRequested(),
), ),
icon: const Icon(Icons.logout_rounded, size: 16), icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('Выйти'), label: const Text('\u0412\u044b\u0439\u0442\u0438'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: cs.onSurfaceVariant, foregroundColor: cs.onSurfaceVariant,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -196,7 +315,6 @@ class _RightPanel extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Spacer(), const Spacer(),
// Folder selector
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => context.read<HomeScreenBloc>().add( onTap: () => context.read<HomeScreenBloc>().add(
@@ -215,7 +333,9 @@ class _RightPanel extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
model.outputPath?.toFilePath() ?? 'Выбрать папку', model.outputPath?.toFilePath() ??
'\u0412\u044b\u0431\u0440\u0430\u0442\u044c '
'\u043f\u0430\u043f\u043a\u0443',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
@@ -233,7 +353,6 @@ class _RightPanel extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Status
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -263,7 +382,8 @@ class _RightPanel extends StatelessWidget {
if (model.totalFiles > 0) ...[ if (model.totalFiles > 0) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Файлы: ${model.processedFiles}/${model.totalFiles}', '\u0424\u0430\u0439\u043b\u044b: '
'${model.processedFiles}/${model.totalFiles}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
), ),
], ],
@@ -278,12 +398,12 @@ class _RightPanel extends StatelessWidget {
), ),
), ),
const Spacer(), const Spacer(),
// Build hashes
if (model.remoteBuildHash != null || model.localBuildHash != null) if (model.remoteBuildHash != null || model.localBuildHash != null)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Text(
'Build: ${_shortHash(model.localBuildHash)} / ${_shortHash(model.remoteBuildHash)}', 'Build: ${_shortHash(model.localBuildHash)} / '
'${_shortHash(model.remoteBuildHash)}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
), ),
@@ -294,15 +414,18 @@ class _RightPanel extends StatelessWidget {
} }
String _shortHash(String? hash) { String _shortHash(String? hash) {
if (hash == null || hash.isEmpty) return ''; if (hash == null || hash.isEmpty) {
if (hash.length <= 12) return hash; return '-';
}
if (hash.length <= 12) {
return hash;
}
return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}'; return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
} }
} }
// ---------------------------------------------------------------------------
// Bottom bar (play button, progress, version)
// ---------------------------------------------------------------------------
class _BottomBar extends StatelessWidget { class _BottomBar extends StatelessWidget {
const _BottomBar({required this.model, required this.cs}); const _BottomBar({required this.model, required this.cs});
@@ -327,7 +450,6 @@ class _BottomBar extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Progress bar (only when syncing)
if (model.isSyncing) ...[ if (model.isSyncing) ...[
Row( Row(
children: [ children: [
@@ -350,10 +472,8 @@ class _BottomBar extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
// Bottom row: play + status
Row( Row(
children: [ children: [
// Play button
SizedBox( SizedBox(
height: 44, height: 44,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -379,7 +499,6 @@ class _BottomBar extends StatelessWidget {
), ),
], ],
const SizedBox(width: 16), const SizedBox(width: 16),
// Status ticker
Expanded( Expanded(
child: Container( child: Container(
height: 38, height: 38,
@@ -436,36 +555,64 @@ class _BottomBar extends StatelessWidget {
} }
String _resolvePrimaryLabel(HomeScreenModel model) { String _resolvePrimaryLabel(HomeScreenModel model) {
if (model.isSyncing) return 'Обновление...'; if (model.isSyncing) {
if (model.canPlay) return 'Играть'; return '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435...';
return model.hasClientExecutable ? 'Обновить' : 'Установить'; }
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) { IconData _resolvePrimaryIcon(HomeScreenModel model) {
if (model.canPlay) return Icons.play_arrow_rounded; if (model.canPlay) {
return Icons.play_arrow_rounded;
}
return Icons.download_rounded; return Icons.download_rounded;
} }
String _resolveSecondaryLabel(HomeScreenModel model) { String _resolveSecondaryLabel(HomeScreenModel model) {
if (model.isSyncing) return 'Пауза'; if (model.isSyncing) {
if (model.canPlay) return 'Проверить'; return '\u041f\u0430\u0443\u0437\u0430';
return 'Папка'; }
if (model.canPlay) {
return '\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c';
}
return '\u041f\u0430\u043f\u043a\u0430';
} }
IconData _resolveSecondaryIcon(HomeScreenModel model) { IconData _resolveSecondaryIcon(HomeScreenModel model) {
if (model.isSyncing) return Pixel.pause; if (model.isSyncing) {
if (model.canPlay) return Icons.refresh_rounded; return Pixel.pause;
}
if (model.canPlay) {
return Icons.refresh_rounded;
}
return Pixel.folder; return Pixel.folder;
} }
String _formatSpeed(int bytesPerSecond) { String _formatSpeed(int bytesPerSecond) {
if (bytesPerSecond <= 0) return ''; if (bytesPerSecond <= 0) {
return '-';
}
if (bytesPerSecond >= 1024 * 1024) { if (bytesPerSecond >= 1024 * 1024) {
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s'; return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} }
if (bytesPerSecond >= 1024) { if (bytesPerSecond >= 1024) {
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
} }
return '$bytesPerSecond B/s'; return '$bytesPerSecond B/s';
} }
} }
@@ -10,6 +10,7 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
@lazySingleton @lazySingleton
@@ -83,6 +84,44 @@ class LauncherApiClient {
} }
} }
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async {
final newsUri = _resolveUri('api/launcher/news');
try {
final response = await _dio.getUri(
newsUri,
options: _authorizedOptions(accessToken),
);
final payload = _coerceMap(response.data);
final rawItems = payload['data'];
if (rawItems is! List) {
throw const LauncherApiException('Unexpected launcher news payload.');
}
return rawItems
.whereType<Map>()
.map(
(item) => LauncherNewsItem.fromJson(
item.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false);
} on DioException catch (error, stackTrace) {
await _launcherLogService.error(
'Launcher news request failed.',
details: <String, Object?>{
'endpoint': newsUri.toString(),
'statusCode': error.response?.statusCode,
'dioType': error.type.name,
'responseBody': _summarizePayload(error.response?.data),
},
error: error,
stackTrace: stackTrace,
);
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<void> downloadFile({ Future<void> downloadFile({
required String installationDir, required String installationDir,
required String accessToken, required String accessToken,
@@ -0,0 +1,41 @@
final class LauncherNewsItem {
final int id;
final String title;
final String body;
final String? imageUrl;
final DateTime? createdAt;
const LauncherNewsItem({
required this.id,
required this.title,
required this.body,
required this.imageUrl,
required this.createdAt,
});
factory LauncherNewsItem.fromJson(Map<String, dynamic> json) {
return LauncherNewsItem(
id: (json['id'] as num? ?? 0).toInt(),
title: (json['title'] as String? ?? '').trim(),
body: (json['body'] as String? ?? '').trim(),
imageUrl: _parseImageUrl(json['image_url'] as String?),
createdAt: _parseDateTime(json['created_at'] as String?),
);
}
static String? _parseImageUrl(String? rawValue) {
if (rawValue == null || rawValue.trim().isEmpty) {
return null;
}
return rawValue.trim();
}
static DateTime? _parseDateTime(String? rawValue) {
if (rawValue == null || rawValue.trim().isEmpty) {
return null;
}
return DateTime.tryParse(rawValue);
}
}
@@ -0,0 +1,107 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.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';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
void main() {
group('HomeScreenBloc', () {
test('loads launcher news on startup', () async {
final bloc = HomeScreenBloc(
clientSyncUseCase: _IdleClientSyncUseCase(),
gameInstallationService: _FakeGameInstallationService(),
launcherApiClient: _FakeLauncherApiClient(
newsItems: const [
LauncherNewsItem(
id: 1,
title: 'Update 1.2',
body: 'News body',
imageUrl: null,
createdAt: null,
),
],
),
preferencesRepository: _FakePreferencesRepository(),
session: LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: null,
),
manifest: ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
}),
);
await pumpEventQueue(times: 20);
expect(bloc.state.model.isLoadingNews, isFalse);
expect(bloc.state.model.newsErrorMessage, isNull);
expect(bloc.state.model.newsItems, hasLength(1));
expect(bloc.state.model.newsItems.first.title, 'Update 1.2');
await bloc.close();
});
});
}
class _IdleClientSyncUseCase extends ClientSyncUseCase {
_IdleClientSyncUseCase()
: super(
launcherApiClient: LauncherApiClient(),
installationService: GameInstallationService(),
);
@override
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
return const Stream<ClientSyncStatus>.empty();
}
}
class _FakeLauncherApiClient extends LauncherApiClient {
_FakeLauncherApiClient({required this.newsItems});
final List<LauncherNewsItem> newsItems;
@override
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
newsItems;
}
class _FakeGameInstallationService extends GameInstallationService {
@override
Future<bool> hasClientExecutable(String installationDir) async => false;
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
return ClientInstallationSnapshot.fromFiles(const []);
}
}
class _FakePreferencesRepository implements PreferencesRepository {
@override
Future<void> clearLauncherSession() async {}
@override
Future<LauncherSession?> getLauncherSession() async => null;
@override
Future<Uri?> getOutputDir() async => null;
@override
Future<void> setLauncherSession(LauncherSession session) async {}
@override
Future<void> setOutputDir(Uri uri) async {}
}
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
void main() {
group('LauncherNewsItem', () {
test('parses news payload with optional image and createdAt', () {
final item = LauncherNewsItem.fromJson({
'id': 42,
'title': 'Update 1.2',
'body': 'New raid is now available.',
'image_url': 'https://moon-well.online/news/update.jpg',
'created_at': '2026-03-22T10:30:00Z',
});
expect(item.id, 42);
expect(item.title, 'Update 1.2');
expect(item.body, 'New raid is now available.');
expect(item.imageUrl, 'https://moon-well.online/news/update.jpg');
expect(
item.createdAt?.toUtc().toIso8601String(),
'2026-03-22T10:30:00.000Z',
);
});
test('treats empty image and date as null', () {
final item = LauncherNewsItem.fromJson({
'id': 1,
'title': 'Hotfix',
'body': 'Minor fixes.',
'image_url': '',
'created_at': '',
});
expect(item.imageUrl, isNull);
expect(item.createdAt, isNull);
});
});
}