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: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')
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class IsarConnector {
|
||||||
[
|
[
|
||||||
CardPackModelSchema,
|
CardPackModelSchema,
|
||||||
GameCardModelSchema,
|
GameCardModelSchema,
|
||||||
|
VoiceModelSchema,
|
||||||
UserModelSchema,
|
UserModelSchema,
|
||||||
UserSubscriptionModelSchema,
|
UserSubscriptionModelSchema,
|
||||||
SubscriptionPlanModelSchema,
|
SubscriptionPlanModelSchema,
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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