From 48ff9b63d8ddd62831480fc0f30342daf790ee1e Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 22 Mar 2026 17:36:24 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/launcher_web_api_spec.md | 8 + .../home_screen/bloc/home_screen_bloc.dart | 34 ++- .../home_screen/bloc/home_screen_model.dart | 20 +- lib/app/home_screen/home_screen.dart | 2 + lib/app/home_screen/home_screen_content.dart | 271 ++++++++++++++---- .../launcher/data/launcher_api_client.dart | 39 +++ .../domain/entities/launcher_news_item.dart | 41 +++ .../bloc/home_screen_bloc_test.dart | 107 +++++++ .../entities/launcher_news_item_test.dart | 38 +++ 9 files changed, 495 insertions(+), 65 deletions(-) create mode 100644 lib/features/launcher/domain/entities/launcher_news_item.dart create mode 100644 test/app/home_screen/bloc/home_screen_bloc_test.dart create mode 100644 test/features/launcher/domain/entities/launcher_news_item_test.dart diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index 3b1d7ec..09970ae 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -31,6 +31,7 @@ The launcher uses these endpoints from `openapi.json`: 1. `POST /api/launcher/login` 2. `GET /api/launcher/manifest` 3. `GET /api/launcher/download/{path}` +4. `GET /api/launcher/news` Authentication sequence: @@ -58,6 +59,13 @@ File download sequence: 6. Downloaded file is verified against manifest `sha256`. 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 The user selects a single installation root directory. diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart index 2f419fa..3bafb06 100644 --- a/lib/app/home_screen/bloc/home_screen_bloc.dart +++ b/lib/app/home_screen/bloc/home_screen_bloc.dart @@ -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/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_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; @@ -21,11 +22,13 @@ class HomeScreenBloc extends Bloc { HomeScreenBloc({ required ClientSyncUseCase clientSyncUseCase, required GameInstallationService gameInstallationService, + required LauncherApiClient launcherApiClient, required PreferencesRepository preferencesRepository, required LauncherSession session, required ClientManifest manifest, }) : _clientSyncUseCase = clientSyncUseCase, _gameInstallationService = gameInstallationService, + _launcherApiClient = launcherApiClient, _preferencesRepository = preferencesRepository, _session = session, _manifest = manifest, @@ -51,6 +54,7 @@ class HomeScreenBloc extends Bloc { final ClientSyncUseCase _clientSyncUseCase; final GameInstallationService _gameInstallationService; + final LauncherApiClient _launcherApiClient; final PreferencesRepository _preferencesRepository; StreamSubscription? _syncSubscription; @@ -79,6 +83,30 @@ class HomeScreenBloc extends Bloc { ), ), ); + + 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 _onHomeScreenSyncRequested( @@ -238,14 +266,16 @@ class HomeScreenBloc extends Bloc { phase: HomeScreenPhase.idle, isAuthenticated: false, progress: const DownloadProgress.initial(), - statusText: - 'Сессия завершена. Войдите снова.', + statusText: 'Сессия завершена. Войдите снова.', currentPath: null, errorMessage: null, localBuildHash: null, remoteBuildHash: null, processedFiles: 0, totalFiles: 0, + newsItems: const [], + isLoadingNews: false, + newsErrorMessage: null, ), ), ); diff --git a/lib/app/home_screen/bloc/home_screen_model.dart b/lib/app/home_screen/bloc/home_screen_model.dart index 5a9807e..638c5af 100644 --- a/lib/app/home_screen/bloc/home_screen_model.dart +++ b/lib/app/home_screen/bloc/home_screen_model.dart @@ -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/launcher_news_item.dart'; enum HomeScreenPhase { idle, @@ -27,6 +28,9 @@ final class HomeScreenModel { final String? remoteBuildHash; final int processedFiles; final int totalFiles; + final List newsItems; + final bool isLoadingNews; + final String? newsErrorMessage; const HomeScreenModel({ required this.progress, @@ -41,6 +45,9 @@ final class HomeScreenModel { required this.remoteBuildHash, required this.processedFiles, required this.totalFiles, + required this.newsItems, + required this.isLoadingNews, + required this.newsErrorMessage, }); const HomeScreenModel.initial() @@ -55,7 +62,10 @@ final class HomeScreenModel { localBuildHash = null, remoteBuildHash = null, processedFiles = 0, - totalFiles = 0; + totalFiles = 0, + newsItems = const [], + isLoadingNews = true, + newsErrorMessage = null; bool get isBusy => phase == HomeScreenPhase.authenticating || @@ -78,6 +88,9 @@ final class HomeScreenModel { Object? remoteBuildHash = _sentinel, int? processedFiles, int? totalFiles, + List? newsItems, + bool? isLoadingNews, + Object? newsErrorMessage = _sentinel, }) { return HomeScreenModel( progress: progress ?? this.progress, @@ -102,6 +115,11 @@ final class HomeScreenModel { : remoteBuildHash as String?, processedFiles: processedFiles ?? this.processedFiles, totalFiles: totalFiles ?? this.totalFiles, + newsItems: newsItems ?? this.newsItems, + isLoadingNews: isLoadingNews ?? this.isLoadingNews, + newsErrorMessage: newsErrorMessage == _sentinel + ? this.newsErrorMessage + : newsErrorMessage as String?, ); } } diff --git a/lib/app/home_screen/home_screen.dart b/lib/app/home_screen/home_screen.dart index 511cca7..d4f14ab 100644 --- a/lib/app/home_screen/home_screen.dart +++ b/lib/app/home_screen/home_screen.dart @@ -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/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_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; @@ -26,6 +27,7 @@ class HomeScreen extends StatelessWidget { create: (context) => HomeScreenBloc( clientSyncUseCase: getIt(), gameInstallationService: getIt(), + launcherApiClient: getIt(), preferencesRepository: getIt(), session: session, manifest: manifest, diff --git a/lib/app/home_screen/home_screen_content.dart b/lib/app/home_screen/home_screen_content.dart index ab75952..e19d55a 100644 --- a/lib/app/home_screen/home_screen_content.dart +++ b/lib/app/home_screen/home_screen_content.dart @@ -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/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'; class HomeScreenContent extends StatelessWidget { @@ -19,14 +20,14 @@ class HomeScreenContent extends StatelessWidget { padding: const EdgeInsets.only(top: 36), child: Column( children: [ - // Main content area Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Left panel — News - Expanded(flex: 3, child: _NewsPanel(cs: cs)), - // Right panel — Status & Controls + Expanded( + flex: 3, + child: _NewsPanel(model: model, cs: cs), + ), SizedBox( width: 320, child: _RightPanel(model: model, cs: cs), @@ -34,7 +35,6 @@ class HomeScreenContent extends StatelessWidget { ], ), ), - // Bottom bar _BottomBar(model: model, cs: cs), ], ), @@ -42,11 +42,10 @@ class HomeScreenContent extends StatelessWidget { } } -// --------------------------------------------------------------------------- -// News panel (left side) -// --------------------------------------------------------------------------- class _NewsPanel extends StatelessWidget { - const _NewsPanel({required this.cs}); + const _NewsPanel({required this.model, required this.cs}); + + final HomeScreenModel model; final ColorScheme cs; @override @@ -56,7 +55,6 @@ class _NewsPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Logo Center( child: Padding( padding: const EdgeInsets.only(bottom: 12), @@ -68,13 +66,12 @@ class _NewsPanel extends StatelessWidget { ), ), Text( - 'Новости', + '\u041d\u043e\u0432\u043e\u0441\u0442\u0438', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(color: cs.onSurface), ), const SizedBox(height: 12), - // News list Expanded( child: ShaderMask( shaderCallback: (bounds) => LinearGradient( @@ -84,35 +81,80 @@ class _NewsPanel extends StatelessWidget { stops: const [0.0, 0.85, 1.0], ).createShader(bounds), blendMode: BlendMode.dstIn, - child: ListView( - padding: const EdgeInsets.only(right: 16, bottom: 24), - children: const [ - _NewsCard( - title: 'Добро пожаловать в MoonWell!', - description: - 'Мы рады приветствовать вас на нашем сервере. ' - 'Установите клиент, авторизуйтесь и окунитесь ' - 'в мир приключений!', - ), - SizedBox(height: 10), - _NewsCard( - title: 'Обновление клиента', - description: - 'Лаунчер автоматически проверит и загрузит ' - 'последние обновления игрового клиента при входе.', - ), - ], - ), + child: _buildNewsBody(context), ), ), ], ), ); } + + 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!, + ), + ], + ); + } + + 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 _NewsCard extends StatelessWidget { - const _NewsCard({required this.title, required this.description}); +class _NewsStatusCard extends StatelessWidget { + const _NewsStatusCard({ + required this.icon, + required this.title, + required this.description, + }); + + final IconData icon; final String title; final String description; @@ -130,6 +172,8 @@ class _NewsCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, color: cs.tertiary), + const SizedBox(height: 12), Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -151,9 +195,84 @@ class _NewsCard extends StatelessWidget { } } -// --------------------------------------------------------------------------- -// Right panel (status & controls) -// --------------------------------------------------------------------------- +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, + ), + ), + ], + ), + ); + } + + 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}); @@ -183,7 +302,7 @@ class _RightPanel extends StatelessWidget { HomeScreenLogoutRequested(), ), icon: const Icon(Icons.logout_rounded, size: 16), - label: const Text('Выйти'), + label: const Text('\u0412\u044b\u0439\u0442\u0438'), style: TextButton.styleFrom( foregroundColor: cs.onSurfaceVariant, padding: const EdgeInsets.symmetric( @@ -196,7 +315,6 @@ class _RightPanel extends StatelessWidget { ), const SizedBox(height: 12), const Spacer(), - // Folder selector GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.read().add( @@ -215,7 +333,9 @@ class _RightPanel extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - model.outputPath?.toFilePath() ?? 'Выбрать папку', + model.outputPath?.toFilePath() ?? + '\u0412\u044b\u0431\u0440\u0430\u0442\u044c ' + '\u043f\u0430\u043f\u043a\u0443', overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurfaceVariant, @@ -233,7 +353,6 @@ class _RightPanel extends StatelessWidget { ), ), const SizedBox(height: 16), - // Status Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -263,7 +382,8 @@ class _RightPanel extends StatelessWidget { if (model.totalFiles > 0) ...[ const SizedBox(height: 6), Text( - 'Файлы: ${model.processedFiles}/${model.totalFiles}', + '\u0424\u0430\u0439\u043b\u044b: ' + '${model.processedFiles}/${model.totalFiles}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), ), ], @@ -278,12 +398,12 @@ class _RightPanel extends StatelessWidget { ), ), const Spacer(), - // Build hashes if (model.remoteBuildHash != null || model.localBuildHash != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( - 'Build: ${_shortHash(model.localBuildHash)} / ${_shortHash(model.remoteBuildHash)}', + 'Build: ${_shortHash(model.localBuildHash)} / ' + '${_shortHash(model.remoteBuildHash)}', textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), ), @@ -294,15 +414,18 @@ class _RightPanel extends StatelessWidget { } String _shortHash(String? hash) { - if (hash == null || hash.isEmpty) return '—'; - if (hash.length <= 12) return hash; + if (hash == null || hash.isEmpty) { + return '-'; + } + + if (hash.length <= 12) { + return hash; + } + return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}'; } } -// --------------------------------------------------------------------------- -// Bottom bar (play button, progress, version) -// --------------------------------------------------------------------------- class _BottomBar extends StatelessWidget { const _BottomBar({required this.model, required this.cs}); @@ -327,7 +450,6 @@ class _BottomBar extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Progress bar (only when syncing) if (model.isSyncing) ...[ Row( children: [ @@ -350,10 +472,8 @@ class _BottomBar extends StatelessWidget { ), const SizedBox(height: 8), ], - // Bottom row: play + status Row( children: [ - // Play button SizedBox( height: 44, child: ElevatedButton.icon( @@ -379,7 +499,6 @@ class _BottomBar extends StatelessWidget { ), ], const SizedBox(width: 16), - // Status ticker Expanded( child: Container( height: 38, @@ -436,36 +555,64 @@ class _BottomBar extends StatelessWidget { } String _resolvePrimaryLabel(HomeScreenModel model) { - if (model.isSyncing) return 'Обновление...'; - if (model.canPlay) return 'Играть'; - return model.hasClientExecutable ? 'Обновить' : 'Установить'; + 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; + if (model.canPlay) { + return Icons.play_arrow_rounded; + } + return Icons.download_rounded; } String _resolveSecondaryLabel(HomeScreenModel model) { - if (model.isSyncing) return 'Пауза'; - if (model.canPlay) return 'Проверить'; - return 'Папка'; + 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; + if (model.isSyncing) { + return Pixel.pause; + } + + if (model.canPlay) { + return Icons.refresh_rounded; + } + return Pixel.folder; } String _formatSpeed(int bytesPerSecond) { - if (bytesPerSecond <= 0) return '—'; + if (bytesPerSecond <= 0) { + return '-'; + } + if (bytesPerSecond >= 1024 * 1024) { return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s'; } + if (bytesPerSecond >= 1024) { return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; } + return '$bytesPerSecond B/s'; } } diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart index 0b793d9..f7a379d 100644 --- a/lib/features/launcher/data/launcher_api_client.dart +++ b/lib/features/launcher/data/launcher_api_client.dart @@ -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_file.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'; @lazySingleton @@ -83,6 +84,44 @@ class LauncherApiClient { } } + Future> 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( + (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: { + '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 downloadFile({ required String installationDir, required String accessToken, diff --git a/lib/features/launcher/domain/entities/launcher_news_item.dart b/lib/features/launcher/domain/entities/launcher_news_item.dart new file mode 100644 index 0000000..028ba09 --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_news_item.dart @@ -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 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); + } +} diff --git a/test/app/home_screen/bloc/home_screen_bloc_test.dart b/test/app/home_screen/bloc/home_screen_bloc_test.dart new file mode 100644 index 0000000..be0aef9 --- /dev/null +++ b/test/app/home_screen/bloc/home_screen_bloc_test.dart @@ -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({ + 'files': >[], + }), + ); + + 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 call(ClientSyncUseCaseInput input) { + return const Stream.empty(); + } +} + +class _FakeLauncherApiClient extends LauncherApiClient { + _FakeLauncherApiClient({required this.newsItems}); + + final List newsItems; + + @override + Future> fetchNews(String accessToken) async => + newsItems; +} + +class _FakeGameInstallationService extends GameInstallationService { + @override + Future hasClientExecutable(String installationDir) async => false; + + @override + Future scanInstallation( + String installationDir, { + InstallationScanProgressCallback? onProgress, + FutureOr Function()? isCancelled, + }) async { + return ClientInstallationSnapshot.fromFiles(const []); + } +} + +class _FakePreferencesRepository implements PreferencesRepository { + @override + Future clearLauncherSession() async {} + + @override + Future getLauncherSession() async => null; + + @override + Future getOutputDir() async => null; + + @override + Future setLauncherSession(LauncherSession session) async {} + + @override + Future setOutputDir(Uri uri) async {} +} diff --git a/test/features/launcher/domain/entities/launcher_news_item_test.dart b/test/features/launcher/domain/entities/launcher_news_item_test.dart new file mode 100644 index 0000000..769563d --- /dev/null +++ b/test/features/launcher/domain/entities/launcher_news_item_test.dart @@ -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); + }); + }); +}