tests fix
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-20 20:58:12 +03:00
parent 5e3061165f
commit f5bd53828c
22 changed files with 2394 additions and 642 deletions

View file

@ -128,6 +128,12 @@
- Backend generator picks cards from pack pool and stores `matrixSize` in `uiData`
- Web UI: image grid + target `original` word; wrong = shake + error sound; correct = flip to translation then disappear
- Admin: question type selector + matrix size form; JSON (de)serialization + vitest coverage
- **Matrix Question Types**: Added support for different matrix question types
- Created `MatrixQuestionType` enum with 5 types: original_translation, original_images, translation_originals, audio_images, audio_translation
- Backend generator now creates stages array with all target words/audio upfront
- MatrixCardDto adaptively fills only needed fields based on question type (text-only, image-only, or both)
- Web UI supports stages navigation, audio playback with repeat button, and adaptive card display
- Added MatrixStageDto for backend and MatrixStage domain model for client
- **CardViewer Carousel Looping**: Implemented infinite carousel scrolling for card viewer
- Cards now loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons
@ -170,6 +176,11 @@
- Added DAO helpers and a focused unit test for the cleanup logic
- Added regression test to ensure `updateGeneratedTests()` creates a pack link
- **Matrix Test (image selection)**: Added matrix question generator + API storage support (cards auto-generated if missing)
- **Matrix Question Types**: Enhanced matrix question generator with multiple question types
- Added MatrixQuestionType enum with extension methods for type checking
- Generator creates stages array with all target words/audio for multi-step questions
- Supports original_translation, original_images, translation_originals, audio_images, audio_translation types
- MatrixCardDto adaptively fills fields based on question type (only needed content)
- **MinIO Migration**: Migrated file storage from local filesystem to MinIO object storage
- Created MinioService for MinIO operations (upload, presigned URLs, delete, fileExists)
- Created MediaApiV2 with endpoints for file uploads (card-image, test-image, voice) and presigned URL generation
@ -267,4 +278,4 @@
---
*Last updated: December 19, 2025*
*Last updated: December 20, 2025*

View file

@ -176,6 +176,12 @@
- Auto-generate matrix images from pack card pool (generator framework)
- Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
- Admin: matrix size configuration + JSON (de)serialization + tests
- [x] **Matrix Question Types**: Added support for different matrix question types
- Created MatrixQuestionType enum with 5 types (original_translation, original_images, translation_originals, audio_images, audio_translation)
- Backend generates stages array with all target words/audio upfront
- MatrixCardDto adaptively fills only needed fields based on question type
- Web UI supports stages navigation, audio playback, and adaptive card display
- Need to add unit tests for generator and all question types
- [x] **CardViewer Carousel Looping**: Implement infinite carousel scrolling for card viewer
- Cards loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons
@ -239,4 +245,4 @@
---
*Last updated: December 19, 2025*
*Last updated: December 20, 2025*

View file

@ -37,8 +37,24 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
}
InputButtonsQuestionType _getRandomQuestionType() {
final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values);
InputButtonsQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) {
var types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values);
// Exclude audio types if audio is not available
if (excludeAudioTypes) {
types = types.where((type) => !type.audioQuestion).toList();
}
// If no types available, fallback to non-audio types
if (types.isEmpty) {
types = InputButtonsQuestionType.values.where((type) => !type.audioQuestion).toList();
}
// If still empty, use translation_original as fallback
if (types.isEmpty) {
return InputButtonsQuestionType.translation_original;
}
return types[random.nextInt(types.length)];
}
@ -66,35 +82,44 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
.take(4)
.toList();
// Check if audio is available before selecting question type
final hasAudio = _getAudioUuid(answerItem) != null;
// mb use custom ids
final questionType = type ?? _getRandomQuestionType();
// If no type specified, select random type excluding audio types if audio is not available
final questionType = type ?? _getRandomQuestionType(excludeAudioTypes: !hasAudio);
// If audio type was selected but audio is not available, select a different type
final finalQuestionType = (questionType.audioQuestion && !hasAudio)
? _getRandomQuestionType(excludeAudioTypes: true)
: questionType;
final questionText =
questionType.translationQuestion ? answerItem.translation : null;
finalQuestionType.translationQuestion ? answerItem.translation : null;
final questionImage =
questionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null;
finalQuestionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null;
String? questionAudio = null;
if (questionType.audioQuestion) {
if (finalQuestionType.audioQuestion) {
// Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerItem);
} else if (withAudio) {
// nothing to fo here
}
final answer = questionType.translationAnswer
final answer = finalQuestionType.translationAnswer
? answerItem.translation
: answerItem.original;
String template = answer.asTemplate;
if (showArticle &&
answer.contains(' ') &&
!questionType.translationAnswer) {
!finalQuestionType.translationAnswer) {
final article = answer.split(' ').firstOrNull;
if (article != null) {
template =
'$article ${answer.substring(article.length + 1).asTemplate}';
}
}
if (visibleButtonsPercent > 0 && !questionType.translationAnswer) {
if (visibleButtonsPercent > 0 && !finalQuestionType.translationAnswer) {
var matches = '_'.allMatches(template).toList();
int visibleLetters = (visibleButtonsPercent * matches.length).floor();
while (visibleLetters-- > 0) {
@ -115,7 +140,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
word: answerItem.original,
audio: questionAudio,
template: template,
buttons: (questionType.translationAnswer
buttons: (finalQuestionType.translationAnswer
? [
...answerItem.translation.split(''),
...otherCards.expand((c) => c.translation.split('').toSet())
@ -124,7 +149,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
...answerItem.original.split(''),
...otherCards.expand((c) => c.original.split('').toSet()),
])
.map((e) => e.trim())
.map((e) => e.trim().toLowerCase())
.where((ch) => ch.isNotEmpty)
.take(20)
.mapIndexed((index, ch) => TestButtonDto('${ch}_$index', null, ch))

View file

@ -5,20 +5,59 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../models/creation_test_data.dart';
import 'question_generator.dart';
/// Generates a matrix-image question:
/// - shows N x N images (cards from the pack pool)
/// - shows a target word (original) under the matrix
enum MatrixQuestionType {
original_translation, // оригинал снизу, переведенные карточки
original_images, // оригинал снизу, изображения на карточках
translation_originals, // перевод снизу, карточки с оригиналом
audio_images, // аудио снизу, карточки с изображениями
audio_translation, // аудио снизу, карточки с переводами
}
extension MatrixQuestionTypeExtension on MatrixQuestionType {
bool get originalTarget => [
MatrixQuestionType.original_translation,
MatrixQuestionType.original_images,
].contains(this);
bool get translationTarget => [
MatrixQuestionType.translation_originals,
].contains(this);
bool get audioTarget => [
MatrixQuestionType.audio_images,
MatrixQuestionType.audio_translation,
].contains(this);
bool get translationCards => [
MatrixQuestionType.original_translation,
MatrixQuestionType.audio_translation,
].contains(this);
bool get originalCards => [
MatrixQuestionType.translation_originals,
].contains(this);
bool get imageCards => [
MatrixQuestionType.original_images,
MatrixQuestionType.audio_images,
].contains(this);
}
/// Generates a matrix question:
/// - shows N x N cards (from the pack pool)
/// - shows a target word/audio under the matrix
/// - user must click the matching card
///
/// The question is multi-step on the client side: after a correct click,
/// the card disappears and a new target word is selected from remaining cards.
/// Backend only provides the initial target via [word] + [answer].
/// the card flips and a new target is shown from stages array.
/// Backend generates all stages upfront.
class MatrixQuestionGenerator implements QuestionGenerator {
MatrixQuestionGenerator(
this.data, {
this.seed,
this.allowedSizes = const [2, 3, 4],
this.fixedMatrixSize,
this.possibleTypes,
}) : random = Random(seed);
final CreationTestData data;
@ -32,11 +71,55 @@ class MatrixQuestionGenerator implements QuestionGenerator {
/// items exist).
final int? fixedMatrixSize;
/// Allowed question types. If null, all types are allowed.
final Set<MatrixQuestionType>? possibleTypes;
// UUID regex pattern (same as in simple_question_generator.dart)
static final _uuidRegex = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
caseSensitive: false,
);
String? _imageIdToUrl(String? imageId) {
if (imageId == null || data.packId == null) return imageId;
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
}
/// Checks if a string is a valid UUID
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
/// Gets audio UUID from answerCard.audio if it's a valid UUID, otherwise returns null
String? _getAudioUuid(TestDataItem answerCard) {
final audioValue = answerCard.audio;
if (audioValue == null || audioValue.trim().isEmpty) return null;
return _isUuid(audioValue) ? audioValue.trim() : null;
}
MatrixQuestionType _getRandomQuestionType({
required List<TestDataItem> selectedCards,
}) {
var types = (possibleTypes?.toList() ?? MatrixQuestionType.values);
// Check if we have audio available
final hasAudio = selectedCards.any((card) => _getAudioUuid(card) != null);
if (!hasAudio) {
types = types.where((type) => !type.audioTarget).toList();
}
// Check if we have images available
final hasImages = selectedCards.any((card) => card.image != null);
if (!hasImages) {
types = types.where((type) => !type.imageCards).toList();
}
// If no types available, fallback to original_translation
if (types.isEmpty) {
return MatrixQuestionType.original_translation;
}
return types[random.nextInt(types.length)];
}
int _maxPossibleSize(int poolSize) {
if (poolSize <= 0) return 0;
return sqrt(poolSize).floor();
@ -57,6 +140,40 @@ class MatrixQuestionGenerator implements QuestionGenerator {
return allowed[random.nextInt(allowed.length)];
}
List<MatrixStageDto> _generateStages(
List<TestDataItem> selectedCards,
MatrixQuestionType questionType,
) {
final stages = <MatrixStageDto>[];
final shuffled = List<TestDataItem>.from(selectedCards)..shuffle(random);
for (final card in shuffled) {
String targetWord;
String? targetAudio;
if (questionType.originalTarget) {
targetWord = card.original;
} else if (questionType.translationTarget) {
targetWord = card.translation;
} else if (questionType.audioTarget) {
targetWord = card.original; // fallback text
targetAudio = _getAudioUuid(card);
} else {
targetWord = card.original; // fallback
}
stages.add(
MatrixStageDto(
targetCardId: card.id,
targetWord: targetWord,
targetAudio: targetAudio,
),
);
}
return stages;
}
@override
Future<AbstractTestQuestion> generate(TestDataItem answerCard) async {
final pool = data.items;
@ -73,23 +190,58 @@ class MatrixQuestionGenerator implements QuestionGenerator {
final selected = <TestDataItem>[answerCard, ...others]..shuffle(random);
final cards = selected
.map(
(c) => MatrixCardDto(
id: c.id,
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
original: c.original,
translation: c.translation,
),
)
.toList();
// Determine question type based on available data
final questionType = _getRandomQuestionType(selectedCards: selected);
// Generate stages for all cards
final stages = _generateStages(selected, questionType);
// Create cards based on question type
final cards = selected.map((c) {
if (questionType.translationCards) {
return MatrixCardDto(
id: c.id,
translation: c.translation,
// Don't include image or original for translation cards
);
} else if (questionType.originalCards) {
return MatrixCardDto(
id: c.id,
original: c.original,
// Don't include image or translation for original cards
);
} else if (questionType.imageCards) {
return MatrixCardDto(
id: c.id,
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
// Don't include text for image cards
);
} else {
// Fallback: include all fields
return MatrixCardDto(
id: c.id,
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
original: c.original,
translation: c.translation,
);
}
}).toList();
// Get first stage for backward compatibility
final firstStage = stages.isNotEmpty ? stages.first : MatrixStageDto(
targetCardId: answerCard.id,
targetWord: answerCard.original,
targetAudio: null,
);
return MatrixTestQuestionBody(
matrixSize: matrixSize,
cards: cards,
// First step: target is the provided answerCard.
word: answerCard.original,
answer: answerCard.id,
stages: stages,
// Backward compatibility fields
word: firstStage.targetWord,
answer: firstStage.targetCardId,
text: 'Найди слово:',
);
}
}

View file

@ -37,8 +37,24 @@ class SimpleQuestionGenerator implements QuestionGenerator {
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
}
SimpleQuestionType _getRandomQuestionType() {
final types = (possibleTypes?.toList() ?? SimpleQuestionType.values);
SimpleQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) {
var types = (possibleTypes?.toList() ?? SimpleQuestionType.values);
// Exclude audio types if audio is not available
if (excludeAudioTypes) {
types = types.where((type) => !type.audioQuestion).toList();
}
// If no types available, fallback to non-audio types
if (types.isEmpty) {
types = SimpleQuestionType.values.where((type) => !type.audioQuestion).toList();
}
// If still empty, use original_translation as fallback
if (types.isEmpty) {
return SimpleQuestionType.original_translation;
}
return types[random.nextInt(types.length)];
}
@ -62,28 +78,38 @@ class SimpleQuestionGenerator implements QuestionGenerator {
(data.items.where((element) => element.id != answerCard.id).toList()
..shuffle())
.take(3);
// Check if audio is available before selecting question type
final hasAudio = _getAudioUuid(answerCard) != null;
// mb use custom ids
final questionType = type ?? _getRandomQuestionType();
final questionText = questionType.originalQuestion
// If no type specified, select random type excluding audio types if audio is not available
final questionType = type ?? _getRandomQuestionType(excludeAudioTypes: !hasAudio);
// If audio type was selected but audio is not available, select a different type
final finalQuestionType = (questionType.audioQuestion && !hasAudio)
? _getRandomQuestionType(excludeAudioTypes: true)
: questionType;
final questionText = finalQuestionType.originalQuestion
? answerCard.original
: questionType.translationQuestion
: finalQuestionType.translationQuestion
? answerCard.translation
: null;
final questionImage =
questionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null;
finalQuestionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null;
String? questionAudio = null;
if (questionType.audioQuestion) {
if (questionType.translationAnswers || questionType.imagesAnswers) {
if (finalQuestionType.audioQuestion) {
if (finalQuestionType.translationAnswers || finalQuestionType.imagesAnswers) {
// Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerCard);
}
} else if (withAudio) {
if (questionText != null && questionType.originalQuestion) {
if (questionText != null && finalQuestionType.originalQuestion) {
// For original questions with text, audio is handled separately
// Use UUID if available, otherwise null
questionAudio = _getAudioUuid(answerCard);
} else if (questionType.translationAnswers ||
questionType.imagesAnswers) {
} else if (finalQuestionType.translationAnswers ||
finalQuestionType.imagesAnswers) {
// Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerCard);
}
@ -97,15 +123,15 @@ class SimpleQuestionGenerator implements QuestionGenerator {
audio: questionAudio,
word: answerCard.original,
buttons: [
if (questionType.translationAnswers)
if (finalQuestionType.translationAnswers)
...answers.map(
(e) => TestButtonDto.text(e.id, e.translation),
)
else if (questionType.originalAnswers)
else if (finalQuestionType.originalAnswers)
...answers.map(
(e) => TestButtonDto.text(e.id, e.original),
)
else if (questionType.imagesAnswers)
else if (finalQuestionType.imagesAnswers)
...answers.map(
(e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)),
)

View file

@ -43,6 +43,7 @@ void main() {
expect(mq.answer, answerCard.id);
expect(mq.word, answerCard.original);
expect(mq.text, 'Найди слово:');
expect(ids.contains(answerCard.id), isTrue);
});

View file

@ -0,0 +1,170 @@
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
import 'package:mnemo_cards_backend/tests/generators/question_generators/simple_question_generator.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:test/test.dart';
void main() {
group('SimpleQuestionGenerator - audio generation', () {
test('generates audio question with UUID from MinIO', () async {
final audioUuid = '550e8400-e29b-41d4-a716-446655440000';
final data = CreationTestData(
packId: 'p1',
title: 'Pack',
color: '#fff',
items: [
TestDataItem(
id: 'card_1',
original: 'la Gama',
translation: 'Лань',
image: 'card_1',
audio: audioUuid, // UUID from MinIO
),
TestDataItem(
id: 'card_2',
original: 'el Cerdo',
translation: 'Свинья',
image: 'card_2',
audio: 'not-a-uuid', // Not a UUID, should be ignored
),
TestDataItem(
id: 'card_3',
original: 'la Cabra',
translation: 'Коза',
image: 'card_3',
audio: null, // No audio
),
TestDataItem(
id: 'card_4',
original: 'el Pescado',
translation: 'Рыба',
image: 'card_4',
audio: 'another-uuid-not-valid', // Not a valid UUID format
),
],
);
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
);
final answerCard = data.items.first; // card_1 with UUID audio
final q = await generator.generate(answerCard);
expect(q, isA<SimpleTestQuestionBody>());
final sq = q as SimpleTestQuestionBody;
// For audio_translation type, audio should be the UUID
expect(sq.audio, equals(audioUuid));
expect(sq.text, isNull); // No text for audio questions
expect(sq.word, equals('la Gama'));
});
test('generates audio question with null when audio is not UUID', () async {
final data = CreationTestData(
packId: 'p1',
title: 'Pack',
color: '#fff',
items: [
TestDataItem(
id: 'card_1',
original: 'la Gama',
translation: 'Лань',
image: 'card_1',
audio: 'es-ES_la Gama', // Old format, not UUID
),
],
);
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
);
final answerCard = data.items.first;
final q = await generator.generate(answerCard);
expect(q, isA<SimpleTestQuestionBody>());
final sq = q as SimpleTestQuestionBody;
// Should be null since audio is not a valid UUID
expect(sq.audio, isNull);
});
test('generates audio question with null when audio is null', () async {
final data = CreationTestData(
packId: 'p1',
title: 'Pack',
color: '#fff',
items: [
TestDataItem(
id: 'card_1',
original: 'la Gama',
translation: 'Лань',
image: 'card_1',
audio: null, // No audio
),
],
);
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_translation,
},
);
final answerCard = data.items.first;
final q = await generator.generate(answerCard);
expect(q, isA<SimpleTestQuestionBody>());
final sq = q as SimpleTestQuestionBody;
// Should be null when audio is null
expect(sq.audio, isNull);
});
test('generates audio_images question with UUID', () async {
final audioUuid = '660e8400-e29b-41d4-a716-446655440000';
final data = CreationTestData(
packId: 'p1',
title: 'Pack',
color: '#fff',
items: [
TestDataItem(
id: 'card_1',
original: 'la Gama',
translation: 'Лань',
image: 'card_1',
audio: audioUuid,
),
],
);
final generator = SimpleQuestionGenerator(
data,
seed: 1,
possibleTypes: {
SimpleQuestionType.audio_images,
},
);
final answerCard = data.items.first;
final q = await generator.generate(answerCard);
expect(q, isA<SimpleTestQuestionBody>());
final sq = q as SimpleTestQuestionBody;
// For audio_images type, audio should be the UUID
expect(sq.audio, equals(audioUuid));
expect(sq.text, isNull); // No text for audio questions
});
});
}

View file

@ -0,0 +1,305 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:test/test.dart';
void main() {
late AppDatabase db;
late TestManager testManager;
late MinioService minioService;
setUpAll(() async {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port =
int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database =
Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ??
Platform.environment['DB_USER'] ??
'mnemo_user';
final password = Platform.environment['TEST_DB_PASSWORD'] ??
Platform.environment['DB_PASSWORD'] ??
'';
db = AppDatabase.connect(
host: host,
port: port,
database: database,
username: username,
password: password,
);
await db.customStatement('CREATE EXTENSION IF NOT EXISTS pgcrypto');
await Migrator(db).createAll();
// Initialize MinioService
final minioEndpoint = Platform.environment['MINIO_ENDPOINT'] ??
'localhost:9000';
final minioAccessKey = Platform.environment['MINIO_ACCESS_KEY'] ??
'minioadmin';
final minioSecretKey = Platform.environment['MINIO_SECRET_KEY'] ??
'minioadmin';
minioService = MinioService(
endpoint: minioEndpoint,
accessKey: minioAccessKey,
secretKey: minioSecretKey,
useSSL: false,
);
testManager = TestManager(db, minioService);
});
tearDownAll(() async {
await db.close();
});
group('TestManager image conversion', () {
String? packId;
String? cardId;
String? testId;
tearDown(() async {
// Cleanup
if (testId != null) {
await (db.delete(db.tests)..where((t) => t.id.equals(testId!))).go();
testId = null;
}
if (packId != null) {
await (db.delete(db.cardPacks)..where((p) => p.id.equals(packId!)))
.go();
packId = null;
}
if (cardId != null) {
await (db.delete(db.gameCards)..where((c) => c.id.equals(cardId!)))
.go();
cardId = null;
}
});
test('matrix question buttons should have imageUrl from card images',
() async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'subtitle',
size: 1,
),
);
// Create card with image (using UUID as objectId in MinIO)
const imageObjectId = '12345678-1234-1234-1234-123456789abc';
cardId = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'perro',
translation: 'dog',
image: imageObjectId,
),
);
await db.packDao.addCardToPack(
packId: packId!,
cardId: cardId!,
order: 0,
);
// Create test with matrix question (without buttons - they'll be generated)
testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'Matrix Test',
color: const Value('#ff0000'),
version: const Value('1.0'),
),
);
await db.testDao.linkTestToPack(testId!, packId!);
// Create matrix question without buttons
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId!,
orderIndex: const Value(0),
questionType: TestQuestionType.matrix.name,
word: 'test',
answer: '',
options: const Value('[]'), // Empty buttons - will be generated
uiData: const Value('{"matrixSize": 1}'),
),
);
// Fetch test
final user = UserModel(id: 'test-user');
final result = await testManager.fetchTest(testId!, user);
expect(result, isNotNull);
expect(result!.questions, hasLength(1));
final question = result.questions.first;
expect(question.questionType, equals(TestQuestionType.matrix));
// Check buttons
if (question is MatrixTestQuestion) {
expect(question.buttons, hasLength(1));
final button = question.buttons.first;
expect(button.id, equals(cardId));
expect(button.image, equals(imageObjectId));
expect(button.imageUrl, isNotNull);
expect(button.imageUrl, isNot(equals(imageObjectId)));
// imageUrl should be a presigned URL or API endpoint
expect(
button.imageUrl,
anyOf(
startsWith('http'),
startsWith('/api/v2/packs'),
),
);
} else {
fail('Expected MatrixTestQuestion');
}
});
test('test question with image should have imageUrl', () async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'subtitle',
size: 1,
),
);
// Create card (needed for question buttons)
cardId = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'perro',
translation: 'dog',
image: 'test.jpg',
),
);
await db.packDao.addCardToPack(
packId: packId!,
cardId: cardId!,
order: 0,
);
// Create test with input_buttons question
testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'Input Test',
color: const Value('#ff0000'),
version: const Value('1.0'),
),
);
await db.testDao.linkTestToPack(testId!, packId!);
const imageObjectId = '87654321-4321-4321-4321-cba987654321';
// Create question with image
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId!,
orderIndex: const Value(0),
questionType: TestQuestionType.input_buttons.name,
word: 'perro',
answer: 'perro',
options: const Value('[]'),
uiData: Value('{"image": "$imageObjectId", "text": "dog"}'),
),
);
// Fetch test
final user = UserModel(id: 'test-user');
final result = await testManager.fetchTest(testId!, user);
expect(result, isNotNull);
expect(result!.questions, hasLength(1));
final question = result.questions.first;
expect(question.questionType, equals(TestQuestionType.input_buttons));
// Check image and imageUrl
if (question is InputButtonsTestQuestion) {
expect(question.image, equals(imageObjectId));
expect(question.imageUrl, isNotNull);
expect(question.imageUrl, isNot(equals(imageObjectId)));
// imageUrl should be a presigned URL
expect(question.imageUrl, startsWith('http'));
} else {
fail('Expected InputButtonsTestQuestion');
}
});
test('test with cover should have coverUrl', () async {
// Create pack
packId = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'subtitle',
size: 1,
),
);
// Create card (needed for test generation)
cardId = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'perro',
translation: 'dog',
image: 'test.jpg',
),
);
await db.packDao.addCardToPack(
packId: packId!,
cardId: cardId!,
order: 0,
);
const coverObjectId = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
// Create test with cover
testId = await db.testDao.createTest(
TestsCompanion.insert(
name: 'Test with Cover',
color: const Value('#ff0000'),
version: const Value('1.0'),
cover: Value(coverObjectId),
),
);
await db.testDao.linkTestToPack(testId!, packId!);
// Create a simple question
await db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId!,
orderIndex: const Value(0),
questionType: TestQuestionType.simple.name,
word: 'test',
answer: 'answer',
options: const Value('[]'),
uiData: const Value('{}'),
),
);
// Fetch test
final user = UserModel(id: 'test-user');
final result = await testManager.fetchTest(testId!, user);
expect(result, isNotNull);
expect(result!.cover, equals(coverObjectId));
expect(result.coverUrl, isNotNull);
expect(result.coverUrl, isNot(equals(coverObjectId)));
// coverUrl should be a presigned URL
expect(result.coverUrl, startsWith('http'));
});
});
}

View file

@ -20,18 +20,29 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
@JsonKey(name: 'buttons', defaultValue: <MatrixCardDto>[])
final List<MatrixCardDto> cards;
/// Stages for multi-step question. Each stage defines the target word/audio
/// and the correct card ID.
@JsonKey(defaultValue: <MatrixStageDto>[])
final List<MatrixStageDto> stages;
/// Initial correct answer (card id) for the first step.
///
/// Next steps are handled on the client side.
/// DEPRECATED: Use stages[0].targetCardId instead.
/// Kept for backward compatibility.
@JsonKey(defaultValue: '')
final String answer;
/// Question text (e.g., "Найди слово:")
final String? text;
MatrixTestQuestionBody({
super.id,
required this.matrixSize,
required this.cards,
this.stages = const [],
required this.answer,
required super.word,
this.text,
super.questionType = TestQuestionType.matrix,
});
@ -42,21 +53,40 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
Map<String, Object?> toJson() => _$MatrixTestQuestionBodyToJson(this);
}
@JsonSerializable()
@CopyWith()
class MatrixStageDto {
final String targetCardId;
final String targetWord;
final String? targetAudio;
const MatrixStageDto({
required this.targetCardId,
required this.targetWord,
this.targetAudio,
});
factory MatrixStageDto.fromJson(Map<String, dynamic> json) =>
_$MatrixStageDtoFromJson(json);
Map<String, Object?> toJson() => _$MatrixStageDtoToJson(this);
}
@JsonSerializable()
@CopyWith()
class MatrixCardDto {
final String id;
final String image; // Object ID in MinIO (for admin)
final String? image; // Object ID in MinIO (for admin)
final String? imageUrl; // Presigned URL (for display)
final String original;
final String translation;
final String? original;
final String? translation;
const MatrixCardDto({
required this.id,
required this.image,
this.image,
this.imageUrl,
required this.original,
required this.translation,
this.original,
this.translation,
});
factory MatrixCardDto.fromJson(Map<String, dynamic> json) =>

View file

@ -13,10 +13,14 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
MatrixTestQuestionBody cards(List<MatrixCardDto> cards);
MatrixTestQuestionBody stages(List<MatrixStageDto> stages);
MatrixTestQuestionBody answer(String answer);
MatrixTestQuestionBody word(String word);
MatrixTestQuestionBody text(String? text);
MatrixTestQuestionBody questionType(TestQuestionType questionType);
/// Creates a new instance with the provided field values.
@ -30,8 +34,10 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
String? id,
int matrixSize,
List<MatrixCardDto> cards,
List<MatrixStageDto> stages,
String answer,
String word,
String? text,
TestQuestionType questionType,
});
}
@ -54,12 +60,19 @@ class _$MatrixTestQuestionBodyCWProxyImpl
@override
MatrixTestQuestionBody cards(List<MatrixCardDto> cards) => call(cards: cards);
@override
MatrixTestQuestionBody stages(List<MatrixStageDto> stages) =>
call(stages: stages);
@override
MatrixTestQuestionBody answer(String answer) => call(answer: answer);
@override
MatrixTestQuestionBody word(String word) => call(word: word);
@override
MatrixTestQuestionBody text(String? text) => call(text: text);
@override
MatrixTestQuestionBody questionType(TestQuestionType questionType) =>
call(questionType: questionType);
@ -76,8 +89,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
Object? id = const $CopyWithPlaceholder(),
Object? matrixSize = const $CopyWithPlaceholder(),
Object? cards = const $CopyWithPlaceholder(),
Object? stages = const $CopyWithPlaceholder(),
Object? answer = const $CopyWithPlaceholder(),
Object? word = const $CopyWithPlaceholder(),
Object? text = const $CopyWithPlaceholder(),
Object? questionType = const $CopyWithPlaceholder(),
}) {
return MatrixTestQuestionBody(
@ -94,6 +109,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
? _value.cards
// ignore: cast_nullable_to_non_nullable
: cards as List<MatrixCardDto>,
stages: stages == const $CopyWithPlaceholder() || stages == null
? _value.stages
// ignore: cast_nullable_to_non_nullable
: stages as List<MatrixStageDto>,
answer: answer == const $CopyWithPlaceholder() || answer == null
? _value.answer
// ignore: cast_nullable_to_non_nullable
@ -102,6 +121,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
? _value.word
// ignore: cast_nullable_to_non_nullable
: word as String,
text: text == const $CopyWithPlaceholder()
? _value.text
// ignore: cast_nullable_to_non_nullable
: text as String?,
questionType:
questionType == const $CopyWithPlaceholder() || questionType == null
? _value.questionType
@ -119,16 +142,94 @@ extension $MatrixTestQuestionBodyCopyWith on MatrixTestQuestionBody {
_$MatrixTestQuestionBodyCWProxyImpl(this);
}
abstract class _$MatrixStageDtoCWProxy {
MatrixStageDto targetCardId(String targetCardId);
MatrixStageDto targetWord(String targetWord);
MatrixStageDto targetAudio(String? targetAudio);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// MatrixStageDto(...).copyWith(id: 12, name: "My name")
/// ```
MatrixStageDto call({
String targetCardId,
String targetWord,
String? targetAudio,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfMatrixStageDto.copyWith(...)` or call `instanceOfMatrixStageDto.copyWith.fieldName(value)` for a single field.
class _$MatrixStageDtoCWProxyImpl implements _$MatrixStageDtoCWProxy {
const _$MatrixStageDtoCWProxyImpl(this._value);
final MatrixStageDto _value;
@override
MatrixStageDto targetCardId(String targetCardId) =>
call(targetCardId: targetCardId);
@override
MatrixStageDto targetWord(String targetWord) => call(targetWord: targetWord);
@override
MatrixStageDto targetAudio(String? targetAudio) =>
call(targetAudio: targetAudio);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// MatrixStageDto(...).copyWith(id: 12, name: "My name")
/// ```
MatrixStageDto call({
Object? targetCardId = const $CopyWithPlaceholder(),
Object? targetWord = const $CopyWithPlaceholder(),
Object? targetAudio = const $CopyWithPlaceholder(),
}) {
return MatrixStageDto(
targetCardId:
targetCardId == const $CopyWithPlaceholder() || targetCardId == null
? _value.targetCardId
// ignore: cast_nullable_to_non_nullable
: targetCardId as String,
targetWord:
targetWord == const $CopyWithPlaceholder() || targetWord == null
? _value.targetWord
// ignore: cast_nullable_to_non_nullable
: targetWord as String,
targetAudio: targetAudio == const $CopyWithPlaceholder()
? _value.targetAudio
// ignore: cast_nullable_to_non_nullable
: targetAudio as String?,
);
}
}
extension $MatrixStageDtoCopyWith on MatrixStageDto {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfMatrixStageDto.copyWith(...)` or `instanceOfMatrixStageDto.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$MatrixStageDtoCWProxy get copyWith => _$MatrixStageDtoCWProxyImpl(this);
}
abstract class _$MatrixCardDtoCWProxy {
MatrixCardDto id(String id);
MatrixCardDto image(String image);
MatrixCardDto image(String? image);
MatrixCardDto imageUrl(String? imageUrl);
MatrixCardDto original(String original);
MatrixCardDto original(String? original);
MatrixCardDto translation(String translation);
MatrixCardDto translation(String? translation);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixCardDto(...).copyWith.fieldName(value)`.
@ -139,10 +240,10 @@ abstract class _$MatrixCardDtoCWProxy {
/// ```
MatrixCardDto call({
String id,
String image,
String? image,
String? imageUrl,
String original,
String translation,
String? original,
String? translation,
});
}
@ -157,16 +258,16 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
MatrixCardDto id(String id) => call(id: id);
@override
MatrixCardDto image(String image) => call(image: image);
MatrixCardDto image(String? image) => call(image: image);
@override
MatrixCardDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl);
@override
MatrixCardDto original(String original) => call(original: original);
MatrixCardDto original(String? original) => call(original: original);
@override
MatrixCardDto translation(String translation) =>
MatrixCardDto translation(String? translation) =>
call(translation: translation);
@override
@ -189,23 +290,22 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
? _value.id
// ignore: cast_nullable_to_non_nullable
: id as String,
image: image == const $CopyWithPlaceholder() || image == null
image: image == const $CopyWithPlaceholder()
? _value.image
// ignore: cast_nullable_to_non_nullable
: image as String,
: image as String?,
imageUrl: imageUrl == const $CopyWithPlaceholder()
? _value.imageUrl
// ignore: cast_nullable_to_non_nullable
: imageUrl as String?,
original: original == const $CopyWithPlaceholder() || original == null
original: original == const $CopyWithPlaceholder()
? _value.original
// ignore: cast_nullable_to_non_nullable
: original as String,
translation:
translation == const $CopyWithPlaceholder() || translation == null
: original as String?,
translation: translation == const $CopyWithPlaceholder()
? _value.translation
// ignore: cast_nullable_to_non_nullable
: translation as String,
: translation as String?,
);
}
}
@ -231,8 +331,14 @@ MatrixTestQuestionBody _$MatrixTestQuestionBodyFromJson(
?.map((e) => MatrixCardDto.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
stages:
(json['stages'] as List<dynamic>?)
?.map((e) => MatrixStageDto.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
answer: json['answer'] as String? ?? '',
word: json['word'] as String,
text: json['text'] as String?,
questionType:
$enumDecodeNullable(_$TestQuestionTypeEnumMap, json['questionType']) ??
TestQuestionType.matrix,
@ -246,7 +352,9 @@ Map<String, dynamic> _$MatrixTestQuestionBodyToJson(
'word': instance.word,
'matrixSize': instance.matrixSize,
'buttons': instance.cards.map((e) => e.toJson()).toList(),
'stages': instance.stages.map((e) => e.toJson()).toList(),
'answer': instance.answer,
'text': ?instance.text,
};
const _$TestQuestionTypeEnumMap = {
@ -257,13 +365,27 @@ const _$TestQuestionTypeEnumMap = {
TestQuestionType.undefined: 'undefined',
};
MatrixStageDto _$MatrixStageDtoFromJson(Map<String, dynamic> json) =>
MatrixStageDto(
targetCardId: json['targetCardId'] as String,
targetWord: json['targetWord'] as String,
targetAudio: json['targetAudio'] as String?,
);
Map<String, dynamic> _$MatrixStageDtoToJson(MatrixStageDto instance) =>
<String, dynamic>{
'targetCardId': instance.targetCardId,
'targetWord': instance.targetWord,
'targetAudio': instance.targetAudio,
};
MatrixCardDto _$MatrixCardDtoFromJson(Map<String, dynamic> json) =>
MatrixCardDto(
id: json['id'] as String,
image: json['image'] as String,
image: json['image'] as String?,
imageUrl: json['imageUrl'] as String?,
original: json['original'] as String,
translation: json['translation'] as String,
original: json['original'] as String?,
translation: json['translation'] as String?,
);
Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) =>

View file

@ -115,21 +115,24 @@ abstract class MatchPair with _$MatchPair {
_$MatchPairFromJson(json);
}
/// Matrix question - user selects a card from an image matrix
/// Matrix question - user selects a card from a matrix
///
/// UI: a matrix of images (N x N) + current target word (original) under it.
/// UI: a matrix of cards (N x N) + current target word/audio under it.
/// The question is multi-step on a single screen: after each correct selection
/// the chosen card flips (showing translation) and disappears, then a new
/// target word is shown until all cards are removed.
/// the chosen card flips and a new target is shown from stages array until
/// all cards are processed.
@freezed
abstract class MatrixQuestion with _$MatrixQuestion {
const factory MatrixQuestion({
required String id,
required int matrixSize,
required List<MatrixCard> cards,
required String initialTargetCardId,
required String initialTargetWord,
@Default([]) List<MatrixStage> stages,
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
@Deprecated('Use stages[0] instead') String? initialTargetCardId,
@Deprecated('Use stages[0] instead') String? initialTargetWord,
required String word,
String? text,
@Default('matrix') String type,
}) = _MatrixQuestion;
@ -137,13 +140,25 @@ abstract class MatrixQuestion with _$MatrixQuestion {
_$MatrixQuestionFromJson(json);
}
@freezed
abstract class MatrixStage with _$MatrixStage {
const factory MatrixStage({
required String targetCardId,
required String targetWord,
String? targetAudio,
}) = _MatrixStage;
factory MatrixStage.fromJson(Map<String, dynamic> json) =>
_$MatrixStageFromJson(json);
}
@freezed
abstract class MatrixCard with _$MatrixCard {
const factory MatrixCard({
required String id,
required String image,
required String original,
required String translation,
String? image,
String? original,
String? translation,
}) = _MatrixCard;
factory MatrixCard.fromJson(Map<String, dynamic> json) =>

View file

@ -2262,7 +2262,8 @@ as String,
/// @nodoc
mixin _$MatrixQuestion {
String get id; int get matrixSize; List<MatrixCard> get cards; String get initialTargetCardId; String get initialTargetWord; String get word; String get type;
String get id; int get matrixSize; List<MatrixCard> get cards; List<MatrixStage> get stages;// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
@Deprecated('Use stages[0] instead') String? get initialTargetCardId;@Deprecated('Use stages[0] instead') String? get initialTargetWord; String get word; String? get text; String get type;
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2275,16 +2276,16 @@ $MatrixQuestionCopyWith<MatrixQuestion> get copyWith => _$MatrixQuestionCopyWith
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&const DeepCollectionEquality().equals(other.stages, stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),initialTargetCardId,initialTargetWord,word,type);
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),const DeepCollectionEquality().hash(stages),initialTargetCardId,initialTargetWord,word,text,type);
@override
String toString() {
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)';
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
}
@ -2295,7 +2296,7 @@ abstract mixin class $MatrixQuestionCopyWith<$Res> {
factory $MatrixQuestionCopyWith(MatrixQuestion value, $Res Function(MatrixQuestion) _then) = _$MatrixQuestionCopyWithImpl;
@useResult
$Res call({
String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type
});
@ -2312,15 +2313,17 @@ class _$MatrixQuestionCopyWithImpl<$Res>
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
as int,cards: null == cards ? _self.cards : cards // ignore: cast_nullable_to_non_nullable
as List<MatrixCard>,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as List<MatrixCard>,stages: null == stages ? _self.stages : stages // ignore: cast_nullable_to_non_nullable
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,
));
}
@ -2406,10 +2409,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MatrixQuestion() when $default != null:
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
return orElse();
}
@ -2427,10 +2430,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type) $default,) {final _that = this;
switch (_that) {
case _MatrixQuestion():
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
throw StateError('Unexpected subclass');
}
@ -2447,10 +2450,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,) {final _that = this;
switch (_that) {
case _MatrixQuestion() when $default != null:
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
return null;
}
@ -2462,7 +2465,7 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
@JsonSerializable()
class _MatrixQuestion implements MatrixQuestion {
const _MatrixQuestion({required this.id, required this.matrixSize, required final List<MatrixCard> cards, required this.initialTargetCardId, required this.initialTargetWord, required this.word, this.type = 'matrix'}): _cards = cards;
const _MatrixQuestion({required this.id, required this.matrixSize, required final List<MatrixCard> cards, final List<MatrixStage> stages = const [], @Deprecated('Use stages[0] instead') this.initialTargetCardId, @Deprecated('Use stages[0] instead') this.initialTargetWord, required this.word, this.text, this.type = 'matrix'}): _cards = cards,_stages = stages;
factory _MatrixQuestion.fromJson(Map<String, dynamic> json) => _$MatrixQuestionFromJson(json);
@override final String id;
@ -2474,9 +2477,18 @@ class _MatrixQuestion implements MatrixQuestion {
return EqualUnmodifiableListView(_cards);
}
@override final String initialTargetCardId;
@override final String initialTargetWord;
final List<MatrixStage> _stages;
@override@JsonKey() List<MatrixStage> get stages {
if (_stages is EqualUnmodifiableListView) return _stages;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_stages);
}
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
@override@Deprecated('Use stages[0] instead') final String? initialTargetCardId;
@override@Deprecated('Use stages[0] instead') final String? initialTargetWord;
@override final String word;
@override final String? text;
@override@JsonKey() final String type;
/// Create a copy of MatrixQuestion
@ -2492,16 +2504,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&const DeepCollectionEquality().equals(other._stages, _stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),initialTargetCardId,initialTargetWord,word,type);
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),const DeepCollectionEquality().hash(_stages),initialTargetCardId,initialTargetWord,word,text,type);
@override
String toString() {
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)';
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
}
@ -2512,7 +2524,7 @@ abstract mixin class _$MatrixQuestionCopyWith<$Res> implements $MatrixQuestionCo
factory _$MatrixQuestionCopyWith(_MatrixQuestion value, $Res Function(_MatrixQuestion) _then) = __$MatrixQuestionCopyWithImpl;
@override @useResult
$Res call({
String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type
});
@ -2529,15 +2541,17 @@ class __$MatrixQuestionCopyWithImpl<$Res>
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) {
return _then(_MatrixQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
as int,cards: null == cards ? _self._cards : cards // ignore: cast_nullable_to_non_nullable
as List<MatrixCard>,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as List<MatrixCard>,stages: null == stages ? _self._stages : stages // ignore: cast_nullable_to_non_nullable
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,
));
}
@ -2546,10 +2560,279 @@ as String,
}
/// @nodoc
mixin _$MatrixStage {
String get targetCardId; String get targetWord; String? get targetAudio;
/// Create a copy of MatrixStage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$MatrixStageCopyWith<MatrixStage> get copyWith => _$MatrixStageCopyWithImpl<MatrixStage>(this as MatrixStage, _$identity);
/// Serializes this MatrixStage to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixStage&&(identical(other.targetCardId, targetCardId) || other.targetCardId == targetCardId)&&(identical(other.targetWord, targetWord) || other.targetWord == targetWord)&&(identical(other.targetAudio, targetAudio) || other.targetAudio == targetAudio));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,targetCardId,targetWord,targetAudio);
@override
String toString() {
return 'MatrixStage(targetCardId: $targetCardId, targetWord: $targetWord, targetAudio: $targetAudio)';
}
}
/// @nodoc
abstract mixin class $MatrixStageCopyWith<$Res> {
factory $MatrixStageCopyWith(MatrixStage value, $Res Function(MatrixStage) _then) = _$MatrixStageCopyWithImpl;
@useResult
$Res call({
String targetCardId, String targetWord, String? targetAudio
});
}
/// @nodoc
class _$MatrixStageCopyWithImpl<$Res>
implements $MatrixStageCopyWith<$Res> {
_$MatrixStageCopyWithImpl(this._self, this._then);
final MatrixStage _self;
final $Res Function(MatrixStage) _then;
/// Create a copy of MatrixStage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? targetCardId = null,Object? targetWord = null,Object? targetAudio = freezed,}) {
return _then(_self.copyWith(
targetCardId: null == targetCardId ? _self.targetCardId : targetCardId // ignore: cast_nullable_to_non_nullable
as String,targetWord: null == targetWord ? _self.targetWord : targetWord // ignore: cast_nullable_to_non_nullable
as String,targetAudio: freezed == targetAudio ? _self.targetAudio : targetAudio // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [MatrixStage].
extension MatrixStagePatterns on MatrixStage {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _MatrixStage value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _MatrixStage() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _MatrixStage value) $default,){
final _that = this;
switch (_that) {
case _MatrixStage():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _MatrixStage value)? $default,){
final _that = this;
switch (_that) {
case _MatrixStage() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String targetCardId, String targetWord, String? targetAudio)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MatrixStage() when $default != null:
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String targetCardId, String targetWord, String? targetAudio) $default,) {final _that = this;
switch (_that) {
case _MatrixStage():
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String targetCardId, String targetWord, String? targetAudio)? $default,) {final _that = this;
switch (_that) {
case _MatrixStage() when $default != null:
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _MatrixStage implements MatrixStage {
const _MatrixStage({required this.targetCardId, required this.targetWord, this.targetAudio});
factory _MatrixStage.fromJson(Map<String, dynamic> json) => _$MatrixStageFromJson(json);
@override final String targetCardId;
@override final String targetWord;
@override final String? targetAudio;
/// Create a copy of MatrixStage
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$MatrixStageCopyWith<_MatrixStage> get copyWith => __$MatrixStageCopyWithImpl<_MatrixStage>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$MatrixStageToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixStage&&(identical(other.targetCardId, targetCardId) || other.targetCardId == targetCardId)&&(identical(other.targetWord, targetWord) || other.targetWord == targetWord)&&(identical(other.targetAudio, targetAudio) || other.targetAudio == targetAudio));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,targetCardId,targetWord,targetAudio);
@override
String toString() {
return 'MatrixStage(targetCardId: $targetCardId, targetWord: $targetWord, targetAudio: $targetAudio)';
}
}
/// @nodoc
abstract mixin class _$MatrixStageCopyWith<$Res> implements $MatrixStageCopyWith<$Res> {
factory _$MatrixStageCopyWith(_MatrixStage value, $Res Function(_MatrixStage) _then) = __$MatrixStageCopyWithImpl;
@override @useResult
$Res call({
String targetCardId, String targetWord, String? targetAudio
});
}
/// @nodoc
class __$MatrixStageCopyWithImpl<$Res>
implements _$MatrixStageCopyWith<$Res> {
__$MatrixStageCopyWithImpl(this._self, this._then);
final _MatrixStage _self;
final $Res Function(_MatrixStage) _then;
/// Create a copy of MatrixStage
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? targetCardId = null,Object? targetWord = null,Object? targetAudio = freezed,}) {
return _then(_MatrixStage(
targetCardId: null == targetCardId ? _self.targetCardId : targetCardId // ignore: cast_nullable_to_non_nullable
as String,targetWord: null == targetWord ? _self.targetWord : targetWord // ignore: cast_nullable_to_non_nullable
as String,targetAudio: freezed == targetAudio ? _self.targetAudio : targetAudio // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
mixin _$MatrixCard {
String get id; String get image; String get original; String get translation;
String get id; String? get image; String? get original; String? get translation;
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2582,7 +2865,7 @@ abstract mixin class $MatrixCardCopyWith<$Res> {
factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl;
@useResult
$Res call({
String id, String image, String original, String translation
String id, String? image, String? original, String? translation
});
@ -2599,13 +2882,13 @@ class _$MatrixCardCopyWithImpl<$Res>
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String,
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@ -2690,7 +2973,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String image, String original, String translation)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? image, String? original, String? translation)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
@ -2711,7 +2994,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String image, String original, String translation) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? image, String? original, String? translation) $default,) {final _that = this;
switch (_that) {
case _MatrixCard():
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
@ -2731,7 +3014,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String image, String original, String translation)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? image, String? original, String? translation)? $default,) {final _that = this;
switch (_that) {
case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
@ -2746,13 +3029,13 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
@JsonSerializable()
class _MatrixCard implements MatrixCard {
const _MatrixCard({required this.id, required this.image, required this.original, required this.translation});
const _MatrixCard({required this.id, this.image, this.original, this.translation});
factory _MatrixCard.fromJson(Map<String, dynamic> json) => _$MatrixCardFromJson(json);
@override final String id;
@override final String image;
@override final String original;
@override final String translation;
@override final String? image;
@override final String? original;
@override final String? translation;
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@ -2787,7 +3070,7 @@ abstract mixin class _$MatrixCardCopyWith<$Res> implements $MatrixCardCopyWith<$
factory _$MatrixCardCopyWith(_MatrixCard value, $Res Function(_MatrixCard) _then) = __$MatrixCardCopyWithImpl;
@override @useResult
$Res call({
String id, String image, String original, String translation
String id, String? image, String? original, String? translation
});
@ -2804,13 +3087,13 @@ class __$MatrixCardCopyWithImpl<$Res>
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
return _then(_MatrixCard(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String,
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String?,
));
}

View file

@ -196,9 +196,15 @@ _MatrixQuestion _$MatrixQuestionFromJson(Map<String, dynamic> json) =>
cards: (json['cards'] as List<dynamic>)
.map((e) => MatrixCard.fromJson(e as Map<String, dynamic>))
.toList(),
initialTargetCardId: json['initialTargetCardId'] as String,
initialTargetWord: json['initialTargetWord'] as String,
stages:
(json['stages'] as List<dynamic>?)
?.map((e) => MatrixStage.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
initialTargetCardId: json['initialTargetCardId'] as String?,
initialTargetWord: json['initialTargetWord'] as String?,
word: json['word'] as String,
text: json['text'] as String?,
type: json['type'] as String? ?? 'matrix',
);
@ -207,17 +213,32 @@ Map<String, dynamic> _$MatrixQuestionToJson(_MatrixQuestion instance) =>
'id': instance.id,
'matrixSize': instance.matrixSize,
'cards': instance.cards,
'stages': instance.stages,
'initialTargetCardId': instance.initialTargetCardId,
'initialTargetWord': instance.initialTargetWord,
'word': instance.word,
'text': instance.text,
'type': instance.type,
};
_MatrixStage _$MatrixStageFromJson(Map<String, dynamic> json) => _MatrixStage(
targetCardId: json['targetCardId'] as String,
targetWord: json['targetWord'] as String,
targetAudio: json['targetAudio'] as String?,
);
Map<String, dynamic> _$MatrixStageToJson(_MatrixStage instance) =>
<String, dynamic>{
'targetCardId': instance.targetCardId,
'targetWord': instance.targetWord,
'targetAudio': instance.targetAudio,
};
_MatrixCard _$MatrixCardFromJson(Map<String, dynamic> json) => _MatrixCard(
id: json['id'] as String,
image: json['image'] as String,
original: json['original'] as String,
translation: json['translation'] as String,
image: json['image'] as String?,
original: json['original'] as String?,
translation: json['translation'] as String?,
);
Map<String, dynamic> _$MatrixCardToJson(_MatrixCard instance) =>

View file

@ -290,11 +290,27 @@ class TestsStateManager extends StateManager<TestsState> {
});
/// Complete the game session
Future<void> completeGameSession() => handle((emit) async {
Future<void> completeGameSession() {
log('completeGameSession method invoked', name: 'TestsStateManager');
return handle((emit) async {
log('completeGameSession handle callback started', name: 'TestsStateManager');
final currentState = state;
if (currentState is! _GameSessionActive || _currentTest == null) return;
log('Current state type: ${currentState.runtimeType}', name: 'TestsStateManager');
log('Current test: ${_currentTest?.id}', name: 'TestsStateManager');
if (currentState is! _GameSessionActive) {
log('State is not GameSessionActive, returning', name: 'TestsStateManager');
return;
}
if (_currentTest == null) {
log('Current test is null, returning', name: 'TestsStateManager');
return;
}
try {
log('Processing completion...', name: 'TestsStateManager');
// Check if current question has been answered
final currentQuestion = currentState.questions[currentState.currentQuestionIndex];
final questionId = _getQuestionId(currentQuestion);
@ -309,11 +325,15 @@ class TestsStateManager extends StateManager<TestsState> {
_gameSessionManager.submitAnswer(questionId, currentQuestion, '');
}
log('Calling gameSessionManager.completeSession', name: 'TestsStateManager');
final result = _gameSessionManager.completeSession(_currentTest!.id!);
log('completeSession returned result', name: 'TestsStateManager');
// Play completion sound
log('Playing completion sound', name: 'TestsStateManager');
await _gameSoundService.playGameComplete();
log('Emitting gameSessionCompleted state', name: 'TestsStateManager');
emit(TestsState.gameSessionCompleted(
test: _currentTest!,
result: result,
@ -331,6 +351,7 @@ class TestsStateManager extends StateManager<TestsState> {
emit(TestsState.error('Failed to complete session: ${e.toString()}'));
}
});
}
/// Reset game session
Future<void> resetGameSession() => handle((emit) async {
@ -434,7 +455,7 @@ class TestsStateManager extends StateManager<TestsState> {
questions.add(GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q_${questions.length}',
question: question.w ?? '',
question: question.text ?? '',
image: question.imageUrl ?? question.image, // Use presigned URL if available
audio: question.audio,
options: options, // Keep for backward compatibility
@ -479,15 +500,36 @@ class TestsStateManager extends StateManager<TestsState> {
)
.toList();
// Convert stages from DTO to domain model
final stages = question.stages
.map(
(s) => MatrixStage(
targetCardId: s.targetCardId,
targetWord: s.targetWord,
targetAudio: s.targetAudio,
),
)
.toList();
// Use stages if available, otherwise fallback to deprecated fields
final initialTargetCardId = stages.isNotEmpty
? stages.first.targetCardId
: question.answer;
final initialTargetWord = stages.isNotEmpty
? stages.first.targetWord
: question.word;
questions.add(
GameQuestion.matrix(
MatrixQuestion(
id: id,
matrixSize: question.matrixSize,
cards: cards,
initialTargetCardId: question.answer,
initialTargetWord: question.word,
stages: stages,
initialTargetCardId: initialTargetCardId,
initialTargetWord: initialTargetWord,
word: question.word,
text: question.text,
),
),
);

View file

@ -17,6 +17,7 @@ import '../../../domain/state/tests_state_manager.dart';
import '../../../presentation/widgets/error_view.dart';
import '../../../presentation/widgets/game/answer_options.dart';
import '../../../presentation/widgets/game/input_letters_widget.dart';
import '../../../presentation/widgets/game/match_widget.dart';
import '../../../presentation/widgets/game/matrix_widget.dart';
import '../../../presentation/widgets/game/progress_indicator.dart';
import '../../../presentation/widgets/game/question_display.dart';
@ -133,6 +134,7 @@ class _GamePageState extends State<GamePage> {
}
Widget _buildBody(TestsState state) {
log('_buildBody called with state: ${state.runtimeType}', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero;
@ -229,6 +231,34 @@ class _GamePageState extends State<GamePage> {
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 720;
final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth;
final availableHeight = constraints.maxHeight;
// Determine progress indicator mode based on available height
final progressMode = availableHeight > 800
? ProgressIndicatorMode.full
: availableHeight > 600
? ProgressIndicatorMode.compact
: ProgressIndicatorMode.mini;
// Calculate spacing based on available height
final spacingAfterProgress = availableHeight > 700 ? 12.h : 8.h;
final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h;
final spacingAfterAnswer = availableHeight > 700 ? 16.h : 12.h;
// Calculate max heights for question and answer sections
// Reserve space: progress (40-100px), navigation buttons (56px), spacing
final reservedHeight = progressMode == ProgressIndicatorMode.full
? 120.h
: progressMode == ProgressIndicatorMode.compact
? 80.h
: 60.h;
final navigationHeight = (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ? 72.h : 0;
final totalSpacing = spacingAfterProgress + spacingAfterQuestion + spacingAfterAnswer;
final availableForContent = availableHeight - reservedHeight - navigationHeight - totalSpacing;
// Distribute: 40% question, 60% answers (adjustable)
final questionMaxHeight = availableForContent * 0.4;
final answerMaxHeight = availableForContent * 0.6;
return Center(
child: ConstrainedBox(
@ -236,100 +266,119 @@ class _GamePageState extends State<GamePage> {
child: Column(
children: [
// Progress indicator
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
child: Row(
children: [
Expanded(
child: GameProgressIndicator(
currentQuestion: currentQuestionIndex,
totalQuestions: questions.length,
correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
timeElapsed: sessionElapsed,
),
),
],
),
Padding(
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
child: GameProgressIndicator(
currentQuestion: currentQuestionIndex,
totalQuestions: questions.length,
correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
timeElapsed: sessionElapsed,
mode: progressMode,
),
),
SizedBox(height: spacingAfterProgress),
// Question content
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(isNarrow ? 12.w : 16.w),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Question display
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: KeyedSubtree(
key: questionKey,
child: Material(
key: GamePage.questionCardKey,
color: colorScheme.surface,
surfaceTintColor: colorScheme.surfaceTint,
elevation: 3,
shadowColor: theme.shadowColor.withOpacity(
theme.brightness == Brightness.dark ? 0.35 : 0.14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18.r),
side: BorderSide(
color: colorScheme.outlineVariant,
Flexible(
flex: 2,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: questionMaxHeight),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: KeyedSubtree(
key: questionKey,
child: Material(
key: GamePage.questionCardKey,
color: colorScheme.surface,
surfaceTintColor: colorScheme.surfaceTint,
elevation: 3,
shadowColor: theme.shadowColor.withOpacity(
theme.brightness == Brightness.dark ? 0.35 : 0.14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18.r),
side: BorderSide(
color: colorScheme.outlineVariant,
),
),
child: Padding(
padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(
question: currentQuestion.question,
maxHeight: questionMaxHeight,
)
: QuestionDisplay(
question: currentQuestion,
onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio,
maxHeight: questionMaxHeight,
),
),
),
),
child: Padding(
padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
child: currentQuestion is GameQuestionMatrix
? MatrixWidget(question: currentQuestion.question)
: QuestionDisplay(
question: currentQuestion,
onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio,
),
),
),
),
),
SizedBox(height: isNarrow ? 16.h : 20.h),
SizedBox(height: spacingAfterQuestion),
// Answer input based on question type with smooth transitions
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: child,
Flexible(
flex: 3,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: answerMaxHeight),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Container(
key: ValueKey('question_input_${currentQuestion.hashCode}'),
child: currentQuestion.when(
multipleChoice: (q) => AnswerOptions(
question: q,
selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults),
onAnswerSelected: _onAnswerSelected,
isAnswerSubmitted: isAnswerSubmitted,
isCorrect: isCorrect,
maxHeight: answerMaxHeight,
),
inputLetters: (q) => InputLettersWidget(
question: q,
maxHeight: answerMaxHeight,
),
match: (q) => MatchWidget(
question: q,
maxHeight: answerMaxHeight,
),
matrix: (q) => const SizedBox.shrink(),
),
),
);
},
child: Container(
key: ValueKey('question_input_${currentQuestion.hashCode}'),
child: currentQuestion.when(
multipleChoice: (q) => AnswerOptions(
question: q,
selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults),
onAnswerSelected: _onAnswerSelected,
isAnswerSubmitted: isAnswerSubmitted,
isCorrect: isCorrect,
),
inputLetters: (q) => InputLettersWidget(question: q),
match: (q) => const Center(child: Text('Match questions coming soon!')),
matrix: (q) => const SizedBox.shrink(),
),
),
),
// Navigation buttons (show when navigation is possible)
if (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ...[
SizedBox(height: isNarrow ? 18.h : 24.h),
SizedBox(height: spacingAfterAnswer),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -342,10 +391,18 @@ class _GamePageState extends State<GamePage> {
SizedBox(width: isNarrow ? 12.w : 16.w),
],
if (_isLastQuestion(state)) ...[
ElevatedButton.icon(
onPressed: _finishGame,
icon: const Icon(Icons.check),
label: const Text('Finish'),
Builder(
builder: (context) {
log('Finish button is being rendered', name: 'GamePage');
return ElevatedButton.icon(
onPressed: () {
log('Finish button onPressed triggered', name: 'GamePage');
_finishGame();
},
icon: const Icon(Icons.check),
label: const Text('Finish'),
);
},
),
] else if (_canGoNext(state)) ...[
ElevatedButton.icon(
@ -546,11 +603,18 @@ class _GamePageState extends State<GamePage> {
Future<void> _finishGame() async {
log('Finish button pressed', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
log('AppScope: ${appScope != null ? 'available' : 'null'}', name: 'GamePage');
final userScope = appScope?.userScopeHolder.scope;
log('UserScope: ${userScope != null ? 'available' : 'null'}', name: 'GamePage');
if (userScope != null) {
log('Completing game session...', name: 'GamePage');
await userScope.testsModule.testsStateManager.completeGameSession();
log('Game session completion called', name: 'GamePage');
log('Calling completeGameSession...', name: 'GamePage');
try {
await userScope.testsModule.testsStateManager.completeGameSession();
log('Game session completion finished successfully', name: 'GamePage');
} catch (e, s) {
log('Error during completeGameSession', error: e, stackTrace: s, name: 'GamePage');
}
} else {
log('No user scope available when finishing game', name: 'GamePage');
}
@ -635,20 +699,28 @@ class _GamePageState extends State<GamePage> {
bool _canGoNext(TestsState state) {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
return userScope?.testsModule.testsStateManager.canGoNext ?? false;
final canNext = userScope?.testsModule.testsStateManager.canGoNext ?? false;
log('Can go next: $canNext', name: 'GamePage');
return canNext;
}
bool _canGoPrevious(TestsState state) {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
return userScope?.testsModule.testsStateManager.canGoPrevious ?? false;
final canPrev = userScope?.testsModule.testsStateManager.canGoPrevious ?? false;
log('Can go previous: $canPrev', name: 'GamePage');
return canPrev;
}
bool _isLastQuestion(TestsState state) {
return state.maybeWhen(
gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) =>
currentQuestionIndex >= questions.length - 1,
final result = state.maybeWhen(
gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) {
final isLast = currentQuestionIndex >= questions.length - 1;
log('Is last question: $isLast (index: $currentQuestionIndex, total: ${questions.length})', name: 'GamePage');
return isLast;
},
orElse: () => false,
);
return result;
}
}

View file

@ -443,7 +443,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
children: [
// Модуль "проверка знаний" для мобильных
// Hide tests section for mobile devices for now
// _buildMobileTestsSection(packColor),
_buildMobileTestsSection(packColor),
const SizedBox(height: 16),
@ -457,9 +457,11 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
// ignore: unused_element
Widget _buildMobileTestsSection(Color packColor) {
final tests = _getTests();
if (tests.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,

View file

@ -11,6 +11,7 @@ class AnswerOptions extends StatelessWidget {
required this.isAnswerSubmitted,
required this.isCorrect,
this.enabled = true,
this.maxHeight,
super.key,
});
@ -20,6 +21,7 @@ class AnswerOptions extends StatelessWidget {
final bool isAnswerSubmitted;
final bool isCorrect;
final bool enabled;
final double? maxHeight;
void _onAnswerSelected(BuildContext context, String option) {
// Note: Sound service access would be implemented through proper DI injection
@ -44,27 +46,42 @@ class AnswerOptions extends StatelessWidget {
// Adjust aspect ratio for image buttons (they need more space)
final hasImages = hasOptionItems &&
question.optionItems.any((item) => item.image != null);
final childAspectRatio = hasImages ? 1.1 : 4.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: childAspectRatio,
// Adapt aspect ratio based on maxHeight
double childAspectRatio;
if (hasImages) {
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 1.0 : 1.1;
} else {
childAspectRatio = maxHeight != null && maxHeight! < 300 ? 3.0 : 4.0;
}
// Adapt spacing based on available height
final spacing = maxHeight != null && maxHeight! < 300 ? 8.0 : 12.0;
return ConstrainedBox(
constraints: maxHeight != null
? BoxConstraints(maxHeight: maxHeight!)
: const BoxConstraints(),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (hasOptionItems) {
final optionItem = question.optionItems[index];
return _buildAnswerOptionFromItem(context, optionItem);
} else {
final option = question.options[index];
return _buildAnswerOption(context, option);
}
},
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (hasOptionItems) {
final optionItem = question.optionItems[index];
return _buildAnswerOptionFromItem(context, optionItem);
} else {
final option = question.options[index];
return _buildAnswerOption(context, option);
}
},
);
},
);
@ -166,7 +183,10 @@ class AnswerOptions extends StatelessWidget {
splashColor: borderColor?.withOpacity(0.1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border.all(
color:
@ -303,7 +323,7 @@ class AnswerOptions extends StatelessWidget {
duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: textColor,
fontWeight: isSelected || isCorrectOption
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),

View file

@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart';
class InputLettersWidget extends StatefulWidget {
const InputLettersWidget({
required this.question,
this.maxHeight,
super.key,
});
final InputLettersQuestion question;
final double? maxHeight;
@override
State<InputLettersWidget> createState() => _InputLettersWidgetState();
@ -73,101 +75,123 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
@override
Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final containerPadding = isCompact ? 12.w : 16.w;
final containerMargin = isCompact ? 12.h : 16.h;
final spacingAfterTitle = isCompact ? 8.h : 12.h;
final spacingAfterWord = isCompact ? 8.h : 12.h;
final spacingAfterGrid = isCompact ? 12.h : 16.h;
final showFillInstruction = !isCompact; // Hide on very compact screens
return LayoutBuilder(
builder: (context, constraints) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Display the template with current input
Container(
padding: EdgeInsets.all(24.w),
margin: EdgeInsets.only(bottom: 24.h),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Display the template with current input
Flexible(
child: Container(
padding: EdgeInsets.all(containerPadding),
margin: EdgeInsets.only(bottom: containerMargin),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
],
),
child: Column(
children: [
Text(
'Fill in the blanks:',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showFillInstruction)
Text(
'Fill in the blanks:',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
if (showFillInstruction && widget.question.word.isNotEmpty)
SizedBox(height: spacingAfterTitle),
if (widget.question.word.isNotEmpty) ...[
Text(
widget.question.word,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: spacingAfterWord),
_buildTemplateDisplay(),
],
),
),
),
// Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[
Flexible(
child: _buildButtonsGrid(constraints),
),
SizedBox(height: spacingAfterGrid),
] else ...[
// Input field (only if no buttons)
Container(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.8,
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type the missing letters...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.r),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
),
style: TextStyle(
fontSize: 18.sp,
letterSpacing: 2,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLength: widget.question.correctAnswer.length,
onSubmitted: _submitAnswer,
),
if (widget.question.word.isNotEmpty) ...[
SizedBox(height: 16.h),
Text(
widget.question.word,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: 16.h),
_buildTemplateDisplay(),
],
),
),
),
SizedBox(height: spacingAfterGrid),
],
// Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[
_buildButtonsGrid(constraints),
SizedBox(height: 24.h),
] else ...[
// Input field (only if no buttons)
Container(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.8,
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type the missing letters...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.r),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
),
style: TextStyle(
fontSize: 18.sp,
letterSpacing: 2,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLength: widget.question.correctAnswer.length,
onSubmitted: _submitAnswer,
// Submit button
ElevatedButton.icon(
onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null,
icon: const Icon(Icons.send),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
),
),
SizedBox(height: 24.h),
],
// Submit button
ElevatedButton.icon(
onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null,
icon: const Icon(Icons.send),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
),
),
],
),
);
},
);
@ -188,6 +212,13 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final parts = <Widget>[];
int inputIndex = 0;
// Adapt sizes based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final cellWidth = isCompact ? 24.w : 28.w;
final cellHeight = isCompact ? 36.h : 48.h;
final cellFontSize = isCompact ? 16.sp : 20.sp;
final blankCellWidth = isCompact ? 28.w : 32.w;
for (int i = 0; i < template.length; i++) {
final char = template[i];
@ -198,8 +229,8 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
parts.add(
Container(
width: 32.w,
height: 48.h,
width: blankCellWidth,
height: cellHeight,
margin: EdgeInsets.symmetric(horizontal: 2.w),
decoration: BoxDecoration(
border: Border.all(
@ -215,7 +246,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
child: Text(
letter.toUpperCase(),
style: TextStyle(
fontSize: 20.sp,
fontSize: cellFontSize,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontFeatures: const [FontFeature.tabularFigures()],
@ -232,14 +263,14 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
// This is a regular character
parts.add(
Container(
width: 28.w,
height: 48.h,
width: cellWidth,
height: cellHeight,
margin: EdgeInsets.symmetric(horizontal: 2.w),
alignment: Alignment.center,
child: Text(
char,
style: TextStyle(
fontSize: 20.sp,
fontSize: cellFontSize,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
@ -256,15 +287,19 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
Widget _buildButtonsGrid(BoxConstraints constraints) {
final hasImages = widget.question.buttons.any((b) => b.image != null);
final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3;
final childAspectRatio = hasImages ? 1.2 : 2.0;
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
final childAspectRatio = hasImages
? (isCompact ? 1.0 : 1.2)
: (isCompact ? 2.0 : 2.0);
final spacing = isCompact ? 8.0 : 12.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: childAspectRatio,
),
itemCount: widget.question.buttons.length,

View file

@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart';
class MatchWidget extends StatefulWidget {
const MatchWidget({
required this.question,
this.maxHeight,
super.key,
});
final MatchQuestion question;
final double? maxHeight;
@override
State<MatchWidget> createState() => _MatchWidgetState();
@ -25,118 +27,146 @@ class _MatchWidgetState extends State<MatchWidget> {
@override
Widget build(BuildContext context) {
// Adapt spacing and padding based on maxHeight
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
final instructionPadding = isCompact ? 12.w : 16.w;
final instructionMargin = isCompact ? 12.h : 16.h;
final connectionPadding = isCompact ? 12.w : 16.w;
final connectionMargin = isCompact ? 12.h : 16.h;
final spacingAfterColumns = isCompact ? 12.h : 16.h;
final instructionText = isCompact
? 'Connect matching items'
: 'Connect the matching items by tapping them in order';
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600;
return Column(
children: [
// Instructions
Container(
padding: EdgeInsets.all(16.w),
margin: EdgeInsets.only(bottom: 24.h),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
'Connect the matching items by tapping them in order',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
// Connection display
if (_connections.isNotEmpty) ...[
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Instructions
Container(
padding: EdgeInsets.all(16.w),
margin: EdgeInsets.only(bottom: 16.h),
padding: EdgeInsets.all(instructionPadding),
margin: EdgeInsets.only(bottom: instructionMargin),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3),
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connections:',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
SizedBox(height: 8.h),
..._buildConnectionDisplay(),
],
),
child: Text(
instructionText,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
// Connection display
if (_connections.isNotEmpty) ...[
Container(
padding: EdgeInsets.all(connectionPadding),
margin: EdgeInsets.only(bottom: connectionMargin),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Connected: ${_connections.length}/${widget.question.correctPairs.length}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (!isCompact) ...[
SizedBox(height: 8.h),
..._buildConnectionDisplay(),
],
],
),
),
],
// Two columns layout
Flexible(
child: isWideScreen
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
SizedBox(width: isCompact ? 16.w : 24.w),
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
],
)
: Column(
children: [
Text(
'Left Column',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
Flexible(child: _buildColumn(widget.question.leftItems, isLeft: true)),
SizedBox(height: isCompact ? 16.h : 24.h),
Text(
'Right Column',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.secondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: isCompact ? 8.h : 12.h),
Flexible(child: _buildColumn(widget.question.rightItems, isLeft: false)),
],
),
),
SizedBox(height: spacingAfterColumns),
// Submit button
ElevatedButton.icon(
onPressed: _canSubmit ? _submitAnswer : null,
icon: const Icon(Icons.check_circle),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
),
),
],
// Two columns layout
Expanded(
child: isWideScreen
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
SizedBox(width: 24.w),
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
],
)
: Column(
children: [
Text(
'Left Column',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
SizedBox(height: 24.h),
Text(
'Right Column',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.secondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
],
),
),
SizedBox(height: 24.h),
// Submit button
ElevatedButton.icon(
onPressed: _canSubmit ? _submitAnswer : null,
icon: const Icon(Icons.check_circle),
label: const Text('Submit Answer'),
style: ElevatedButton.styleFrom(
minimumSize: Size(200.w, 48.h),
textStyle: TextStyle(fontSize: 16.sp),
),
),
],
),
);
},
);
}
Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
final itemMargin = isCompact ? 4.h : 6.h;
final itemPadding = isCompact ? 8.w : 12.w;
final imageSize = isCompact ? 32.0 : 40.0;
final fontSize = isCompact ? 14.sp : 16.sp;
final iconSize = isCompact ? 18.sp : 20.sp;
return ListView.builder(
itemCount: items.length,
itemExtent: isCompact ? 52.0 : 64.0, // Fixed item height for better performance
itemBuilder: (context, index) {
final item = items[index];
final isSelected = isLeft
@ -147,7 +177,7 @@ class _MatchWidgetState extends State<MatchWidget> {
: _connections.containsValue(item.id);
return Container(
margin: EdgeInsets.only(bottom: 8.h),
margin: EdgeInsets.only(bottom: itemMargin),
child: Material(
color: isConnected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
@ -159,7 +189,7 @@ class _MatchWidgetState extends State<MatchWidget> {
onTap: isConnected ? null : () => _onItemTap(item.id, isLeft),
borderRadius: BorderRadius.circular(12.r),
child: Container(
padding: EdgeInsets.all(12.w),
padding: EdgeInsets.all(itemPadding),
decoration: BoxDecoration(
border: Border.all(
color: isConnected
@ -175,8 +205,8 @@ class _MatchWidgetState extends State<MatchWidget> {
children: [
if (item.image != null) ...[
Container(
width: 40.w,
height: 40.h,
width: imageSize,
height: imageSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
image: DecorationImage(
@ -185,13 +215,13 @@ class _MatchWidgetState extends State<MatchWidget> {
),
),
),
SizedBox(width: 12.w),
SizedBox(width: isCompact ? 8.w : 12.w),
],
Expanded(
child: Text(
item.text,
style: TextStyle(
fontSize: 16.sp,
fontSize: fontSize,
fontWeight: isConnected ? FontWeight.w600 : FontWeight.normal,
color: isConnected
? Theme.of(context).colorScheme.primary
@ -204,7 +234,7 @@ class _MatchWidgetState extends State<MatchWidget> {
Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
size: 20.sp,
size: iconSize,
),
],
],

View file

@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
@ -12,19 +14,21 @@ import '../../../domain/models/matrix_image_select_answer.dart';
///
/// Shows a N x N matrix of images, and the current target word (original)
/// under the matrix. User clicks an image to answer:
/// - correct: card flips (translation on the back) and disappears; target word updates
/// - correct: card flips (showing original and translation on the back) and stays flipped; target word updates
/// - wrong: card shakes + wrong sound; test continues
class MatrixWidget extends StatefulWidget {
const MatrixWidget({
required this.question,
this.onWrongAttempt,
this.onCompleted,
this.maxHeight,
super.key,
});
final MatrixQuestion question;
final Future<void> Function()? onWrongAttempt;
final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted;
final double? maxHeight;
@override
State<MatrixWidget> createState() => _MatrixWidgetState();
@ -35,10 +39,8 @@ class _MatrixWidgetState extends State<MatrixWidget>
static const _flipDuration = Duration(milliseconds: 450);
static const _afterFlipHold = Duration(milliseconds: 650);
late math.Random _random;
late List<MatrixCard?> _slots; // stable positions; null == removed
late String _targetCardId;
late String _targetWord;
var _currentStageIndex = 0;
final _flipped = <String>{};
final _correctIdsInOrder = <String>[];
@ -49,6 +51,9 @@ class _MatrixWidgetState extends State<MatrixWidget>
late final AnimationController _shakeController;
late final Animation<double> _shakeOffset;
AudioPlayer? _audioPlayer;
bool _isPlayingAudio = false;
@override
void initState() {
super.initState();
@ -76,23 +81,78 @@ class _MatrixWidgetState extends State<MatrixWidget>
}
void _initFromQuestion(MatrixQuestion q) {
_random = math.Random(q.id.hashCode ^ q.matrixSize);
_slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false);
_flipped.clear();
_correctIdsInOrder.clear();
_wrongAttempts = 0;
_isCompleting = false;
_shakingCardId = null;
_targetCardId = q.initialTargetCardId;
_targetWord = q.initialTargetWord;
_currentStageIndex = 0;
// Play audio for first stage if available
if (q.stages.isNotEmpty) {
_playStageAudio(q.stages[0]);
}
}
@override
void dispose() {
_shakeController.dispose();
_audioPlayer?.dispose();
super.dispose();
}
Future<void> _playStageAudio(MatrixStage stage) async {
if (stage.targetAudio == null || stage.targetAudio!.isEmpty) return;
_audioPlayer ??= AudioPlayer();
if (!mounted) return;
// Get appScope before async operations
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
if (appScope == null) {
debugPrint('MatrixWidget: Scope not available');
return;
}
setState(() {
_isPlayingAudio = true;
});
try {
await _audioPlayer!.stop();
final audioValue = stage.targetAudio!;
Uint8List bytes;
// Check if it's already a presigned URL (contains query params)
final uri = Uri.tryParse(audioValue);
if (uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.queryParameters.isNotEmpty) {
// Likely a presigned URL, download directly
bytes = await appScope.httpRepository.downloadBytesFromUrl(audioValue);
} else {
// Assume it's a UUID, use voice API endpoint
bytes = await appScope.httpRepository.getVoiceFileBytes(audioValue);
}
if (!mounted) return;
await _audioPlayer!.play(BytesSource(bytes));
} catch (e) {
debugPrint('MatrixWidget: Failed to play audio: $e');
// Could show error to user here if needed
} finally {
if (mounted) {
setState(() {
_isPlayingAudio = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -110,78 +170,199 @@ class _MatrixWidgetState extends State<MatrixWidget>
...List<MatrixCard?>.filled(total - _slots.length, null),
];
// Calculate available height for grid (reserve space for target container)
// Target container: ~100-120px (compact) to 140-160px (full)
final targetContainerHeight = widget.maxHeight != null && widget.maxHeight! < 350
? 100.0
: 140.0;
final spacingBetween = widget.maxHeight != null && widget.maxHeight! < 350
? 12.h
: 16.h;
final gridMaxHeight = widget.maxHeight != null
? widget.maxHeight! - targetContainerHeight - spacingBetween
: null;
// Adapt spacing for grid cells
final cellSpacing = widget.maxHeight != null && widget.maxHeight! < 350
? 6.0
: 10.0;
// Adapt padding for target container
final targetPaddingH = widget.maxHeight != null && widget.maxHeight! < 350
? 16.w
: 20.w;
final targetPaddingV = widget.maxHeight != null && widget.maxHeight! < 350
? 12.h
: 16.h;
return ConstrainedBox(
constraints: widget.maxHeight != null
? BoxConstraints(maxHeight: widget.maxHeight!)
: const BoxConstraints(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ConstrainedBox(
constraints: gridMaxHeight != null
? BoxConstraints(maxHeight: gridMaxHeight)
: const BoxConstraints(),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: size,
crossAxisSpacing: cellSpacing,
mainAxisSpacing: cellSpacing,
childAspectRatio: 1.0,
),
itemCount: total,
itemBuilder: (context, index) {
final card = slots[index];
return _buildSlot(context, card);
},
),
),
),
SizedBox(height: spacingBetween),
Container(
padding: EdgeInsets.symmetric(
horizontal: targetPaddingH,
vertical: targetPaddingV,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(
color: colorScheme.primary,
width: 3,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.question.text != null && widget.question.text!.isNotEmpty)
Text(
widget.question.text!,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (widget.question.text != null && widget.question.text!.isNotEmpty)
SizedBox(height: 8.h),
_buildTargetContent(context),
],
),
),
],
),
);
}
Widget _buildTargetContent(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final colorScheme = theme.colorScheme;
final currentStage = _getCurrentStage();
if (currentStage == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: size,
crossAxisSpacing: 10.w,
mainAxisSpacing: 10.h,
childAspectRatio: 1.0,
// Audio button if audio is available
if (currentStage.targetAudio != null && currentStage.targetAudio!.isNotEmpty)
Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: IconButton(
icon: _isPlayingAudio
? SizedBox(
width: 24.w,
height: 24.h,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary,
),
),
)
: Icon(
Icons.volume_up,
size: 32.sp,
color: colorScheme.primary,
),
onPressed: _isPlayingAudio
? null
: () => _playStageAudio(currentStage),
),
),
itemCount: total,
itemBuilder: (context, index) {
final card = slots[index];
return _buildSlot(context, card);
},
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.35),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
Text(
'Найди слово:',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
SizedBox(height: 6.h),
SelectableText(
_targetWord,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
// Target word
SelectableText(
currentStage.targetWord,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
);
}
MatrixStage? _getCurrentStage() {
final stages = widget.question.stages;
if (stages.isEmpty) {
// Fallback to deprecated fields
if (widget.question.initialTargetCardId != null &&
widget.question.initialTargetWord != null) {
return MatrixStage(
targetCardId: widget.question.initialTargetCardId!,
targetWord: widget.question.initialTargetWord!,
targetAudio: null,
);
}
return null;
}
if (_currentStageIndex >= stages.length) {
return null;
}
return stages[_currentStageIndex];
}
Widget _buildSlot(BuildContext context, MatrixCard? card) {
final colorScheme = Theme.of(context).colorScheme;
final isFlipped = card != null && _flipped.contains(card.id);
final isShaking = card != null && _shakingCardId == card.id;
if (card == null) {
return AspectRatio(
aspectRatio: 1,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24.r),
),
),
);
}
final isFlipped = _flipped.contains(card.id);
final isShaking = _shakingCardId == card.id;
final base = AspectRatio(
aspectRatio: 1,
child: card == null
? DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.r),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.7),
),
color: colorScheme.surfaceContainerHighest.withOpacity(0.15),
),
)
: _MatrixFlipCard(
card: card,
isFlipped: isFlipped,
onTap: _isCompleting ? null : () => _onCardTap(card),
),
child: _MatrixFlipCard(
card: card,
isFlipped: isFlipped,
onTap: _isCompleting || isFlipped ? null : () => _onCardTap(card),
),
);
if (!isShaking) return base;
@ -200,9 +381,12 @@ class _MatrixWidgetState extends State<MatrixWidget>
Future<void> _onCardTap(MatrixCard card) async {
if (_isCompleting) return;
if (_flipped.contains(card.id)) return;
if (_flipped.contains(card.id)) return; // Already flipped, ignore tap
if (card.id == _targetCardId) {
final currentStage = _getCurrentStage();
if (currentStage == null) return;
if (card.id == currentStage.targetCardId) {
await _handleCorrect(card);
} else {
await _handleWrong(card);
@ -234,25 +418,27 @@ class _MatrixWidgetState extends State<MatrixWidget>
await Future<void>.delayed(_flipDuration + _afterFlipHold);
// Remove from matrix (keep slot stable -> set to null)
final idx = _slots.indexWhere((c) => c?.id == card.id);
if (idx >= 0) {
setState(() {
_slots[idx] = null;
});
}
// Check if all cards are flipped or all stages completed
final allFlipped = _slots
.whereType<MatrixCard>()
.every((c) => _flipped.contains(c.id));
final allStagesCompleted = _currentStageIndex >= widget.question.stages.length - 1;
final remaining = _slots.whereType<MatrixCard>().toList(growable: false);
if (remaining.isEmpty) {
if (allFlipped || allStagesCompleted) {
await _completeQuestion();
return;
}
final next = remaining[_random.nextInt(remaining.length)];
// Move to next stage
setState(() {
_targetCardId = next.id;
_targetWord = next.original;
_currentStageIndex++;
});
// Play audio for next stage if available
final nextStage = _getCurrentStage();
if (nextStage != null) {
_playStageAudio(nextStage);
}
}
Future<void> _completeQuestion() async {
@ -291,11 +477,10 @@ class _MatrixFlipCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14.r),
borderRadius: BorderRadius.circular(24.r),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0,
@ -312,46 +497,66 @@ class _MatrixFlipCard extends StatelessWidget {
return Transform(
transform: transform,
alignment: Alignment.center,
child: ClipRRect(
borderRadius: BorderRadius.circular(14.r),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24.r),
border: Border.all(
color: colorScheme.primary,
width: 3,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(21.r),
child: isBack
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi),
child: Center(
child: Padding(
padding: EdgeInsets.all(10.w),
child: Text(
card.translation,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
padding: EdgeInsets.all(24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (card.original != null)
Text(
card.original!,
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (card.original != null && card.translation != null)
SizedBox(height: 12.h),
if (card.translation != null)
Text(
card.translation!,
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
)
: Image.network(
card.image, // Already contains presigned URL or objectId from tests_state_manager
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
color: colorScheme.onSurfaceVariant,
),
);
},
),
: _buildCardFront(card, colorScheme),
),
),
);
@ -359,4 +564,123 @@ class _MatrixFlipCard extends StatelessWidget {
),
);
}
Widget _buildCardFront(MatrixCard card, ColorScheme colorScheme) {
final hasImage = card.image != null && card.image!.isNotEmpty;
final hasOriginal = card.original != null && card.original!.isNotEmpty;
final hasTranslation = card.translation != null && card.translation!.isNotEmpty;
final hasText = hasOriginal || hasTranslation;
// If only text (no image), show text centered and large
if (!hasImage && hasText) {
return Center(
child: Padding(
padding: EdgeInsets.all(24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasOriginal)
Text(
card.original!,
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (hasOriginal && hasTranslation) SizedBox(height: 8.h),
if (hasTranslation)
Text(
card.translation!,
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
// If only image (no text), show image full size
if (hasImage && !hasText) {
return Image.network(
card.image!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
color: colorScheme.onSurfaceVariant,
),
);
},
);
}
// If both image and text, show text on top and image below
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasText)
Padding(
padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 8.h),
child: Column(
children: [
if (hasOriginal)
Text(
card.original!,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasOriginal && hasTranslation) SizedBox(height: 4.h),
if (hasTranslation)
Text(
card.translation!,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (hasImage)
Expanded(
child: Image.network(
card.image!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
color: colorScheme.onSurfaceVariant,
),
);
},
),
),
],
);
}
}

View file

@ -1,6 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// Compactness mode for progress indicator
enum ProgressIndicatorMode {
/// Full mode: shows all statistics (correct, time, accuracy)
full,
/// Compact mode: shows progress bar and question number with minimal stats
compact,
/// Mini mode: shows only progress bar and question number
mini,
}
/// Widget for displaying game progress
class GameProgressIndicator extends StatelessWidget {
const GameProgressIndicator({
@ -8,6 +18,7 @@ class GameProgressIndicator extends StatelessWidget {
required this.totalQuestions,
required this.correctAnswers,
required this.timeElapsed,
this.mode = ProgressIndicatorMode.full,
super.key,
});
@ -15,14 +26,25 @@ class GameProgressIndicator extends StatelessWidget {
final int totalQuestions;
final int correctAnswers;
final Duration timeElapsed;
final ProgressIndicatorMode mode;
@override
Widget build(BuildContext context) {
final progress = currentQuestion / totalQuestions;
final accuracy = currentQuestion > 0 ? correctAnswers / currentQuestion : 0.0;
// Adjust padding based on mode
final horizontalPadding = mode == ProgressIndicatorMode.mini ? 12.w : 16.w;
final verticalPadding = mode == ProgressIndicatorMode.mini ? 4.h : (mode == ProgressIndicatorMode.compact ? 6.h : 8.h);
final progressBarHeight = mode == ProgressIndicatorMode.mini ? 4.h : 6.h;
final showStats = mode == ProgressIndicatorMode.full;
final showPercentage = mode != ProgressIndicatorMode.mini;
return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16.r),
@ -37,7 +59,7 @@ class GameProgressIndicator extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress bar
// Progress row with question number
Row(
children: [
Text(
@ -45,30 +67,34 @@ class GameProgressIndicator extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null,
),
),
Text(
' / $totalQuestions',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null,
),
),
const Spacer(),
Text(
'${(progress * 100).round()}%',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (showPercentage) ...[
const Spacer(),
Text(
'${(progress * 100).round()}%',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
SizedBox(height: 4.h),
SizedBox(height: mode == ProgressIndicatorMode.mini ? 4.h : 4.h),
// Progress bar
Container(
height: 6.h,
height: progressBarHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(3.r),
@ -90,35 +116,36 @@ class GameProgressIndicator extends StatelessWidget {
),
),
SizedBox(height: 8.h),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(
context: context,
icon: Icons.check_circle,
value: '$correctAnswers',
label: 'Correct',
color: Colors.green,
),
_buildStatItem(
context: context,
icon: Icons.schedule,
value: _formatDuration(timeElapsed),
label: 'Time',
color: Theme.of(context).colorScheme.primary,
),
_buildStatItem(
context: context,
icon: Icons.trending_up,
value: '${(accuracy * 100).round()}%',
label: 'Accuracy',
color: accuracy >= 0.8 ? Colors.green : accuracy >= 0.6 ? Colors.orange : Colors.red,
),
],
),
// Stats row (only in full mode)
if (showStats) ...[
SizedBox(height: 8.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(
context: context,
icon: Icons.check_circle,
value: '$correctAnswers',
label: 'Correct',
color: Colors.green,
),
_buildStatItem(
context: context,
icon: Icons.schedule,
value: _formatDuration(timeElapsed),
label: 'Time',
color: Theme.of(context).colorScheme.primary,
),
_buildStatItem(
context: context,
icon: Icons.trending_up,
value: '${(accuracy * 100).round()}%',
label: 'Accuracy',
color: accuracy >= 0.8 ? Colors.green : accuracy >= 0.6 ? Colors.orange : Colors.red,
),
],
),
],
],
),
);

View file

@ -10,11 +10,13 @@ class QuestionDisplay extends StatelessWidget {
const QuestionDisplay({
required this.question,
this.onPlayAudio,
this.maxHeight,
super.key,
});
final GameQuestion question;
final QuestionAudioPlayback? onPlayAudio;
final double? maxHeight;
@override
Widget build(BuildContext context) {
@ -70,61 +72,92 @@ class QuestionDisplay extends StatelessWidget {
}) {
final theme = Theme.of(context).textTheme;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Image display
if (image != null) ...[
Container(
constraints: BoxConstraints(
maxHeight: 200.h,
maxWidth: double.infinity,
),
child: Image.network(
image,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 120.h,
width: 120.w,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.image_not_supported,
size: 48.sp,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
},
),
),
SizedBox(height: 16.h),
],
// Calculate available height for image (30-40% of maxHeight, min 120, max 250)
double? imageMaxHeight;
if (maxHeight != null) {
final imageHeight = maxHeight! * 0.35;
imageMaxHeight = imageHeight.clamp(120.0, 250.0);
} else {
imageMaxHeight = 200.h;
}
// Text display
if (text.isNotEmpty) ...[
Text(
text,
style: theme.headlineSmall?.copyWith(
fontSize: 20.sp,
height: 1.4,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
// Calculate spacing based on available height
final spacingAfterImage = maxHeight != null && maxHeight! < 300 ? 8.h : 12.h;
final spacingAfterText = maxHeight != null && maxHeight! < 300 ? 6.h : 8.h;
// Audio button
if (audio != null) ...[
SizedBox(height: 12.h),
_QuestionAudioButton(
audioUrl: audio,
onPlayAudio: onPlayAudio,
),
// Determine max lines for text based on available height
final maxLines = maxHeight != null && maxHeight! < 250 ? 3 : 5;
// Adjust font size slightly on very small screens
final fontSize = maxHeight != null && maxHeight! < 200 ? 18.sp : 20.sp;
return ConstrainedBox(
constraints: maxHeight != null
? BoxConstraints(maxHeight: maxHeight!)
: const BoxConstraints(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Image display
if (image != null) ...[
Flexible(
child: Container(
constraints: BoxConstraints(
maxHeight: imageMaxHeight,
maxWidth: double.infinity,
),
child: Image.network(
image,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 120.h,
width: 120.w,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.image_not_supported,
size: 48.sp,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
},
),
),
),
SizedBox(height: spacingAfterImage),
],
// Text display
if (text.isNotEmpty) ...[
Flexible(
child: Text(
text,
style: theme.headlineSmall?.copyWith(
fontSize: fontSize,
height: 1.4,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: maxLines,
overflow: TextOverflow.fade,
),
),
],
// Audio button
if (audio != null) ...[
SizedBox(height: spacingAfterText),
_QuestionAudioButton(
audioUrl: audio,
onPlayAudio: onPlayAudio,
),
],
],
],
),
);
}
}