fixes
This commit is contained in:
parent
183a59381b
commit
0159aff93e
19 changed files with 1422 additions and 948 deletions
|
|
@ -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/<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 tests for a pack
|
||||
@Route.get('/packs/<packId>/tests')
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
|
|||
r'/packs/<packId>/cards/<cardId>/image',
|
||||
service.getCardImage,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards/<cardId>/voices',
|
||||
service.getCardVoices,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/voice/<voiceId>',
|
||||
service.getVoiceFile,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/tests',
|
||||
|
|
|
|||
|
|
@ -129,32 +129,6 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -205,6 +179,32 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -520,6 +520,43 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -779,84 +816,6 @@ paths:
|
|||
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!"
|
||||
/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/<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: { }
|
||||
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: AdminAnalyticsApiV2
|
||||
description: Admin endpoints for analytics and statistics in API v2.
|
||||
- name: TasksApiV2
|
||||
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
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class IsarConnector {
|
|||
[
|
||||
CardPackModelSchema,
|
||||
GameCardModelSchema,
|
||||
VoiceModelSchema,
|
||||
UserModelSchema,
|
||||
UserSubscriptionModelSchema,
|
||||
SubscriptionPlanModelSchema,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<CardPackModel> packs = IsarLinks<CardPackModel>();
|
||||
final IsarLinks<VoiceModel> voices = IsarLinks<VoiceModel>();
|
||||
|
||||
GameCardModel({
|
||||
required this.image,
|
||||
|
|
|
|||
|
|
@ -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<IsarLinkBase<dynamic>> _gameCardModelGetLinks(GameCardModel object) {
|
||||
return [object.packs];
|
||||
return [object.packs, object.voices];
|
||||
}
|
||||
|
||||
void _gameCardModelAttach(
|
||||
IsarCollection<dynamic> col, Id id, GameCardModel object) {
|
||||
object.id = 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
|
||||
|
|
@ -1718,6 +1725,67 @@ extension GameCardModelQueryLinks
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? params) {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
void startSession(String testId, List<GameQuestion> 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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Future<CardPackBuyDto?> getPackBuy(String packId) async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<TestsState> {
|
|||
// 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<TestsState> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class GamePage extends StatefulWidget {
|
|||
});
|
||||
|
||||
final String testId;
|
||||
static const questionCardKey = Key('game_question_card');
|
||||
|
||||
@override
|
||||
State<GamePage> createState() => _GamePageState();
|
||||
|
|
@ -33,6 +34,7 @@ class GamePage extends StatefulWidget {
|
|||
|
||||
class _GamePageState extends State<GamePage> {
|
||||
GameSoundService? _soundService;
|
||||
double _maxContentWidth = 880;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -65,8 +67,22 @@ class _GamePageState extends State<GamePage> {
|
|||
}
|
||||
|
||||
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<GamePage> {
|
|||
}
|
||||
|
||||
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<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...'),
|
||||
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<GamePage> {
|
|||
bool isAnswerSubmitted,
|
||||
bool isCorrect,
|
||||
Map<String, QuestionResult> 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<Offset>(
|
||||
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<Offset>(
|
||||
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<GamePage> {
|
|||
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<double>(
|
||||
tween: Tween<double>(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<int>(
|
||||
tween: Tween<int>(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<double>(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<double>(
|
||||
tween: Tween<double>(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<int>(
|
||||
tween: Tween<int>(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<double>(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<GamePage> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ class _TestPageState extends State<TestPage> {
|
|||
TestDto? _test;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
bool _isTestStarted = false;
|
||||
bool _isTestCompleted = false;
|
||||
int _currentQuestionIndex = 0;
|
||||
final Map<int, String> _userAnswers = {};
|
||||
TestResult? _testResult;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -88,14 +83,6 @@ class _TestPageState extends State<TestPage> {
|
|||
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<TestPage> {
|
|||
);
|
||||
}
|
||||
|
||||
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<TestPage> {
|
|||
),
|
||||
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<TestPage> {
|
|||
}
|
||||
|
||||
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<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() {
|
||||
void _handleBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
if (_isTestStarted && !_isTestCompleted) {
|
||||
_showExitConfirmation();
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<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 {
|
||||
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<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 {
|
||||
final result = GameSessionResult(
|
||||
testId: 'test1',
|
||||
|
|
@ -227,3 +412,8 @@ void main() {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initScreenUtil() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
ScreenUtil.ensureScreenSize();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue