From 0159aff93ee8e683898d25a01b05fecbec3f0bc1 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 7 Dec 2025 03:09:50 +0300 Subject: [PATCH] fixes --- .../lib/api/v2/packs_api_v2.dart | 143 ++++ .../lib/api/v2/packs_api_v2.g.dart | 10 + mnemo_cards_backend/public/open_api.yaml | 257 ++++---- .../lib/mnemo_cards_common.dart | 1 + mnemo_cards_common_backend/lib/src/isar.dart | 1 + .../lib/src/models/export.dart | 1 + .../lib/src/models/game_card_model.dart | 2 + .../lib/src/models/game_card_model.g.dart | 70 +- .../lib/domain/config/api_config_v2.dart | 15 + .../domain/services/game_session_manager.dart | 24 + .../domain/services/http_repository_v2.dart | 28 + .../lib/domain/state/tests_state_manager.dart | 19 +- .../presentation/pages/game/game_page.dart | 581 ++++++++++------- .../presentation/pages/test/test_page.dart | 611 ++---------------- .../lib/presentation/widgets/card_viewer.dart | 213 ++++++ mnemo_cards_web_v2/pubspec.lock | 56 ++ mnemo_cards_web_v2/pubspec.yaml | 1 + .../pages/game/game_page_test.dart | 220 ++++++- .../pages/test/test_page_test.dart | 117 ++++ 19 files changed, 1422 insertions(+), 948 deletions(-) diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart index 9845209..c9c58cc 100644 --- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/api/authorize/acl_types.dart'; @@ -7,6 +8,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart'; import 'package:mnemo_cards_backend/main.dart' as backend_main; import 'package:mnemo_cards_backend/packs/card_model_extension.dart'; import 'package:mnemo_cards_backend/packs/pack_manager.dart'; +import 'package:mnemo_cards_backend/packs/voice_model_extension.dart'; import 'package:mnemo_cards_backend/tests/test_manager.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; @@ -442,6 +444,147 @@ class PacksApiV2 { } } + /// GET /api/v2/packs/{packId}/cards/{cardId}/voices + /// Get card voices metadata + /// Returns JSON list of VoiceDto + @Route.get('/packs//cards//voices') + @OpenApiRoute() + Future getCardVoices( + Request request, + String packId, + String cardId, + ) async { + try { + final cardIdInt = int.tryParse(cardId); + if (cardIdInt == null) { + return _badRequest('Invalid card ID'); + } + + final packIdInt = int.tryParse(packId); + if (packIdInt == null) { + return _badRequest('Invalid pack ID'); + } + + final packModel = await backend_main.isar.cardPackModels.get(packIdInt); + if (packModel == null || !packModel.enabled) { + return _notFound('Pack not found or not enabled'); + } + + final card = await backend_main.isar.gameCardModels.get(cardIdInt); + if (card == null) { + return _notFound('Card not found'); + } + + await card.packs.load(); + final cardPacks = card.packs.toList(); + final belongsToPack = cardPacks.any((p) => p.id == packIdInt); + if (!belongsToPack) { + return _notFound('Card does not belong to this pack'); + } + + await card.voices.load(); + final voices = card.voices.toList(); + if (voices.isEmpty) { + return _ok({'items': >[]}); + } + + final items = voices + .where((voice) => voice.id != null) + .map( + (voice) => voice + .toDto(url: '/api/v2/voice/${voice.id}') + .toJson(), + ) + .toList(); + + return _ok({'items': items}); + } catch (e, s) { + print('Error fetching card voices: $e\n$s'); + return _internalServerError('Error loading voices'); + } + } + + /// GET /api/v2/voice/{voiceId} + /// Returns audio/mp3 bytes for the voice file + @Route.get('/voice/') + @OpenApiRoute() + Future getVoiceFile( + Request request, + String voiceId, + ) async { + try { + final voiceIdInt = int.tryParse(voiceId); + if (voiceIdInt == null) { + return _badRequest('Invalid voice ID'); + } + + final voice = await backend_main.isar.voiceModels.get(voiceIdInt); + if (voice == null || voice.path.isEmpty) { + return _notFound('Voice not found'); + } + + await voice.cards.load(); + final cards = voice.cards.toList(); + if (cards.isEmpty) { + return _notFound('Voice not attached to any card'); + } + + final hasEnabledPack = await _hasEnabledPack(cards); + if (!hasEnabledPack) { + return _notFound('Voice not available'); + } + + final sanitizedPath = _sanitizeVoicePath(voice.path); + if (sanitizedPath == null) { + return _badRequest('Invalid voice path'); + } + + final file = File( + '${PackManager.assetsDirectory.path}/voice/$sanitizedPath', + ); + + if (!file.existsSync()) { + return _notFound('Voice file not found'); + } + + final bytes = await file.readAsBytes(); + + return Response.ok( + bytes, + headers: { + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'public, max-age=86400', + }, + ); + } catch (e, s) { + print('Error fetching voice file: $e\n$s'); + return _internalServerError('Error loading voice'); + } + } + + String? _sanitizeVoicePath(String path) { + final normalized = path.replaceAll('\\', '/').replaceFirst(RegExp('^/'), ''); + if (normalized.contains('..')) { + return null; + } + if (normalized.startsWith('voice/')) { + return normalized.substring('voice/'.length); + } + return normalized; + } + + Future _hasEnabledPack(List cards) async { + for (final card in cards) { + await card.packs.load(); + for (final pack in card.packs) { + if (pack.enabled) { + return true; + } + } + } + return false; + } + /// GET /api/v2/packs/{packId}/tests /// Get tests for a pack @Route.get('/packs//tests') diff --git a/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart index f75c04d..b7578d1 100644 --- a/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart +++ b/mnemo_cards_backend/lib/api/v2/packs_api_v2.g.dart @@ -33,6 +33,16 @@ Router _$PacksApiV2Router(PacksApiV2 service) { r'/packs//cards//image', service.getCardImage, ); + router.add( + 'GET', + r'/packs//cards//voices', + service.getCardVoices, + ); + router.add( + 'GET', + r'/voice/', + service.getVoiceFile, + ); router.add( 'GET', r'/packs//tests', diff --git a/mnemo_cards_backend/public/open_api.yaml b/mnemo_cards_backend/public/open_api.yaml index fffe1bc..8f88e2a 100644 --- a/mnemo_cards_backend/public/open_api.yaml +++ b/mnemo_cards_backend/public/open_api.yaml @@ -129,32 +129,6 @@ paths: responses: 200: description: "Operation completed!" - /ads/product/acquire/: - post: - tags: - - AdsApiV2 - summary: "POST /api/v2/ads/product/acquire/{key}" - description: Confirms rewarded ad completion and grants product access to the user. - operationId: acquireProductForAd - parameters: - - name: key - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /adsgram/reward: - get: - tags: - - AdsApiV2 - summary: "GET /api/v2/adsgram/reward?userId={userId}" - description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad. - operationId: adsgramRewardCallback - responses: - 200: - description: "Operation completed!" /admin/cards: get: tags: @@ -205,6 +179,32 @@ paths: responses: 200: description: "Operation completed!" + /ads/product/acquire/: + post: + tags: + - AdsApiV2 + summary: "POST /api/v2/ads/product/acquire/{key}" + description: Confirms rewarded ad completion and grants product access to the user. + operationId: acquireProductForAd + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /adsgram/reward: + get: + tags: + - AdsApiV2 + summary: "GET /api/v2/adsgram/reward?userId={userId}" + description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad. + operationId: adsgramRewardCallback + responses: + 200: + description: "Operation completed!" /subscriptions/plans: get: tags: @@ -520,6 +520,43 @@ paths: responses: 200: description: "Operation completed!" + /packs//cards//voices: + get: + tags: + - PacksApiV2 + summary: getCardVoices + description: "GET /api/v2/packs/{packId}/cards/{cardId}/voices\nGet card voices metadata\nReturns JSON list of VoiceDto" + operationId: getCardVoices + parameters: + - name: packId + in: path + required: true + schema: + type: string + - name: cardId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /voice/: + get: + tags: + - PacksApiV2 + summary: getVoiceFile + description: "GET /api/v2/voice/{voiceId}\nReturns audio/mp3 bytes for the voice file" + operationId: getVoiceFile + parameters: + - name: voiceId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" /packs//tests: get: tags: @@ -779,84 +816,6 @@ paths: responses: 200: description: "Operation completed!" - /tests/: - get: - tags: - - TestsApiV2 - summary: getTest - description: "GET /api/v2/tests/{testId}\nGet test details by ID" - operationId: getTest - parameters: - - name: testId - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /tests//results: - post: - tags: - - TestsApiV2 - summary: submitTestResults - description: "POST /api/v2/tests/{testId}/results\nSubmit test results" - operationId: submitTestResults - parameters: - - name: testId - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /tests//history: - get: - tags: - - TestsApiV2 - summary: getTestHistory - description: "GET /api/v2/tests/{testId}/history\nGet test attempt history for the authenticated user\nSupports pagination via query params: ?page=1&limit=20" - operationId: getTestHistory - parameters: - - name: testId - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /admin/analytics/dashboard: - get: - tags: - - AdminAnalyticsApiV2 - summary: getDashboardAnalytics - description: GET /api/v2/admin/analytics/dashboard\nGet dashboard analytics data - operationId: getDashboardAnalytics - responses: - 200: - description: "Operation completed!" - /admin/analytics/users/chart: - get: - tags: - - AdminAnalyticsApiV2 - summary: getUsersChart - description: GET /api/v2/admin/analytics/users/chart\nGet user registration chart data for the last 30 days - operationId: getUsersChart - responses: - 200: - description: "Operation completed!" - /admin/analytics/revenue/chart: - get: - tags: - - AdminAnalyticsApiV2 - summary: getRevenueChart - description: GET /api/v2/admin/analytics/revenue/chart\nGet revenue chart data for the last 30 days - operationId: getRevenueChart - responses: - 200: - description: "Operation completed!" /tasks: get: tags: @@ -929,16 +888,94 @@ paths: responses: 200: description: "Operation completed!" + /admin/analytics/dashboard: + get: + tags: + - AdminAnalyticsApiV2 + summary: getDashboardAnalytics + description: GET /api/v2/admin/analytics/dashboard\nGet dashboard analytics data + operationId: getDashboardAnalytics + responses: + 200: + description: "Operation completed!" + /admin/analytics/users/chart: + get: + tags: + - AdminAnalyticsApiV2 + summary: getUsersChart + description: GET /api/v2/admin/analytics/users/chart\nGet user registration chart data for the last 30 days + operationId: getUsersChart + responses: + 200: + description: "Operation completed!" + /admin/analytics/revenue/chart: + get: + tags: + - AdminAnalyticsApiV2 + summary: getRevenueChart + description: GET /api/v2/admin/analytics/revenue/chart\nGet revenue chart data for the last 30 days + operationId: getRevenueChart + responses: + 200: + description: "Operation completed!" + /tests/: + get: + tags: + - TestsApiV2 + summary: getTest + description: "GET /api/v2/tests/{testId}\nGet test details by ID" + operationId: getTest + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /tests//results: + post: + tags: + - TestsApiV2 + summary: submitTestResults + description: "POST /api/v2/tests/{testId}/results\nSubmit test results" + operationId: submitTestResults + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /tests//history: + get: + tags: + - TestsApiV2 + summary: getTestHistory + description: "GET /api/v2/tests/{testId}/history\nGet test attempt history for the authenticated user\nSupports pagination via query params: ?page=1&limit=20" + operationId: getTestHistory + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" components: { } tags: - name: PromocodesApiV2 description: API v2 endpoints for promocode management and activation. - name: DiscountsApiV2 description: Admin endpoints for discount campaign management. - - name: AdsApiV2 - description: API v2 endpoints for rewarded ads flows. - name: AdminCardsApiV2 description: Admin endpoints for card management in API v2. + - name: AdsApiV2 + description: API v2 endpoints for rewarded ads flows. - name: SubscriptionsApiV2 description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans" - name: UsersApiV2 @@ -957,9 +994,9 @@ tags: description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments - name: AuthApiV2 description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication - - name: TestsApiV2 - description: Tests API v2\n\nRESTful endpoints for managing tests and test results + - name: TasksApiV2 + description: API v2 endpoints for user tasks management - name: AdminAnalyticsApiV2 description: Admin endpoints for analytics and statistics in API v2. - - name: TasksApiV2 - description: API v2 endpoints for user tasks management \ No newline at end of file + - name: TestsApiV2 + description: Tests API v2\n\nRESTful endpoints for managing tests and test results \ No newline at end of file diff --git a/mnemo_cards_common/lib/mnemo_cards_common.dart b/mnemo_cards_common/lib/mnemo_cards_common.dart index ed8a2b3..c033df8 100644 --- a/mnemo_cards_common/lib/mnemo_cards_common.dart +++ b/mnemo_cards_common/lib/mnemo_cards_common.dart @@ -9,6 +9,7 @@ export 'src/dtos/packs/card_pack_preview_dto.dart'; export 'src/dtos/packs/pack_tip.dart'; export 'src/dtos/packs/get_card_pack_response.dart'; export 'src/dtos/game_card_dto.dart'; +export 'src/dtos/voice_dto.dart'; export 'src/dtos/payment/export.dart'; diff --git a/mnemo_cards_common_backend/lib/src/isar.dart b/mnemo_cards_common_backend/lib/src/isar.dart index 469be98..c6438cb 100644 --- a/mnemo_cards_common_backend/lib/src/isar.dart +++ b/mnemo_cards_common_backend/lib/src/isar.dart @@ -13,6 +13,7 @@ class IsarConnector { [ CardPackModelSchema, GameCardModelSchema, + VoiceModelSchema, UserModelSchema, UserSubscriptionModelSchema, SubscriptionPlanModelSchema, diff --git a/mnemo_cards_common_backend/lib/src/models/export.dart b/mnemo_cards_common_backend/lib/src/models/export.dart index 9834250..397f23c 100644 --- a/mnemo_cards_common_backend/lib/src/models/export.dart +++ b/mnemo_cards_common_backend/lib/src/models/export.dart @@ -1,5 +1,6 @@ export 'card_pack_model.dart'; export 'game_card_model.dart'; +export 'voice_model.dart'; export 'payment.dart'; export 'product_model.dart'; export 'refresh_token_model.dart'; diff --git a/mnemo_cards_common_backend/lib/src/models/game_card_model.dart b/mnemo_cards_common_backend/lib/src/models/game_card_model.dart index c1976aa..8d1098e 100644 --- a/mnemo_cards_common_backend/lib/src/models/game_card_model.dart +++ b/mnemo_cards_common_backend/lib/src/models/game_card_model.dart @@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:isar/isar.dart'; import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:mnemo_cards_common_backend/src/models/card_pack_model.dart'; +import 'package:mnemo_cards_common_backend/src/models/voice_model.dart'; part 'game_card_model.g.dart'; @@ -27,6 +28,7 @@ class GameCardModel { @JsonKey(name: 'back') final String? back; final IsarLinks packs = IsarLinks(); + final IsarLinks voices = IsarLinks(); GameCardModel({ required this.image, diff --git a/mnemo_cards_common_backend/lib/src/models/game_card_model.g.dart b/mnemo_cards_common_backend/lib/src/models/game_card_model.g.dart index 89fd142..7be8db7 100644 --- a/mnemo_cards_common_backend/lib/src/models/game_card_model.g.dart +++ b/mnemo_cards_common_backend/lib/src/models/game_card_model.g.dart @@ -215,6 +215,12 @@ const GameCardModelSchema = CollectionSchema( name: r'packs', target: r'CardPackModel', single: false, + ), + r'voices': LinkSchema( + id: 7850270866015854518, + name: r'voices', + target: r'VoiceModel', + single: false, ) }, embeddedSchemas: {}, @@ -330,13 +336,14 @@ Id _gameCardModelGetId(GameCardModel object) { } List> _gameCardModelGetLinks(GameCardModel object) { - return [object.packs]; + return [object.packs, object.voices]; } void _gameCardModelAttach( IsarCollection col, Id id, GameCardModel object) { object.id = id; object.packs.attach(col, col.isar.collection(), r'packs', id); + object.voices.attach(col, col.isar.collection(), r'voices', id); } extension GameCardModelQueryWhereSort @@ -1718,6 +1725,67 @@ extension GameCardModelQueryLinks r'packs', lower, includeLower, upper, includeUpper); }); } + + QueryBuilder voices( + FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.link(q, r'voices'); + }); + } + + QueryBuilder + voicesLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'voices', length, true, length, true); + }); + } + + QueryBuilder + voicesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'voices', 0, true, 0, true); + }); + } + + QueryBuilder + voicesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'voices', 0, false, 999999, true); + }); + } + + QueryBuilder + voicesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'voices', 0, true, length, include); + }); + } + + QueryBuilder + voicesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength(r'voices', length, include, 999999, true); + }); + } + + QueryBuilder + voicesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.linkLength( + r'voices', lower, includeLower, upper, includeUpper); + }); + } } extension GameCardModelQuerySortBy diff --git a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart index 7af8f17..da61d8f 100644 --- a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart @@ -129,11 +129,20 @@ class ApiConfigV2 { /// Get all cards in a pack static String packCards(String packId) => '/packs/$packId/cards'; + /// GET /api/v2/packs/{packId}/cards/{cardId}/voices + /// Get card voices metadata + static String packCardVoices(String packId, int cardId) => + '/packs/$packId/cards/$cardId/voices'; + /// GET /api/v2/packs/{packId}/card/{cardId}/image /// Get card image static String packCardImage(String packId, int cardId) => '/packs/$packId/cards/$cardId/image'; + /// GET /api/v2/voice/{voiceId} + /// Get voice file + static String voiceFile(int voiceId) => '/voice/$voiceId'; + /// GET /api/v2/packs/{packId}/tests /// Get tests for a pack static String packTests(String packId) => '/packs/$packId/tests'; @@ -220,6 +229,12 @@ class ApiConfigV2 { return '$baseUrl${packCardImage(packId, cardId)}'; } + /// Get voice file URL by id + /// Returns: http://baseUrl/api/v2/voice/{voiceId} + static String getVoiceFileUrl(int voiceId) { + return '$baseUrl${voiceFile(voiceId)}'; + } + /// Build query string from parameters /// Example: buildQuery({'search': 'food', 'page': 1}) => '?search=food&page=1' static String buildQuery(Map? params) { diff --git a/mnemo_cards_web_v2/lib/domain/services/game_session_manager.dart b/mnemo_cards_web_v2/lib/domain/services/game_session_manager.dart index 645bbf6..1f766f1 100644 --- a/mnemo_cards_web_v2/lib/domain/services/game_session_manager.dart +++ b/mnemo_cards_web_v2/lib/domain/services/game_session_manager.dart @@ -3,6 +3,8 @@ import 'dart:developer'; import '../models/game_question.dart'; +typedef DelayProvider = Duration Function(); + /// Manages active game session state and user interactions class GameSessionManager { GameSessionManager(); @@ -12,6 +14,7 @@ class GameSessionManager { Timer? _questionTimer; DateTime? _sessionStartTime; DateTime? _currentQuestionStartTime; + DelayProvider _delayProvider = () => const Duration(milliseconds: 1500); /// Current game session result GameSessionResult? get currentResult => _currentResult; @@ -19,6 +22,21 @@ class GameSessionManager { /// All question results for current session Map get questionResults => Map.unmodifiable(_questionResults); + /// Elapsed time since session start + Duration get sessionElapsed => _calculateSessionTime(); + + /// Elapsed time for the current question + Duration get currentQuestionElapsed { + if (_currentQuestionStartTime == null) return Duration.zero; + return DateTime.now().difference(_currentQuestionStartTime!); + } + + void configureDelays({DelayProvider? answerFeedbackDelay}) { + if (answerFeedbackDelay != null) { + _delayProvider = answerFeedbackDelay; + } + } + /// Start a new game session void startSession(String testId, List questions) { log('Starting game session for test: $testId', name: 'GameSessionManager'); @@ -37,6 +55,10 @@ class GameSessionManager { timeSpent: Duration.zero, ); } + + if (questions.isNotEmpty) { + startQuestionTimer(_getQuestionId(questions.first)); + } } /// Start timing for a specific question @@ -72,6 +94,8 @@ class GameSessionManager { _currentQuestionStartTime = null; } + Duration get feedbackDelay => _delayProvider(); + /// Complete the current game session GameSessionResult completeSession(String testId) { final totalTime = _calculateSessionTime(); diff --git a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart index bf4768a..ee32e6a 100644 --- a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart @@ -947,6 +947,34 @@ class HttpRepositoryV2 { } } + /// Get voices metadata for a specific card + Future> getCardVoices(String packId, int cardId) async { + try { + final response = await _dio.get>( + ApiConfigV2.packCardVoices(packId, cardId), + ); + final data = response.data; + if (data == null) { + return const []; + } + final items = data['items'] as List? ?? []; + return items + .map((item) => VoiceDto.fromJson(item as Map)) + .toList(); + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + return const []; + } + if (e.error is ApiException) { + rethrow; + } + throw NetworkException( + message: e.message ?? 'Network error', + originalError: e, + ); + } + } + /// Get pack purchase details (includes ad reward offers) Future getPackBuy(String packId) async { try { diff --git a/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart b/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart index 9da468c..ebd5872 100644 --- a/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart +++ b/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:flutter/services.dart'; import 'package:yx_state/yx_state.dart'; import '../models/game_question.dart'; @@ -160,15 +161,17 @@ class TestsStateManager extends StateManager { // Play sound based on answer correctness if (isCorrect) { await _gameSoundService.playCorrectAnswer(); + await HapticFeedback.lightImpact(); } else { await _gameSoundService.playWrongAnswer(); + await HapticFeedback.mediumImpact(); } // Update state with answer feedback emit(currentState.copyWith( isAnswerSubmitted: true, isCorrect: isCorrect, - answerFeedbackDelay: const Duration(milliseconds: 1500), + answerFeedbackDelay: _gameSessionManager.feedbackDelay, questionResults: _gameSessionManager.questionResults, )); @@ -241,6 +244,20 @@ class TestsStateManager extends StateManager { emit(const TestsState.loading()); }); + /// Resume existing active session without re-starting + void resumeGameSession() { + final currentState = state; + if (currentState is! _GameSessionActive) return; + + handle((emit) async { + final questionId = _getQuestionId( + currentState.questions[currentState.currentQuestionIndex], + ); + _gameSessionManager.startQuestionTimer(questionId); + emit(currentState.copyWith()); + }); + } + /// Check if can navigate to next question bool get canGoNext { final currentState = state; diff --git a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart index a13afab..9f28475 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart @@ -26,6 +26,7 @@ class GamePage extends StatefulWidget { }); final String testId; + static const questionCardKey = Key('game_question_card'); @override State createState() => _GamePageState(); @@ -33,6 +34,7 @@ class GamePage extends StatefulWidget { class _GamePageState extends State { GameSoundService? _soundService; + double _maxContentWidth = 880; @override void initState() { @@ -65,8 +67,22 @@ class _GamePageState extends State { } try { - await userScope.testsModule.testsStateManager.startGameSession(widget.testId); - await _soundService?.playGameStart(); + final state = userScope.testsModule.testsStateManager.state; + final isActive = state.maybeWhen( + gameSessionActive: (_, __, ___, ____, _____, ______, _______, ________) => true, + orElse: () => false, + ); + final isPreparing = state.maybeWhen( + gameSessionPreparing: (_, __) => true, + orElse: () => false, + ); + + if (isActive) { + userScope.testsModule.testsStateManager.resumeGameSession(); + } else if (!isPreparing) { + await userScope.testsModule.testsStateManager.startGameSession(widget.testId); + await _soundService?.playGameStart(); + } } catch (e, s) { log('Error starting game session', error: e, stackTrace: s, name: 'GamePage'); } @@ -115,55 +131,79 @@ class _GamePageState extends State { } Widget _buildBody(TestsState state) { - return state.when( - loading: () => const LoadingView(message: 'Loading game...'), - loaded: (tests, packId) => const Center(child: Text('Game loaded')), - error: (message) => ErrorView( - title: 'Game Error', - message: message, - onRetry: _startGame, + final appScope = ScopeProvider.of(context, listen: false); + final userScope = appScope?.userScopeHolder.scope; + final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero; + + return SafeArea( + child: state.when( + loading: () => const LoadingView(message: 'Loading game...'), + loaded: (tests, packId) => const Center(child: Text('Game loaded')), + error: (message) => ErrorView( + title: 'Game Error', + message: message, + onRetry: _startGame, + ), + gameSessionPreparing: (test, questions) => _buildPreparingView(test), + gameSessionActive: (test, questions, currentQuestionIndex, currentResult, questionResults, isAnswerSubmitted, isCorrect, answerFeedbackDelay) => + _buildActiveGame( + state, + test, + questions, + currentQuestionIndex, + isAnswerSubmitted, + isCorrect, + questionResults, + sessionElapsed, + ), + gameSessionCompleted: (test, result) => _buildCompletedView(test, result), ), - gameSessionPreparing: (test, questions) => _buildPreparingView(test), - gameSessionActive: (test, questions, currentQuestionIndex, currentResult, questionResults, isAnswerSubmitted, isCorrect, answerFeedbackDelay) => - _buildActiveGame(state, test, questions, currentQuestionIndex, isAnswerSubmitted, isCorrect, questionResults), - gameSessionCompleted: (test, result) => _buildCompletedView(test, result), ); } Widget _buildPreparingView(TestDto test) { - return Container( - padding: EdgeInsets.all(24.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.play_circle_fill, - size: 80.sp, - color: Theme.of(context).colorScheme.primary, + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: _maxContentWidth), + child: Padding( + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.play_circle_fill, + size: 84.sp, + color: colorScheme.primary, + ), + SizedBox(height: 28.h), + Text( + 'Ready to Start?', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + SizedBox(height: 16.h), + Text( + test.name, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + SizedBox(height: 32.h), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _startGameSession, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Game'), + style: ElevatedButton.styleFrom( + minimumSize: Size(220.w, 56.h), + textStyle: TextStyle(fontSize: 18.sp), + ), + ), + ), + ], ), - SizedBox(height: 24.h), - Text( - 'Ready to Start?', - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - SizedBox(height: 16.h), - Text( - test.name, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, - ), - SizedBox(height: 32.h), - ElevatedButton.icon( - onPressed: _startGameSession, - icon: const Icon(Icons.play_arrow), - label: const Text('Start Game'), - style: ElevatedButton.styleFrom( - minimumSize: Size(200.w, 56.h), - textStyle: TextStyle(fontSize: 18.sp), - ), - ), - ], + ), ), ); } @@ -176,113 +216,149 @@ class _GamePageState extends State { bool isAnswerSubmitted, bool isCorrect, Map questionResults, + Duration sessionElapsed, ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final currentQuestion = questions[currentQuestionIndex]; final questionKey = ValueKey('question_$currentQuestionIndex'); - return Column( - children: [ - // Progress indicator - Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - child: GameProgressIndicator( - currentQuestion: currentQuestionIndex, - totalQuestions: questions.length, - correctAnswers: questionResults.values.where((r) => r.isCorrect).length, - timeElapsed: const Duration(seconds: 0), // TODO: Track actual time - ), - ), + return LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 720; + final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth; - // Question content - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(16.w), + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: contentWidth), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Question display - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: Container( - key: questionKey, - padding: EdgeInsets.all(16.w), - margin: EdgeInsets.only(bottom: 24.h), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: QuestionDisplay(question: currentQuestion), - ), + // Progress indicator + Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: Row( + children: [ + Expanded( + child: GameProgressIndicator( + currentQuestion: currentQuestionIndex, + totalQuestions: questions.length, + correctAnswers: questionResults.values.where((r) => r.isCorrect).length, + timeElapsed: sessionElapsed, + ), + ), + SizedBox(width: isNarrow ? 12.w : 20.w), + Tooltip( + message: 'Exit game', + child: IconButton( + onPressed: () => _showExitConfirmation(context), + icon: const Icon(Icons.close), + ), + ), + ], + ), ), - // Answer input based on question type with smooth transitions - AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.1, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: Container( - key: ValueKey('question_input_${currentQuestion.hashCode}'), - child: currentQuestion.when( - multipleChoice: (q) => AnswerOptions( - question: q, - selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults), - onAnswerSelected: _onAnswerSelected, - isAnswerSubmitted: isAnswerSubmitted, - isCorrect: isCorrect, - ), - inputLetters: (q) => InputLettersWidget(question: q), - match: (q) => const Center(child: Text('Match questions coming soon!')), - matrix: (q) => const Center(child: Text('Matrix questions coming soon!')), + // Question content + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(isNarrow ? 12.w : 16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Question display + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: questionKey, + child: Material( + key: GamePage.questionCardKey, + color: colorScheme.surface, + surfaceTintColor: colorScheme.surfaceTint, + elevation: 3, + shadowColor: theme.shadowColor.withOpacity( + theme.brightness == Brightness.dark ? 0.35 : 0.14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.r), + side: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: EdgeInsets.all(isNarrow ? 14.w : 18.w), + child: QuestionDisplay(question: currentQuestion), + ), + ), + ), + ), + + SizedBox(height: isNarrow ? 16.h : 20.h), + + // Answer input based on question type with smooth transitions + AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.05, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Container( + key: ValueKey('question_input_${currentQuestion.hashCode}'), + child: currentQuestion.when( + multipleChoice: (q) => AnswerOptions( + question: q, + selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults), + onAnswerSelected: _onAnswerSelected, + isAnswerSubmitted: isAnswerSubmitted, + isCorrect: isCorrect, + ), + inputLetters: (q) => InputLettersWidget(question: q), + match: (q) => const Center(child: Text('Match questions coming soon!')), + matrix: (q) => const Center(child: Text('Matrix questions coming soon!')), + ), + ), + ), + + // Navigation buttons (only show for multiple choice after submission) + if (currentQuestion is GameQuestionMultipleChoice && isAnswerSubmitted) ...[ + SizedBox(height: isNarrow ? 18.h : 24.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_canGoPrevious(state)) ...[ + OutlinedButton.icon( + onPressed: _previousQuestion, + icon: const Icon(Icons.arrow_back), + label: const Text('Previous'), + ), + SizedBox(width: isNarrow ? 12.w : 16.w), + ], + ElevatedButton.icon( + onPressed: _nextOrFinish, + icon: Icon(_isLastQuestion(state) ? Icons.check : Icons.arrow_forward), + label: Text(_isLastQuestion(state) ? 'Finish' : 'Next'), + ), + ], + ), + ], + ], ), ), ), - - // Navigation buttons (only show for multiple choice after submission) - if (currentQuestion is GameQuestionMultipleChoice && isAnswerSubmitted) ...[ - SizedBox(height: 24.h), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_canGoPrevious(state)) ...[ - OutlinedButton.icon( - onPressed: _previousQuestion, - icon: const Icon(Icons.arrow_back), - label: const Text('Previous'), - ), - SizedBox(width: 16.w), - ], - ElevatedButton.icon( - onPressed: _nextOrFinish, - icon: Icon(_isLastQuestion(state) ? Icons.check : Icons.arrow_forward), - label: Text(_isLastQuestion(state) ? 'Finish' : 'Next'), - ), - ], - ), - ], ], ), ), - ), - ], + ); + }, ); } @@ -290,119 +366,130 @@ class _GamePageState extends State { final accuracy = result.totalQuestions > 0 ? (result.correctAnswers / result.totalQuestions * 100).round() : 0; + final colorScheme = Theme.of(context).colorScheme; + final scoreColor = _scoreColor(colorScheme, accuracy); - return Container( - padding: EdgeInsets.all(24.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Animated score circle - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 800), - curve: Curves.elasticOut, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: Container( - width: 120.w, - height: 120.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getScoreColor(accuracy).withOpacity(0.1 * value), - border: Border.all( - color: _getScoreColor(accuracy), - width: 4 * value, - ), - boxShadow: [ - BoxShadow( - color: _getScoreColor(accuracy).withOpacity(0.3 * value), - blurRadius: 20 * value, - spreadRadius: 5 * value, - ), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TweenAnimationBuilder( - tween: Tween(begin: 0, end: accuracy), - duration: const Duration(milliseconds: 1200), - builder: (context, animatedAccuracy, child) { - return Text( - '$animatedAccuracy%', - style: TextStyle( - fontSize: 32.sp, - fontWeight: FontWeight.bold, - color: _getScoreColor(accuracy), - ), - ); - }, - ), - SizedBox(height: 4.h), - FadeTransition( - opacity: Tween(begin: 0, end: 1).animate( - CurvedAnimation( - parent: ModalRoute.of(context)?.animation ?? const AlwaysStoppedAnimation(1), - curve: const Interval(0.5, 1.0, curve: Curves.easeIn), - ), - ), - child: Text( - 'Score', - style: TextStyle( - fontSize: 14.sp, - color: _getScoreColor(accuracy), - ), - ), - ), - ], - ), - ), - ); - }, - ), - - SizedBox(height: 32.h), - - // Results - Text( - 'Game Completed!', - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - SizedBox(height: 16.h), - Text( - '${result.correctAnswers}/${result.totalQuestions} correct answers', - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.center, - ), - SizedBox(height: 8.h), - Text( - 'Time: ${_formatDuration(result.totalTime)}', - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - - SizedBox(height: 48.h), - - // Action buttons - Row( + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: _maxContentWidth), + child: Padding( + padding: EdgeInsets.all(24.w), + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - OutlinedButton.icon( - onPressed: _restartGame, - icon: const Icon(Icons.refresh), - label: const Text('Play Again'), + // Animated score circle + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 800), + curve: Curves.elasticOut, + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Container( + width: 120.w, + height: 120.h, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scoreColor.withOpacity(0.1 * value), + border: Border.all( + color: scoreColor, + width: 4 * value, + ), + boxShadow: [ + BoxShadow( + color: scoreColor.withOpacity(0.3 * value), + blurRadius: 20 * value, + spreadRadius: 5 * value, + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0, end: accuracy), + duration: const Duration(milliseconds: 1200), + builder: (context, animatedAccuracy, child) { + return Text( + '$animatedAccuracy%', + style: TextStyle( + fontSize: 32.sp, + fontWeight: FontWeight.bold, + color: scoreColor, + ), + ); + }, + ), + SizedBox(height: 4.h), + FadeTransition( + opacity: Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: ModalRoute.of(context)?.animation ?? const AlwaysStoppedAnimation(1), + curve: const Interval(0.5, 1.0, curve: Curves.easeIn), + ), + ), + child: Text( + 'Score', + style: TextStyle( + fontSize: 14.sp, + color: scoreColor, + ), + ), + ), + ], + ), + ), + ); + }, ), - SizedBox(width: 16.w), - ElevatedButton.icon( - onPressed: () => context.pop(), - icon: const Icon(Icons.home), - label: const Text('Back to Tests'), + + SizedBox(height: 32.h), + + // Results + Text( + 'Game Completed!', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + SizedBox(height: 16.h), + Text( + '${result.correctAnswers}/${result.totalQuestions} correct answers', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + SizedBox(height: 8.h), + Text( + 'Time: ${_formatDuration(result.totalTime)}', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + + SizedBox(height: 48.h), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _restartGame, + icon: const Icon(Icons.refresh), + label: const Text('Play Again'), + ), + SizedBox(width: 16.w), + ElevatedButton.icon( + onPressed: () => context.pop(), + icon: const Icon(Icons.home), + label: const Text('Back to Tests'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + ), + ], ), ], ), - ], + ), ), ); } @@ -462,10 +549,10 @@ class _GamePageState extends State { return result?.selectedAnswer; } - Color _getScoreColor(int score) { - if (score >= 80) return Colors.green; - if (score >= 60) return Colors.orange; - return Colors.red; + Color _scoreColor(ColorScheme colorScheme, int score) { + if (score >= 80) return colorScheme.primary; + if (score >= 60) return colorScheme.tertiary; + return colorScheme.error; } String _formatDuration(Duration duration) { diff --git a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart index 6b06999..981f818 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart @@ -26,11 +26,6 @@ class _TestPageState extends State { TestDto? _test; bool _isLoading = true; String? _errorMessage; - bool _isTestStarted = false; - bool _isTestCompleted = false; - int _currentQuestionIndex = 0; - final Map _userAnswers = {}; - TestResult? _testResult; @override void initState() { @@ -88,14 +83,6 @@ class _TestPageState extends State { icon: const Icon(Icons.arrow_back), onPressed: () => _handleBack(), ), - actions: _isTestStarted && !_isTestCompleted - ? [ - TextButton( - onPressed: _finishTest, - child: const Text('Finish'), - ), - ] - : null, ), body: _buildBody(), ); @@ -132,18 +119,10 @@ class _TestPageState extends State { ); } - if (_isTestCompleted && _testResult != null) { - return _buildTestResults(); - } - - if (_isTestStarted) { - return _buildTestQuestion(); - } - - return _buildTestIntro(); + return _buildTestOverview(); } - Widget _buildTestIntro() { + Widget _buildTestOverview() { final test = _test!; return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -182,44 +161,53 @@ class _TestPageState extends State { ), const SizedBox(height: 8), const Text( - '• Read each question carefully\n' - '• Select the best answer\n' - '• You can go back to previous questions\n' - '• Click "Finish" when you\'re done\n' - '• Your progress will be saved automatically', + '• Start the interactive game to answer cards\n' + '• Audio and hints follow your theme settings\n' + '• Progress is saved automatically during play\n' + '• You can exit any time and restart later', style: TextStyle(fontSize: 16), ), const SizedBox(height: 32), - Column( - children: [ SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () => _playGame(context), icon: const Icon(Icons.games), - label: const Text('Play Interactive Game'), + label: const Text('Play Interactive Game'), style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(48), textStyle: const TextStyle(fontSize: 18), - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - ), - ), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _startTest, - icon: const Icon(Icons.quiz), - label: const Text('Take Traditional Test'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - textStyle: const TextStyle(fontSize: 16), - ), - ), + ), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, ), - ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Interactive mode only', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Traditional testing was removed. Launch the interactive game ' + 'to complete this pack.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), ), ], ), @@ -241,536 +229,11 @@ class _TestPageState extends State { } void _playGame(BuildContext context) { - // Navigate to the game page context.go('/game/${widget.testId}'); } - void _startTest() { - setState(() { - _isTestStarted = true; - _currentQuestionIndex = 0; - _userAnswers.clear(); - }); - } - - void _selectAnswer(String answer) { - setState(() { - _userAnswers[_currentQuestionIndex] = answer; - }); - } - - void _nextQuestion() { - if (_currentQuestionIndex < _test!.questions.length - 1) { - setState(() { - _currentQuestionIndex++; - }); - } else { - _finishTest(); - } - } - - void _previousQuestion() { - if (_currentQuestionIndex > 0) { - setState(() { - _currentQuestionIndex--; - }); - } - } - - Future _finishTest() async { - if (_test == null) return; - - final startTime = DateTime.now(); - final endTime = DateTime.now(); - final timeTaken = endTime.difference(startTime).inSeconds; - - // Calculate results - int correctAnswers = 0; - int incorrectAnswers = 0; - - for (int i = 0; i < _test!.questions.length; i++) { - final question = _test!.questions[i]; - final userAnswer = _userAnswers[i]; - - if (question is SimpleTestQuestionBody) { - if (userAnswer == question.answer) { - correctAnswers++; - } else { - incorrectAnswers++; - } - } else { - // For unsupported question types, count as incorrect - incorrectAnswers++; - } - } - - final result = TestResult( - testId: widget.testId, - correctAnswers: correctAnswers, - incorrectAnswers: incorrectAnswers, - totalQuestions: _test!.questions.length, - timeTaken: timeTaken, - completedAt: endTime, - ); - - // Submit results to backend - try { - final appScope = ScopeProvider.of( - context, - listen: false, - ); - final userScope = appScope?.userScopeHolder.scope; - if (userScope != null) { - // Create a simplified statistics DTO for submission - final statistics = TestStatisticsDto( - testId: int.tryParse(widget.testId) ?? 0, - words: AllWordsStatisticsDto.empty(), - attempts: 1, - ); - - await userScope.testsModule.testManager.submitTestStatistics( - widget.testId, - statistics, - ); - } - } catch (e, s) { - log( - 'Error submitting test statistics', - error: e, - stackTrace: s, - name: 'TestPage', - ); - } - - setState(() { - _isTestCompleted = true; - _testResult = result; - }); - } - - void _retakeTest() { - setState(() { - _isTestStarted = false; - _isTestCompleted = false; - _currentQuestionIndex = 0; - _userAnswers.clear(); - _testResult = null; - }); - } - - void _goBack() { + void _handleBack() { context.pop(); } - - void _handleBack() { - if (_isTestStarted && !_isTestCompleted) { - _showExitConfirmation(); - } else { - context.pop(); - } - } - - void _showExitConfirmation() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Exit Test'), - content: const Text('Are you sure you want to exit? Your progress will be lost.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - context.pop(); - }, - child: const Text('Exit'), - ), - ], - ), - ); - } - - Widget _buildTestQuestion() { - final test = _test!; - final question = test.questions[_currentQuestionIndex]; - - return Column( - children: [ - // Progress bar - Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Question ${_currentQuestionIndex + 1} of ${test.questions.length}'), - Text('${((_currentQuestionIndex + 1) / test.questions.length * 100).round()}%'), - ], - ), - const SizedBox(height: 8), - LinearProgressIndicator( - value: (_currentQuestionIndex + 1) / test.questions.length, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - ), - ], - ), - ), - - // Question content - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Question ${_currentQuestionIndex + 1}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - Text( - question.word, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontSize: 18, - height: 1.4, - ), - ), - if (question is SimpleTestQuestionBody && question.text != null) ...[ - const SizedBox(height: 8), - Text( - question.text!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ], - ), - ), - ), - - const SizedBox(height: 16), - - // Answer options - Text( - 'Select your answer:', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - - if (question is SimpleTestQuestionBody) ...[ - ...question.buttons.map((button) { - final isSelected = _userAnswers[_currentQuestionIndex] == (button.text ?? ''); - - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () => _selectAnswer(button.text ?? ''), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - width: isSelected ? 2 : 1, - ), - borderRadius: BorderRadius.circular(8), - color: isSelected - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) - : null, - ), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - border: Border.all( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Builder( - builder: (context) { - final colorScheme = - Theme.of(context).colorScheme; - return Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ); - }, - ) - : null, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - button.text ?? '', - style: TextStyle( - fontSize: 16, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - ), - ], - ), - ), - ), - ); - }), - ] else ...[ - // Fallback for other question types - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).colorScheme.outline), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'This question type is not yet supported.', - style: TextStyle(fontSize: 16), - ), - ), - ], - ], - ), - ), - ), - - // Navigation buttons - Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - if (_currentQuestionIndex > 0) - Expanded( - child: OutlinedButton.icon( - onPressed: _previousQuestion, - icon: const Icon(Icons.arrow_back), - label: const Text('Previous'), - ), - ), - if (_currentQuestionIndex > 0) const SizedBox(width: 16), - Expanded( - child: ElevatedButton.icon( - onPressed: _nextQuestion, - icon: Icon(_currentQuestionIndex == test.questions.length - 1 - ? Icons.check - : Icons.arrow_forward), - label: Text(_currentQuestionIndex == test.questions.length - 1 - ? 'Finish' - : 'Next'), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildTestResults() { - final result = _testResult!; - final score = (result.correctAnswers / result.totalQuestions * 100).round(); - - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - const SizedBox(height: 32), - - // Score circle - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getScoreColor(score).withValues(alpha: 0.1), - border: Border.all( - color: _getScoreColor(score), - width: 4, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '$score%', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: _getScoreColor(score), - ), - ), - Text( - 'Score', - style: TextStyle( - fontSize: 14, - color: _getScoreColor(score), - ), - ), - ], - ), - ), - - const SizedBox(height: 32), - - // Results summary - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - 'Test Results', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - _buildResultRow('Correct Answers', '${result.correctAnswers}/${result.totalQuestions}'), - _buildResultRow('Incorrect Answers', '${result.incorrectAnswers}/${result.totalQuestions}'), - _buildResultRow('Time Taken', '${result.timeTaken} seconds'), - _buildResultRow('Date', result.completedAt.toString().split(' ')[0]), - ], - ), - ), - ), - - const SizedBox(height: 24), - - // Performance message - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _getScoreColor(score).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _getScoreColor(score).withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - _getScoreIcon(score), - color: _getScoreColor(score), - size: 32, - ), - const SizedBox(width: 16), - Expanded( - child: Text( - _getScoreMessage(score), - style: TextStyle( - fontSize: 16, - color: _getScoreColor(score), - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 32), - - // Action buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _retakeTest, - icon: const Icon(Icons.refresh), - label: const Text('Retake Test'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton.icon( - onPressed: _goBack, - icon: const Icon(Icons.home), - label: const Text('Back to Packs'), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildResultRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label), - Text( - value, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - - Color _getScoreColor(int score) { - if (score >= 80) return Colors.green; - if (score >= 60) return Colors.orange; - return Colors.red; - } - - IconData _getScoreIcon(int score) { - if (score >= 80) return Icons.celebration; - if (score >= 60) return Icons.thumb_up; - return Icons.thumb_down; - } - - String _getScoreMessage(int score) { - if (score >= 80) return 'Excellent work! You have a great understanding of this topic.'; - if (score >= 60) return 'Good job! You have a decent understanding, but there\'s room for improvement.'; - return 'Keep studying! Review the material and try again.'; - } -} - -/// Result of a completed test -class TestResult { - const TestResult({ - required this.testId, - required this.correctAnswers, - required this.incorrectAnswers, - required this.totalQuestions, - required this.timeTaken, - required this.completedAt, - }); - - final String testId; - final int correctAnswers; - final int incorrectAnswers; - final int totalQuestions; - final int timeTaken; - final DateTime completedAt; } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart b/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart index 3b2bcf0..57e90ef 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/card_viewer.dart @@ -1,10 +1,13 @@ +import 'dart:developer'; import 'dart:math' as math; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:yx_scope_flutter/yx_scope_flutter.dart'; +import '../../di/app_scope/app_scope_container.dart'; import '../../di/user_scope/user_scope.dart'; import '../../domain/config/api_config_v2.dart'; import '../../presentation/theme/app_colors.dart'; @@ -490,6 +493,12 @@ class _CardSide extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + CardVoiceControls( + packId: packId, + cardId: card.id, + accentColor: packColor, + ), + const SizedBox(height: 12), if (card.original != null && card.original!.isNotEmpty) MnemoText( card.original, @@ -565,3 +574,207 @@ class _CardSide extends StatelessWidget { } } +class CardVoiceControls extends StatefulWidget { + const CardVoiceControls({ + required this.packId, + required this.cardId, + required this.accentColor, + super.key, + }); + + final String packId; + final int cardId; + final Color accentColor; + + @override + State createState() => _CardVoiceControlsState(); +} + +class _CardVoiceControlsState extends State { + late Future> _voicesFuture; + final AudioPlayer _player = AudioPlayer(); + bool _isPlaying = false; + int? _currentVoiceId; + String? _playError; + + @override + void initState() { + super.initState(); + _voicesFuture = _loadVoices(); + _player.onPlayerComplete.listen((_) { + if (!mounted) { + return; + } + setState(() { + _isPlaying = false; + }); + }); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + Future> _loadVoices() async { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + if (appScope == null) { + throw Exception('Scope not available'); + } + return appScope.httpRepository.getCardVoices( + widget.packId, + widget.cardId, + ); + } + + Future _playVoice(VoiceDto voice) async { + setState(() { + _playError = null; + _isPlaying = true; + _currentVoiceId = voice.id; + }); + try { + await _player.stop(); + await _player.play( + UrlSource(ApiConfigV2.getVoiceFileUrl(voice.id)), + ); + } catch (e, s) { + log( + 'Voice playback failed', + name: 'CardVoiceControls', + error: e, + stackTrace: s, + ); + if (!mounted) { + return; + } + setState(() { + _playError = 'Не удалось воспроизвести озвучку'; + _isPlaying = false; + }); + } + } + + Future _stop() async { + await _player.stop(); + if (!mounted) { + return; + } + setState(() { + _isPlaying = false; + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _voicesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: SizedBox( + height: 32, + width: 32, + child: CircularProgressIndicator( + strokeWidth: 2, + color: widget.accentColor, + ), + ), + ), + ); + } + + if (snapshot.hasError) { + return _errorText( + 'Не удалось загрузить озвучку: ${snapshot.error}', + ); + } + + final voices = snapshot.data ?? const []; + if (voices.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...voices.map(_voiceRow), + if (_playError != null) _errorText(_playError!), + ], + ); + }, + ); + } + + Widget _voiceRow(VoiceDto voice) { + final isCurrent = _currentVoiceId == voice.id && _isPlaying; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + IconButton( + onPressed: isCurrent ? _stop : () => _playVoice(voice), + icon: Icon(isCurrent ? Icons.stop : Icons.play_arrow), + color: widget.accentColor, + tooltip: isCurrent ? 'Остановить' : 'Воспроизвести', + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + voice.phrase, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: widget.accentColor, + ), + ), + Text( + voice.speaker, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (isCurrent) + const Icon( + Icons.equalizer, + color: Colors.green, + ), + ], + ), + ); + } + + Widget _errorText(String message) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: SelectableText.rich( + TextSpan( + children: [ + const WidgetSpan( + child: Icon( + Icons.error_outline, + color: Colors.red, + size: 16, + ), + ), + const TextSpan(text: ' '), + TextSpan( + text: message, + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + ); + } +} + diff --git a/mnemo_cards_web_v2/pubspec.lock b/mnemo_cards_web_v2/pubspec.lock index aad7bfa..5b61090 100644 --- a/mnemo_cards_web_v2/pubspec.lock +++ b/mnemo_cards_web_v2/pubspec.lock @@ -49,6 +49,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" auto_route: dependency: transitive description: diff --git a/mnemo_cards_web_v2/pubspec.yaml b/mnemo_cards_web_v2/pubspec.yaml index 6f20abe..99e151c 100644 --- a/mnemo_cards_web_v2/pubspec.yaml +++ b/mnemo_cards_web_v2/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: auto_size_text: ^3.0.0 fl_chart: ^0.68.0 cached_network_image: ^3.4.1 + audioplayers: ^6.1.0 # Utils universal_image: ^1.0.10 diff --git a/mnemo_cards_web_v2/test/presentation/pages/game/game_page_test.dart b/mnemo_cards_web_v2/test/presentation/pages/game/game_page_test.dart index 92f49ce..9515887 100644 --- a/mnemo_cards_web_v2/test/presentation/pages/game/game_page_test.dart +++ b/mnemo_cards_web_v2/test/presentation/pages/game/game_page_test.dart @@ -3,13 +3,17 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart'; -import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_container.dart'; import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart'; +import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart'; +import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart'; +import 'package:mnemo_cards_web_v2/domain/services/game_session_manager.dart'; +import 'package:mnemo_cards_web_v2/domain/services/game_sound_service.dart'; +import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart'; import 'package:mnemo_cards_web_v2/domain/models/game_question.dart'; import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.dart'; import 'package:mnemo_cards_web_v2/presentation/pages/game/game_page.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:provider/provider.dart'; -import 'package:yx_scope/yx_scope.dart'; // Mock classes class MockAppScopeContainer extends Mock implements AppScopeContainer {} @@ -18,39 +22,63 @@ class MockUserScopeHolder extends Mock implements UserScopeHolder {} class MockUserScope extends Mock implements UserScope {} -class MockTestsModule extends Mock {} +class MockGameSoundService extends Mock implements GameSoundService {} class MockTestsStateManager extends Mock implements TestsStateManager {} +class FakeTestsModule extends Fake implements TestsModule { + FakeTestsModule({ + required this.testsStateManager, + required this.gameSoundService, + }); + + @override + final TestsStateManager testsStateManager; + + @override + final GameSoundService gameSoundService; + + @override + GameSessionManager get gameSessionManager => throw UnimplementedError(); + + @override + TestManager get testManager => throw UnimplementedError(); +} + void main() { late MockAppScopeContainer mockAppScope; late MockUserScopeHolder mockUserScopeHolder; late MockUserScope mockUserScope; - late MockTestsModule mockTestsModule; late MockTestsStateManager mockTestsStateManager; + late MockGameSoundService mockGameSoundService; + late FakeTestsModule fakeTestsModule; setUp(() { mockAppScope = MockAppScopeContainer(); mockUserScopeHolder = MockUserScopeHolder(); mockUserScope = MockUserScope(); - mockTestsModule = MockTestsModule(); mockTestsStateManager = MockTestsStateManager(); + mockGameSoundService = MockGameSoundService(); + fakeTestsModule = FakeTestsModule( + testsStateManager: mockTestsStateManager, + gameSoundService: mockGameSoundService, + ); // Setup the mock chain when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder); when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope); - when(() => mockUserScope.testsModule).thenReturn(mockTestsModule); - when(() => mockTestsModule.testsStateManager).thenReturn(mockTestsStateManager); + when(() => mockUserScope.testsModule).thenReturn(fakeTestsModule); + when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading()); + when(() => mockTestsStateManager.startGameSession(any())).thenAnswer((_) async {}); + when(() => mockTestsStateManager.resetGameSession()).thenAnswer((_) async {}); + when(() => mockTestsStateManager.submitAnswer(any())).thenAnswer((_) async {}); + when(() => mockTestsStateManager.nextQuestion()).thenAnswer((_) async {}); + when(() => mockTestsStateManager.previousQuestion()).thenAnswer((_) async {}); + when(() => mockGameSoundService.initialize()).thenAnswer((_) async {}); + when(() => mockGameSoundService.playGameStart()).thenAnswer((_) async {}); // Initialize screen util - FlutterScreenUtil.init( - const BoxConstraints( - maxWidth: 375, - maxHeight: 812, - ), - designSize: const Size(375, 812), - minTextAdapt: true, - ); + _initScreenUtil(); }); group('GamePage', () { @@ -77,6 +105,47 @@ void main() { expect(find.text('Start Game'), findsOneWidget); }); + testWidgets('does not restart when session already active', (tester) async { + final questions = [ + GameQuestion.multipleChoice( + MultipleChoiceQuestion( + id: 'q1', + question: '2+2?', + options: ['3', '4'], + correctAnswer: '4', + word: 'four', + ), + ), + ]; + + when(() => mockTestsStateManager.state).thenReturn( + TestsState.gameSessionActive( + test: TestDto(id: 'test1', name: 'Test Game', questions: []), + questions: questions, + currentQuestionIndex: 0, + currentResult: null, + questionResults: {}, + isAnswerSubmitted: false, + isCorrect: false, + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: mockAppScope), + ], + child: const MaterialApp( + home: GamePage(testId: 'test1'), + ), + ), + ); + await tester.pumpAndSettle(); + + verifyNever(() => mockTestsStateManager.startGameSession(any())); + verify(() => mockTestsStateManager.resumeGameSession()).called(1); + }); + testWidgets('should display active game state', (tester) async { final questions = [ GameQuestion.multipleChoice( @@ -121,6 +190,122 @@ void main() { expect(find.text('5'), findsOneWidget); }); + testWidgets('uses theme surface colors for question card', (tester) async { + final questions = [ + GameQuestion.multipleChoice( + MultipleChoiceQuestion( + id: 'q1', + question: 'Capital of France?', + options: ['Paris', 'London'], + correctAnswer: 'Paris', + word: 'paris', + ), + ), + ]; + + when(() => mockTestsStateManager.state).thenReturn( + TestsState.gameSessionActive( + test: TestDto(id: 'test1', name: 'Test Game', questions: []), + questions: questions, + currentQuestionIndex: 0, + currentResult: null, + questionResults: {}, + isAnswerSubmitted: false, + isCorrect: false, + ), + ); + when(() => mockTestsStateManager.canGoNext).thenReturn(true); + when(() => mockTestsStateManager.canGoPrevious).thenReturn(false); + + final theme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF005AC1), + ).copyWith( + surface: const Color(0xFFF5F7FB), + surfaceTint: const Color(0xFFE8ECF5), + outlineVariant: const Color(0xFFCFD6E0), + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: mockAppScope), + ], + child: MaterialApp( + theme: theme, + home: const GamePage(testId: 'test1'), + ), + ), + ); + await tester.pumpAndSettle(); + + final material = tester.widget(find.byKey(GamePage.questionCardKey)); + + expect(material.color, theme.colorScheme.surface); + expect(material.surfaceTintColor, theme.colorScheme.surfaceTint); + }); + + testWidgets('shows exit icon beside progress', (tester) async { + final questions = [ + GameQuestion.multipleChoice( + MultipleChoiceQuestion( + id: 'q1', + question: 'Capital of France?', + options: ['Paris', 'London'], + correctAnswer: 'Paris', + word: 'paris', + ), + ), + ]; + + when(() => mockTestsStateManager.state).thenReturn( + TestsState.gameSessionActive( + test: TestDto(id: 'test1', name: 'Test Game', questions: []), + questions: questions, + currentQuestionIndex: 0, + currentResult: null, + questionResults: {}, + isAnswerSubmitted: false, + isCorrect: false, + ), + ); + when(() => mockTestsStateManager.canGoNext).thenReturn(true); + when(() => mockTestsStateManager.canGoPrevious).thenReturn(false); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: mockAppScope), + ], + child: const MaterialApp( + home: GamePage(testId: 'test1'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('starts session when not active', (tester) async { + when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading()); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: mockAppScope), + ], + child: const MaterialApp( + home: GamePage(testId: 'test1'), + ), + ), + ); + await tester.pumpAndSettle(); + + verify(() => mockTestsStateManager.startGameSession('test1')).called(1); + }); + testWidgets('should display completed game state', (tester) async { final result = GameSessionResult( testId: 'test1', @@ -227,3 +412,8 @@ void main() { }); }); } + +Future _initScreenUtil() async { + TestWidgetsFlutterBinding.ensureInitialized(); + ScreenUtil.ensureScreenSize(); +} diff --git a/mnemo_cards_web_v2/test/presentation/pages/test/test_page_test.dart b/mnemo_cards_web_v2/test/presentation/pages/test/test_page_test.dart index e69de29..864161b 100644 --- a/mnemo_cards_web_v2/test/presentation/pages/test/test_page_test.dart +++ b/mnemo_cards_web_v2/test/presentation/pages/test/test_page_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart'; +import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart'; +import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_holder.dart'; +import 'package:mnemo_cards_web_v2/di/user_scope/modules/tests_module.dart'; +import 'package:mnemo_cards_web_v2/domain/services/test_manager.dart'; +import 'package:mnemo_cards_web_v2/presentation/pages/test/test_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:yx_scope/yx_scope.dart'; +import 'package:yx_scope_flutter/yx_scope_flutter.dart'; + +class MockAppScopeContainer extends Mock implements AppScopeContainer {} + +class MockUserScopeHolder extends Mock implements UserScopeHolder {} + +class MockUserScope extends Mock implements UserScope {} + +class MockTestsModule extends Mock implements TestsModule {} + +class MockTestManager extends Mock implements TestManager {} + +void main() { + late MockAppScopeContainer mockAppScope; + late MockUserScopeHolder mockUserScopeHolder; + late MockUserScope mockUserScope; + late MockTestsModule mockTestsModule; + late MockTestManager mockTestManager; + late ScopeStateHolder appScopeHolder; + + setUp(() { + mockAppScope = MockAppScopeContainer(); + mockUserScopeHolder = MockUserScopeHolder(); + mockUserScope = MockUserScope(); + mockTestsModule = MockTestsModule(); + mockTestManager = MockTestManager(); + + when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder); + when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope); + when(() => mockUserScope.testsModule).thenReturn(mockTestsModule); + when(() => mockTestsModule.testManager).thenReturn(mockTestManager); + when(() => mockTestManager.loadTest(any())).thenAnswer( + (_) async => TestDto( + id: '42', + name: 'Sample Test', + questions: const [], + ), + ); + + appScopeHolder = ScopeStateHolder( + ScopeState.available(scope: mockAppScope), + ); + }); + + group('TestPage', () { + testWidgets('shows interactive-only notice', (tester) async { + final router = _buildRouter(); + + await tester.pumpWidget( + ScopeProvider( + holder: appScopeHolder, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Sample Test'), findsOneWidget); + expect(find.text('Interactive mode only'), findsOneWidget); + expect(find.text('Play Interactive Game'), findsOneWidget); + + router.dispose(); + }); + + testWidgets('navigates to game page on tap', (tester) async { + final router = _buildRouter(); + + await tester.pumpWidget( + ScopeProvider( + holder: appScopeHolder, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Play Interactive Game')); + await tester.pumpAndSettle(); + + expect(find.text('Game 42'), findsOneWidget); + + router.dispose(); + }); + }); +} + +GoRouter _buildRouter() { + return GoRouter( + initialLocation: '/test/42', + routes: [ + GoRoute( + path: '/test/:id', + builder: (context, state) => TestPage( + testId: state.pathParameters['id'] ?? '', + ), + ), + GoRoute( + path: '/game/:id', + builder: (context, state) => Scaffold( + body: Center( + child: Text('Game ${state.pathParameters['id']}'), + ), + ), + ), + ], + ); +}