This commit is contained in:
Dmitry 2025-12-07 03:09:50 +03:00
parent 183a59381b
commit 0159aff93e
19 changed files with 1422 additions and 948 deletions

View file

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/acl_types.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/main.dart' as backend_main;
import 'package:mnemo_cards_backend/packs/card_model_extension.dart'; 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/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_backend/tests/test_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.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/<packId>/cards/<cardId>/voices')
@OpenApiRoute()
Future<Response> 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': <Map<String, Object?>>[]});
}
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/<voiceId>')
@OpenApiRoute()
Future<Response> 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<bool> _hasEnabledPack(List<GameCardModel> 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 /api/v2/packs/{packId}/tests
/// Get tests for a pack /// Get tests for a pack
@Route.get('/packs/<packId>/tests') @Route.get('/packs/<packId>/tests')

View file

@ -33,6 +33,16 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
r'/packs/<packId>/cards/<cardId>/image', r'/packs/<packId>/cards/<cardId>/image',
service.getCardImage, service.getCardImage,
); );
router.add(
'GET',
r'/packs/<packId>/cards/<cardId>/voices',
service.getCardVoices,
);
router.add(
'GET',
r'/voice/<voiceId>',
service.getVoiceFile,
);
router.add( router.add(
'GET', 'GET',
r'/packs/<packId>/tests', r'/packs/<packId>/tests',

View file

@ -129,32 +129,6 @@ paths:
responses: responses:
200: 200:
description: "Operation completed!" description: "Operation completed!"
/ads/product/acquire/<key>:
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: /admin/cards:
get: get:
tags: tags:
@ -205,6 +179,32 @@ paths:
responses: responses:
200: 200:
description: "Operation completed!" description: "Operation completed!"
/ads/product/acquire/<key>:
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: /subscriptions/plans:
get: get:
tags: tags:
@ -520,6 +520,43 @@ paths:
responses: responses:
200: 200:
description: "Operation completed!" description: "Operation completed!"
/packs/<packId>/cards/<cardId>/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/<voiceId>:
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/<packId>/tests: /packs/<packId>/tests:
get: get:
tags: tags:
@ -779,84 +816,6 @@ paths:
responses: responses:
200: 200:
description: "Operation completed!" description: "Operation completed!"
/tests/<testId>:
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/<testId>/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/<testId>/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: /tasks:
get: get:
tags: tags:
@ -929,16 +888,94 @@ paths:
responses: responses:
200: 200:
description: "Operation completed!" 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/<testId>:
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/<testId>/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/<testId>/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: { } components: { }
tags: tags:
- name: PromocodesApiV2 - name: PromocodesApiV2
description: API v2 endpoints for promocode management and activation. description: API v2 endpoints for promocode management and activation.
- name: DiscountsApiV2 - name: DiscountsApiV2
description: Admin endpoints for discount campaign management. description: Admin endpoints for discount campaign management.
- name: AdsApiV2
description: API v2 endpoints for rewarded ads flows.
- name: AdminCardsApiV2 - name: AdminCardsApiV2
description: Admin endpoints for card management in API v2. description: Admin endpoints for card management in API v2.
- name: AdsApiV2
description: API v2 endpoints for rewarded ads flows.
- name: SubscriptionsApiV2 - name: SubscriptionsApiV2
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans" description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
- name: UsersApiV2 - name: UsersApiV2
@ -957,9 +994,9 @@ tags:
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
- name: AuthApiV2 - name: AuthApiV2
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication 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: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2.
- name: TasksApiV2 - name: TasksApiV2
description: API v2 endpoints for user tasks management description: API v2 endpoints for user tasks management
- name: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2.
- name: TestsApiV2
description: Tests API v2\n\nRESTful endpoints for managing tests and test results

View file

@ -9,6 +9,7 @@ export 'src/dtos/packs/card_pack_preview_dto.dart';
export 'src/dtos/packs/pack_tip.dart'; export 'src/dtos/packs/pack_tip.dart';
export 'src/dtos/packs/get_card_pack_response.dart'; export 'src/dtos/packs/get_card_pack_response.dart';
export 'src/dtos/game_card_dto.dart'; export 'src/dtos/game_card_dto.dart';
export 'src/dtos/voice_dto.dart';
export 'src/dtos/payment/export.dart'; export 'src/dtos/payment/export.dart';

View file

@ -13,6 +13,7 @@ class IsarConnector {
[ [
CardPackModelSchema, CardPackModelSchema,
GameCardModelSchema, GameCardModelSchema,
VoiceModelSchema,
UserModelSchema, UserModelSchema,
UserSubscriptionModelSchema, UserSubscriptionModelSchema,
SubscriptionPlanModelSchema, SubscriptionPlanModelSchema,

View file

@ -1,5 +1,6 @@
export 'card_pack_model.dart'; export 'card_pack_model.dart';
export 'game_card_model.dart'; export 'game_card_model.dart';
export 'voice_model.dart';
export 'payment.dart'; export 'payment.dart';
export 'product_model.dart'; export 'product_model.dart';
export 'refresh_token_model.dart'; export 'refresh_token_model.dart';

View file

@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:copy_with_extension/copy_with_extension.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/card_pack_model.dart';
import 'package:mnemo_cards_common_backend/src/models/voice_model.dart';
part 'game_card_model.g.dart'; part 'game_card_model.g.dart';
@ -27,6 +28,7 @@ class GameCardModel {
@JsonKey(name: 'back') @JsonKey(name: 'back')
final String? back; final String? back;
final IsarLinks<CardPackModel> packs = IsarLinks<CardPackModel>(); final IsarLinks<CardPackModel> packs = IsarLinks<CardPackModel>();
final IsarLinks<VoiceModel> voices = IsarLinks<VoiceModel>();
GameCardModel({ GameCardModel({
required this.image, required this.image,

View file

@ -215,6 +215,12 @@ const GameCardModelSchema = CollectionSchema(
name: r'packs', name: r'packs',
target: r'CardPackModel', target: r'CardPackModel',
single: false, single: false,
),
r'voices': LinkSchema(
id: 7850270866015854518,
name: r'voices',
target: r'VoiceModel',
single: false,
) )
}, },
embeddedSchemas: {}, embeddedSchemas: {},
@ -330,13 +336,14 @@ Id _gameCardModelGetId(GameCardModel object) {
} }
List<IsarLinkBase<dynamic>> _gameCardModelGetLinks(GameCardModel object) { List<IsarLinkBase<dynamic>> _gameCardModelGetLinks(GameCardModel object) {
return [object.packs]; return [object.packs, object.voices];
} }
void _gameCardModelAttach( void _gameCardModelAttach(
IsarCollection<dynamic> col, Id id, GameCardModel object) { IsarCollection<dynamic> col, Id id, GameCardModel object) {
object.id = id; object.id = id;
object.packs.attach(col, col.isar.collection<CardPackModel>(), r'packs', id); object.packs.attach(col, col.isar.collection<CardPackModel>(), r'packs', id);
object.voices.attach(col, col.isar.collection<VoiceModel>(), r'voices', id);
} }
extension GameCardModelQueryWhereSort extension GameCardModelQueryWhereSort
@ -1718,6 +1725,67 @@ extension GameCardModelQueryLinks
r'packs', lower, includeLower, upper, includeUpper); r'packs', lower, includeLower, upper, includeUpper);
}); });
} }
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition> voices(
FilterQuery<VoiceModel> q) {
return QueryBuilder.apply(this, (query) {
return query.link(q, r'voices');
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
voicesLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'voices', length, true, length, true);
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
voicesIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'voices', 0, true, 0, true);
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
voicesIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'voices', 0, false, 999999, true);
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
voicesLengthLessThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'voices', 0, true, length, include);
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
voicesLengthGreaterThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.linkLength(r'voices', length, include, 999999, true);
});
}
QueryBuilder<GameCardModel, GameCardModel, QAfterFilterCondition>
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 extension GameCardModelQuerySortBy

View file

@ -129,11 +129,20 @@ class ApiConfigV2 {
/// Get all cards in a pack /// Get all cards in a pack
static String packCards(String packId) => '/packs/$packId/cards'; 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 /api/v2/packs/{packId}/card/{cardId}/image
/// Get card image /// Get card image
static String packCardImage(String packId, int cardId) => static String packCardImage(String packId, int cardId) =>
'/packs/$packId/cards/$cardId/image'; '/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 /api/v2/packs/{packId}/tests
/// Get tests for a pack /// Get tests for a pack
static String packTests(String packId) => '/packs/$packId/tests'; static String packTests(String packId) => '/packs/$packId/tests';
@ -220,6 +229,12 @@ class ApiConfigV2 {
return '$baseUrl${packCardImage(packId, cardId)}'; 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 /// Build query string from parameters
/// Example: buildQuery({'search': 'food', 'page': 1}) => '?search=food&page=1' /// Example: buildQuery({'search': 'food', 'page': 1}) => '?search=food&page=1'
static String buildQuery(Map<String, dynamic>? params) { static String buildQuery(Map<String, dynamic>? params) {

View file

@ -3,6 +3,8 @@ import 'dart:developer';
import '../models/game_question.dart'; import '../models/game_question.dart';
typedef DelayProvider = Duration Function();
/// Manages active game session state and user interactions /// Manages active game session state and user interactions
class GameSessionManager { class GameSessionManager {
GameSessionManager(); GameSessionManager();
@ -12,6 +14,7 @@ class GameSessionManager {
Timer? _questionTimer; Timer? _questionTimer;
DateTime? _sessionStartTime; DateTime? _sessionStartTime;
DateTime? _currentQuestionStartTime; DateTime? _currentQuestionStartTime;
DelayProvider _delayProvider = () => const Duration(milliseconds: 1500);
/// Current game session result /// Current game session result
GameSessionResult? get currentResult => _currentResult; GameSessionResult? get currentResult => _currentResult;
@ -19,6 +22,21 @@ class GameSessionManager {
/// All question results for current session /// All question results for current session
Map<String, QuestionResult> get questionResults => Map.unmodifiable(_questionResults); Map<String, QuestionResult> 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 /// Start a new game session
void startSession(String testId, List<GameQuestion> questions) { void startSession(String testId, List<GameQuestion> questions) {
log('Starting game session for test: $testId', name: 'GameSessionManager'); log('Starting game session for test: $testId', name: 'GameSessionManager');
@ -37,6 +55,10 @@ class GameSessionManager {
timeSpent: Duration.zero, timeSpent: Duration.zero,
); );
} }
if (questions.isNotEmpty) {
startQuestionTimer(_getQuestionId(questions.first));
}
} }
/// Start timing for a specific question /// Start timing for a specific question
@ -72,6 +94,8 @@ class GameSessionManager {
_currentQuestionStartTime = null; _currentQuestionStartTime = null;
} }
Duration get feedbackDelay => _delayProvider();
/// Complete the current game session /// Complete the current game session
GameSessionResult completeSession(String testId) { GameSessionResult completeSession(String testId) {
final totalTime = _calculateSessionTime(); final totalTime = _calculateSessionTime();

View file

@ -947,6 +947,34 @@ class HttpRepositoryV2 {
} }
} }
/// Get voices metadata for a specific card
Future<List<VoiceDto>> getCardVoices(String packId, int cardId) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
ApiConfigV2.packCardVoices(packId, cardId),
);
final data = response.data;
if (data == null) {
return const [];
}
final items = data['items'] as List<dynamic>? ?? [];
return items
.map((item) => VoiceDto.fromJson(item as Map<String, dynamic>))
.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) /// Get pack purchase details (includes ad reward offers)
Future<CardPackBuyDto?> getPackBuy(String packId) async { Future<CardPackBuyDto?> getPackBuy(String packId) async {
try { try {

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:flutter/services.dart';
import 'package:yx_state/yx_state.dart'; import 'package:yx_state/yx_state.dart';
import '../models/game_question.dart'; import '../models/game_question.dart';
@ -160,15 +161,17 @@ class TestsStateManager extends StateManager<TestsState> {
// Play sound based on answer correctness // Play sound based on answer correctness
if (isCorrect) { if (isCorrect) {
await _gameSoundService.playCorrectAnswer(); await _gameSoundService.playCorrectAnswer();
await HapticFeedback.lightImpact();
} else { } else {
await _gameSoundService.playWrongAnswer(); await _gameSoundService.playWrongAnswer();
await HapticFeedback.mediumImpact();
} }
// Update state with answer feedback // Update state with answer feedback
emit(currentState.copyWith( emit(currentState.copyWith(
isAnswerSubmitted: true, isAnswerSubmitted: true,
isCorrect: isCorrect, isCorrect: isCorrect,
answerFeedbackDelay: const Duration(milliseconds: 1500), answerFeedbackDelay: _gameSessionManager.feedbackDelay,
questionResults: _gameSessionManager.questionResults, questionResults: _gameSessionManager.questionResults,
)); ));
@ -241,6 +244,20 @@ class TestsStateManager extends StateManager<TestsState> {
emit(const TestsState.loading()); 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 /// Check if can navigate to next question
bool get canGoNext { bool get canGoNext {
final currentState = state; final currentState = state;

View file

@ -26,6 +26,7 @@ class GamePage extends StatefulWidget {
}); });
final String testId; final String testId;
static const questionCardKey = Key('game_question_card');
@override @override
State<GamePage> createState() => _GamePageState(); State<GamePage> createState() => _GamePageState();
@ -33,6 +34,7 @@ class GamePage extends StatefulWidget {
class _GamePageState extends State<GamePage> { class _GamePageState extends State<GamePage> {
GameSoundService? _soundService; GameSoundService? _soundService;
double _maxContentWidth = 880;
@override @override
void initState() { void initState() {
@ -65,8 +67,22 @@ class _GamePageState extends State<GamePage> {
} }
try { try {
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 userScope.testsModule.testsStateManager.startGameSession(widget.testId);
await _soundService?.playGameStart(); await _soundService?.playGameStart();
}
} catch (e, s) { } catch (e, s) {
log('Error starting game session', error: e, stackTrace: s, name: 'GamePage'); log('Error starting game session', error: e, stackTrace: s, name: 'GamePage');
} }
@ -115,7 +131,12 @@ class _GamePageState extends State<GamePage> {
} }
Widget _buildBody(TestsState state) { Widget _buildBody(TestsState state) {
return state.when( final appScope = ScopeProvider.of<AppScopeContainer>(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...'), loading: () => const LoadingView(message: 'Loading game...'),
loaded: (tests, packId) => const Center(child: Text('Game loaded')), loaded: (tests, packId) => const Center(child: Text('Game loaded')),
error: (message) => ErrorView( error: (message) => ErrorView(
@ -125,23 +146,37 @@ class _GamePageState extends State<GamePage> {
), ),
gameSessionPreparing: (test, questions) => _buildPreparingView(test), gameSessionPreparing: (test, questions) => _buildPreparingView(test),
gameSessionActive: (test, questions, currentQuestionIndex, currentResult, questionResults, isAnswerSubmitted, isCorrect, answerFeedbackDelay) => gameSessionActive: (test, questions, currentQuestionIndex, currentResult, questionResults, isAnswerSubmitted, isCorrect, answerFeedbackDelay) =>
_buildActiveGame(state, test, questions, currentQuestionIndex, isAnswerSubmitted, isCorrect, questionResults), _buildActiveGame(
state,
test,
questions,
currentQuestionIndex,
isAnswerSubmitted,
isCorrect,
questionResults,
sessionElapsed,
),
gameSessionCompleted: (test, result) => _buildCompletedView(test, result), gameSessionCompleted: (test, result) => _buildCompletedView(test, result),
),
); );
} }
Widget _buildPreparingView(TestDto test) { Widget _buildPreparingView(TestDto test) {
return Container( final colorScheme = Theme.of(context).colorScheme;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _maxContentWidth),
child: Padding(
padding: EdgeInsets.all(24.w), padding: EdgeInsets.all(24.w),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.play_circle_fill, Icons.play_circle_fill,
size: 80.sp, size: 84.sp,
color: Theme.of(context).colorScheme.primary, color: colorScheme.primary,
), ),
SizedBox(height: 24.h), SizedBox(height: 28.h),
Text( Text(
'Ready to Start?', 'Ready to Start?',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
@ -154,17 +189,22 @@ class _GamePageState extends State<GamePage> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: 32.h), SizedBox(height: 32.h),
ElevatedButton.icon( SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _startGameSession, onPressed: _startGameSession,
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: const Text('Start Game'), label: const Text('Start Game'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 56.h), minimumSize: Size(220.w, 56.h),
textStyle: TextStyle(fontSize: 18.sp), textStyle: TextStyle(fontSize: 18.sp),
), ),
), ),
),
], ],
), ),
),
),
); );
} }
@ -176,51 +216,83 @@ class _GamePageState extends State<GamePage> {
bool isAnswerSubmitted, bool isAnswerSubmitted,
bool isCorrect, bool isCorrect,
Map<String, QuestionResult> questionResults, Map<String, QuestionResult> questionResults,
Duration sessionElapsed,
) { ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final currentQuestion = questions[currentQuestionIndex]; final currentQuestion = questions[currentQuestionIndex];
final questionKey = ValueKey('question_$currentQuestionIndex'); final questionKey = ValueKey('question_$currentQuestionIndex');
return Column( return LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 720;
final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: contentWidth),
child: Column(
children: [ children: [
// Progress indicator // Progress indicator
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
child: Row(
children: [
Expanded(
child: GameProgressIndicator( child: GameProgressIndicator(
currentQuestion: currentQuestionIndex, currentQuestion: currentQuestionIndex,
totalQuestions: questions.length, totalQuestions: questions.length,
correctAnswers: questionResults.values.where((r) => r.isCorrect).length, correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
timeElapsed: const Duration(seconds: 0), // TODO: Track actual time timeElapsed: sessionElapsed,
),
),
SizedBox(width: isNarrow ? 12.w : 20.w),
Tooltip(
message: 'Exit game',
child: IconButton(
onPressed: () => _showExitConfirmation(context),
icon: const Icon(Icons.close),
),
),
],
), ),
), ),
// Question content // Question content
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.all(16.w), padding: EdgeInsets.all(isNarrow ? 12.w : 16.w),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Question display // Question display
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: Container( child: KeyedSubtree(
key: questionKey, key: questionKey,
padding: EdgeInsets.all(16.w), child: Material(
margin: EdgeInsets.only(bottom: 24.h), key: GamePage.questionCardKey,
decoration: BoxDecoration( color: colorScheme.surface,
color: Theme.of(context).colorScheme.surface, surfaceTintColor: colorScheme.surfaceTint,
borderRadius: BorderRadius.circular(16.r), elevation: 3,
boxShadow: [ shadowColor: theme.shadowColor.withOpacity(
BoxShadow( theme.brightness == Brightness.dark ? 0.35 : 0.14,
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
), ),
], 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), child: QuestionDisplay(question: currentQuestion),
), ),
), ),
),
),
SizedBox(height: isNarrow ? 16.h : 20.h),
// Answer input based on question type with smooth transitions // Answer input based on question type with smooth transitions
AnimatedSwitcher( AnimatedSwitcher(
@ -232,7 +304,7 @@ class _GamePageState extends State<GamePage> {
opacity: animation, opacity: animation,
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(0.1, 0), begin: const Offset(0.05, 0),
end: Offset.zero, end: Offset.zero,
).animate(animation), ).animate(animation),
child: child, child: child,
@ -258,7 +330,7 @@ class _GamePageState extends State<GamePage> {
// Navigation buttons (only show for multiple choice after submission) // Navigation buttons (only show for multiple choice after submission)
if (currentQuestion is GameQuestionMultipleChoice && isAnswerSubmitted) ...[ if (currentQuestion is GameQuestionMultipleChoice && isAnswerSubmitted) ...[
SizedBox(height: 24.h), SizedBox(height: isNarrow ? 18.h : 24.h),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -268,7 +340,7 @@ class _GamePageState extends State<GamePage> {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
label: const Text('Previous'), label: const Text('Previous'),
), ),
SizedBox(width: 16.w), SizedBox(width: isNarrow ? 12.w : 16.w),
], ],
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _nextOrFinish, onPressed: _nextOrFinish,
@ -283,6 +355,10 @@ class _GamePageState extends State<GamePage> {
), ),
), ),
], ],
),
),
);
},
); );
} }
@ -290,8 +366,13 @@ class _GamePageState extends State<GamePage> {
final accuracy = result.totalQuestions > 0 final accuracy = result.totalQuestions > 0
? (result.correctAnswers / result.totalQuestions * 100).round() ? (result.correctAnswers / result.totalQuestions * 100).round()
: 0; : 0;
final colorScheme = Theme.of(context).colorScheme;
final scoreColor = _scoreColor(colorScheme, accuracy);
return Container( return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _maxContentWidth),
child: Padding(
padding: EdgeInsets.all(24.w), padding: EdgeInsets.all(24.w),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -309,14 +390,14 @@ class _GamePageState extends State<GamePage> {
height: 120.h, height: 120.h,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: _getScoreColor(accuracy).withOpacity(0.1 * value), color: scoreColor.withOpacity(0.1 * value),
border: Border.all( border: Border.all(
color: _getScoreColor(accuracy), color: scoreColor,
width: 4 * value, width: 4 * value,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: _getScoreColor(accuracy).withOpacity(0.3 * value), color: scoreColor.withOpacity(0.3 * value),
blurRadius: 20 * value, blurRadius: 20 * value,
spreadRadius: 5 * value, spreadRadius: 5 * value,
), ),
@ -334,7 +415,7 @@ class _GamePageState extends State<GamePage> {
style: TextStyle( style: TextStyle(
fontSize: 32.sp, fontSize: 32.sp,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _getScoreColor(accuracy), color: scoreColor,
), ),
); );
}, },
@ -351,7 +432,7 @@ class _GamePageState extends State<GamePage> {
'Score', 'Score',
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
color: _getScoreColor(accuracy), color: scoreColor,
), ),
), ),
), ),
@ -399,11 +480,17 @@ class _GamePageState extends State<GamePage> {
onPressed: () => context.pop(), onPressed: () => context.pop(),
icon: const Icon(Icons.home), icon: const Icon(Icons.home),
label: const Text('Back to Tests'), label: const Text('Back to Tests'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
), ),
], ],
), ),
], ],
), ),
),
),
); );
} }
@ -462,10 +549,10 @@ class _GamePageState extends State<GamePage> {
return result?.selectedAnswer; return result?.selectedAnswer;
} }
Color _getScoreColor(int score) { Color _scoreColor(ColorScheme colorScheme, int score) {
if (score >= 80) return Colors.green; if (score >= 80) return colorScheme.primary;
if (score >= 60) return Colors.orange; if (score >= 60) return colorScheme.tertiary;
return Colors.red; return colorScheme.error;
} }
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {

View file

@ -26,11 +26,6 @@ class _TestPageState extends State<TestPage> {
TestDto? _test; TestDto? _test;
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
bool _isTestStarted = false;
bool _isTestCompleted = false;
int _currentQuestionIndex = 0;
final Map<int, String> _userAnswers = {};
TestResult? _testResult;
@override @override
void initState() { void initState() {
@ -88,14 +83,6 @@ class _TestPageState extends State<TestPage> {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => _handleBack(), onPressed: () => _handleBack(),
), ),
actions: _isTestStarted && !_isTestCompleted
? [
TextButton(
onPressed: _finishTest,
child: const Text('Finish'),
),
]
: null,
), ),
body: _buildBody(), body: _buildBody(),
); );
@ -132,18 +119,10 @@ class _TestPageState extends State<TestPage> {
); );
} }
if (_isTestCompleted && _testResult != null) { return _buildTestOverview();
return _buildTestResults();
} }
if (_isTestStarted) { Widget _buildTestOverview() {
return _buildTestQuestion();
}
return _buildTestIntro();
}
Widget _buildTestIntro() {
final test = _test!; final test = _test!;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -182,16 +161,13 @@ class _TestPageState extends State<TestPage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'• Read each question carefully\n' '• Start the interactive game to answer cards\n'
'• Select the best answer\n' '• Audio and hints follow your theme settings\n'
'• You can go back to previous questions\n' '• Progress is saved automatically during play\n'
'• Click "Finish" when you\'re done\n' '• You can exit any time and restart later',
'• Your progress will be saved automatically',
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Column(
children: [
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@ -207,20 +183,32 @@ class _TestPageState extends State<TestPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( Container(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( padding: const EdgeInsets.all(16),
onPressed: _startTest, decoration: BoxDecoration(
icon: const Icon(Icons.quiz), color: Theme.of(context).colorScheme.surfaceContainerHighest,
label: const Text('Take Traditional Test'), borderRadius: BorderRadius.circular(12),
style: OutlinedButton.styleFrom( border: Border.all(
minimumSize: const Size.fromHeight(48), color: Theme.of(context).colorScheme.outlineVariant,
textStyle: const TextStyle(fontSize: 16),
), ),
), ),
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<TestPage> {
} }
void _playGame(BuildContext context) { void _playGame(BuildContext context) {
// Navigate to the game page
context.go('/game/${widget.testId}'); 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<void> _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<AppScopeContainer>(
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() {
context.pop();
}
void _handleBack() { void _handleBack() {
if (_isTestStarted && !_isTestCompleted) {
_showExitConfirmation();
} else {
context.pop(); context.pop();
} }
}
void _showExitConfirmation() {
showDialog<void>(
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;
} }

View file

@ -1,10 +1,13 @@
import 'dart:developer';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.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 '../../di/user_scope/user_scope.dart';
import '../../domain/config/api_config_v2.dart'; import '../../domain/config/api_config_v2.dart';
import '../../presentation/theme/app_colors.dart'; import '../../presentation/theme/app_colors.dart';
@ -490,6 +493,12 @@ class _CardSide extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CardVoiceControls(
packId: packId,
cardId: card.id,
accentColor: packColor,
),
const SizedBox(height: 12),
if (card.original != null && card.original!.isNotEmpty) if (card.original != null && card.original!.isNotEmpty)
MnemoText( MnemoText(
card.original, 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<CardVoiceControls> createState() => _CardVoiceControlsState();
}
class _CardVoiceControlsState extends State<CardVoiceControls> {
late Future<List<VoiceDto>> _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<List<VoiceDto>> _loadVoices() async {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
throw Exception('Scope not available');
}
return appScope.httpRepository.getCardVoices(
widget.packId,
widget.cardId,
);
}
Future<void> _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<void> _stop() async {
await _player.stop();
if (!mounted) {
return;
}
setState(() {
_isPlaying = false;
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<VoiceDto>>(
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 <VoiceDto>[];
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),
),
],
),
),
);
}
}

View file

@ -49,6 +49,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" 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: auto_route:
dependency: transitive dependency: transitive
description: description:

View file

@ -61,6 +61,7 @@ dependencies:
auto_size_text: ^3.0.0 auto_size_text: ^3.0.0
fl_chart: ^0.68.0 fl_chart: ^0.68.0
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
audioplayers: ^6.1.0
# Utils # Utils
universal_image: ^1.0.10 universal_image: ^1.0.10

View file

@ -3,13 +3,17 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.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/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_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/models/game_question.dart';
import 'package:mnemo_cards_web_v2/domain/state/tests_state_manager.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_web_v2/presentation/pages/game/game_page.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:yx_scope/yx_scope.dart';
// Mock classes // Mock classes
class MockAppScopeContainer extends Mock implements AppScopeContainer {} class MockAppScopeContainer extends Mock implements AppScopeContainer {}
@ -18,39 +22,63 @@ class MockUserScopeHolder extends Mock implements UserScopeHolder {}
class MockUserScope extends Mock implements UserScope {} class MockUserScope extends Mock implements UserScope {}
class MockTestsModule extends Mock {} class MockGameSoundService extends Mock implements GameSoundService {}
class MockTestsStateManager extends Mock implements TestsStateManager {} 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() { void main() {
late MockAppScopeContainer mockAppScope; late MockAppScopeContainer mockAppScope;
late MockUserScopeHolder mockUserScopeHolder; late MockUserScopeHolder mockUserScopeHolder;
late MockUserScope mockUserScope; late MockUserScope mockUserScope;
late MockTestsModule mockTestsModule;
late MockTestsStateManager mockTestsStateManager; late MockTestsStateManager mockTestsStateManager;
late MockGameSoundService mockGameSoundService;
late FakeTestsModule fakeTestsModule;
setUp(() { setUp(() {
mockAppScope = MockAppScopeContainer(); mockAppScope = MockAppScopeContainer();
mockUserScopeHolder = MockUserScopeHolder(); mockUserScopeHolder = MockUserScopeHolder();
mockUserScope = MockUserScope(); mockUserScope = MockUserScope();
mockTestsModule = MockTestsModule();
mockTestsStateManager = MockTestsStateManager(); mockTestsStateManager = MockTestsStateManager();
mockGameSoundService = MockGameSoundService();
fakeTestsModule = FakeTestsModule(
testsStateManager: mockTestsStateManager,
gameSoundService: mockGameSoundService,
);
// Setup the mock chain // Setup the mock chain
when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder); when(() => mockAppScope.userScopeHolder).thenReturn(mockUserScopeHolder);
when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope); when(() => mockUserScopeHolder.scope).thenReturn(mockUserScope);
when(() => mockUserScope.testsModule).thenReturn(mockTestsModule); when(() => mockUserScope.testsModule).thenReturn(fakeTestsModule);
when(() => mockTestsModule.testsStateManager).thenReturn(mockTestsStateManager); when(() => mockTestsStateManager.state).thenReturn(const TestsState.loading());
when(() => mockTestsStateManager.startGameSession(any<String>())).thenAnswer((_) async {});
when(() => mockTestsStateManager.resetGameSession()).thenAnswer((_) async {});
when(() => mockTestsStateManager.submitAnswer(any<String>())).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 // Initialize screen util
FlutterScreenUtil.init( _initScreenUtil();
const BoxConstraints(
maxWidth: 375,
maxHeight: 812,
),
designSize: const Size(375, 812),
minTextAdapt: true,
);
}); });
group('GamePage', () { group('GamePage', () {
@ -77,6 +105,47 @@ void main() {
expect(find.text('Start Game'), findsOneWidget); 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<AppScopeContainer>.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 { testWidgets('should display active game state', (tester) async {
final questions = [ final questions = [
GameQuestion.multipleChoice( GameQuestion.multipleChoice(
@ -121,6 +190,122 @@ void main() {
expect(find.text('5'), findsOneWidget); 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<AppScopeContainer>.value(value: mockAppScope),
],
child: MaterialApp(
theme: theme,
home: const GamePage(testId: 'test1'),
),
),
);
await tester.pumpAndSettle();
final material = tester.widget<Material>(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<AppScopeContainer>.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<AppScopeContainer>.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 { testWidgets('should display completed game state', (tester) async {
final result = GameSessionResult( final result = GameSessionResult(
testId: 'test1', testId: 'test1',
@ -227,3 +412,8 @@ void main() {
}); });
}); });
} }
Future<void> _initScreenUtil() async {
TestWidgetsFlutterBinding.ensureInitialized();
ScreenUtil.ensureScreenSize();
}

View file

@ -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<AppScopeContainer?> 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<String>())).thenAnswer(
(_) async => TestDto(
id: '42',
name: 'Sample Test',
questions: const <AbstractTestQuestion>[],
),
);
appScopeHolder = ScopeStateHolder<AppScopeContainer?>(
ScopeState.available(scope: mockAppScope),
);
});
group('TestPage', () {
testWidgets('shows interactive-only notice', (tester) async {
final router = _buildRouter();
await tester.pumpWidget(
ScopeProvider<AppScopeContainer>(
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<AppScopeContainer>(
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']}'),
),
),
),
],
);
}