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` - 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 - 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 - 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 - **CardViewer Carousel Looping**: Implemented infinite carousel scrolling for card viewer
- Cards now loop seamlessly: last card → first card and first card → last card - Cards now loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons - 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 DAO helpers and a focused unit test for the cleanup logic
- Added regression test to ensure `updateGeneratedTests()` creates a pack link - 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 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 - **MinIO Migration**: Migrated file storage from local filesystem to MinIO object storage
- Created MinioService for MinIO operations (upload, presigned URLs, delete, fileExists) - 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 - 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) - 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) - Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear)
- Admin: matrix size configuration + JSON (de)serialization + tests - 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 - [x] **CardViewer Carousel Looping**: Implement infinite carousel scrolling for card viewer
- Cards loop seamlessly: last card → first card and first card → last card - Cards loop seamlessly: last card → first card and first card → last card
- Works for both swipe gestures and navigation buttons - 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'; return '/api/v2/packs/${data.packId}/cards/$imageId/image';
} }
InputButtonsQuestionType _getRandomQuestionType() { InputButtonsQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) {
final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values); 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)]; return types[random.nextInt(types.length)];
} }
@ -66,35 +82,44 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
.take(4) .take(4)
.toList(); .toList();
// Check if audio is available before selecting question type
final hasAudio = _getAudioUuid(answerItem) != null;
// mb use custom ids // 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 = final questionText =
questionType.translationQuestion ? answerItem.translation : null; finalQuestionType.translationQuestion ? answerItem.translation : null;
final questionImage = final questionImage =
questionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null; finalQuestionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (finalQuestionType.audioQuestion) {
// Use UUID from MinIO if available, otherwise null // Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerItem); questionAudio = _getAudioUuid(answerItem);
} else if (withAudio) { } else if (withAudio) {
// nothing to fo here // nothing to fo here
} }
final answer = questionType.translationAnswer final answer = finalQuestionType.translationAnswer
? answerItem.translation ? answerItem.translation
: answerItem.original; : answerItem.original;
String template = answer.asTemplate; String template = answer.asTemplate;
if (showArticle && if (showArticle &&
answer.contains(' ') && answer.contains(' ') &&
!questionType.translationAnswer) { !finalQuestionType.translationAnswer) {
final article = answer.split(' ').firstOrNull; final article = answer.split(' ').firstOrNull;
if (article != null) { if (article != null) {
template = template =
'$article ${answer.substring(article.length + 1).asTemplate}'; '$article ${answer.substring(article.length + 1).asTemplate}';
} }
} }
if (visibleButtonsPercent > 0 && !questionType.translationAnswer) { if (visibleButtonsPercent > 0 && !finalQuestionType.translationAnswer) {
var matches = '_'.allMatches(template).toList(); var matches = '_'.allMatches(template).toList();
int visibleLetters = (visibleButtonsPercent * matches.length).floor(); int visibleLetters = (visibleButtonsPercent * matches.length).floor();
while (visibleLetters-- > 0) { while (visibleLetters-- > 0) {
@ -115,7 +140,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
word: answerItem.original, word: answerItem.original,
audio: questionAudio, audio: questionAudio,
template: template, template: template,
buttons: (questionType.translationAnswer buttons: (finalQuestionType.translationAnswer
? [ ? [
...answerItem.translation.split(''), ...answerItem.translation.split(''),
...otherCards.expand((c) => c.translation.split('').toSet()) ...otherCards.expand((c) => c.translation.split('').toSet())
@ -124,7 +149,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
...answerItem.original.split(''), ...answerItem.original.split(''),
...otherCards.expand((c) => c.original.split('').toSet()), ...otherCards.expand((c) => c.original.split('').toSet()),
]) ])
.map((e) => e.trim()) .map((e) => e.trim().toLowerCase())
.where((ch) => ch.isNotEmpty) .where((ch) => ch.isNotEmpty)
.take(20) .take(20)
.mapIndexed((index, ch) => TestButtonDto('${ch}_$index', null, ch)) .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 '../models/creation_test_data.dart';
import 'question_generator.dart'; import 'question_generator.dart';
/// Generates a matrix-image question: enum MatrixQuestionType {
/// - shows N x N images (cards from the pack pool) original_translation, // оригинал снизу, переведенные карточки
/// - shows a target word (original) under the matrix 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 /// - user must click the matching card
/// ///
/// The question is multi-step on the client side: after a correct click, /// 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. /// the card flips and a new target is shown from stages array.
/// Backend only provides the initial target via [word] + [answer]. /// Backend generates all stages upfront.
class MatrixQuestionGenerator implements QuestionGenerator { class MatrixQuestionGenerator implements QuestionGenerator {
MatrixQuestionGenerator( MatrixQuestionGenerator(
this.data, { this.data, {
this.seed, this.seed,
this.allowedSizes = const [2, 3, 4], this.allowedSizes = const [2, 3, 4],
this.fixedMatrixSize, this.fixedMatrixSize,
this.possibleTypes,
}) : random = Random(seed); }) : random = Random(seed);
final CreationTestData data; final CreationTestData data;
@ -32,11 +71,55 @@ class MatrixQuestionGenerator implements QuestionGenerator {
/// items exist). /// items exist).
final int? fixedMatrixSize; 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) { String? _imageIdToUrl(String? imageId) {
if (imageId == null || data.packId == null) return imageId; if (imageId == null || data.packId == null) return imageId;
return '/api/v2/packs/${data.packId}/cards/$imageId/image'; 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) { int _maxPossibleSize(int poolSize) {
if (poolSize <= 0) return 0; if (poolSize <= 0) return 0;
return sqrt(poolSize).floor(); return sqrt(poolSize).floor();
@ -57,6 +140,40 @@ class MatrixQuestionGenerator implements QuestionGenerator {
return allowed[random.nextInt(allowed.length)]; 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 @override
Future<AbstractTestQuestion> generate(TestDataItem answerCard) async { Future<AbstractTestQuestion> generate(TestDataItem answerCard) async {
final pool = data.items; final pool = data.items;
@ -73,23 +190,58 @@ class MatrixQuestionGenerator implements QuestionGenerator {
final selected = <TestDataItem>[answerCard, ...others]..shuffle(random); final selected = <TestDataItem>[answerCard, ...others]..shuffle(random);
final cards = selected // Determine question type based on available data
.map( final questionType = _getRandomQuestionType(selectedCards: selected);
(c) => MatrixCardDto(
id: c.id, // Generate stages for all cards
image: _imageIdToUrl(c.image) ?? c.image ?? c.id, final stages = _generateStages(selected, questionType);
original: c.original,
translation: c.translation, // Create cards based on question type
), final cards = selected.map((c) {
) if (questionType.translationCards) {
.toList(); 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( return MatrixTestQuestionBody(
matrixSize: matrixSize, matrixSize: matrixSize,
cards: cards, cards: cards,
// First step: target is the provided answerCard. stages: stages,
word: answerCard.original, // Backward compatibility fields
answer: answerCard.id, 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'; return '/api/v2/packs/${data.packId}/cards/$imageId/image';
} }
SimpleQuestionType _getRandomQuestionType() { SimpleQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) {
final types = (possibleTypes?.toList() ?? SimpleQuestionType.values); 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)]; return types[random.nextInt(types.length)];
} }
@ -62,28 +78,38 @@ class SimpleQuestionGenerator implements QuestionGenerator {
(data.items.where((element) => element.id != answerCard.id).toList() (data.items.where((element) => element.id != answerCard.id).toList()
..shuffle()) ..shuffle())
.take(3); .take(3);
// Check if audio is available before selecting question type
final hasAudio = _getAudioUuid(answerCard) != null;
// mb use custom ids // mb use custom ids
final questionType = type ?? _getRandomQuestionType(); // If no type specified, select random type excluding audio types if audio is not available
final questionText = questionType.originalQuestion 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 ? answerCard.original
: questionType.translationQuestion : finalQuestionType.translationQuestion
? answerCard.translation ? answerCard.translation
: null; : null;
final questionImage = final questionImage =
questionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null; finalQuestionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (finalQuestionType.audioQuestion) {
if (questionType.translationAnswers || questionType.imagesAnswers) { if (finalQuestionType.translationAnswers || finalQuestionType.imagesAnswers) {
// Use UUID from MinIO if available, otherwise null // Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerCard); questionAudio = _getAudioUuid(answerCard);
} }
} else if (withAudio) { } else if (withAudio) {
if (questionText != null && questionType.originalQuestion) { if (questionText != null && finalQuestionType.originalQuestion) {
// For original questions with text, audio is handled separately // For original questions with text, audio is handled separately
// Use UUID if available, otherwise null // Use UUID if available, otherwise null
questionAudio = _getAudioUuid(answerCard); questionAudio = _getAudioUuid(answerCard);
} else if (questionType.translationAnswers || } else if (finalQuestionType.translationAnswers ||
questionType.imagesAnswers) { finalQuestionType.imagesAnswers) {
// Use UUID from MinIO if available, otherwise null // Use UUID from MinIO if available, otherwise null
questionAudio = _getAudioUuid(answerCard); questionAudio = _getAudioUuid(answerCard);
} }
@ -97,15 +123,15 @@ class SimpleQuestionGenerator implements QuestionGenerator {
audio: questionAudio, audio: questionAudio,
word: answerCard.original, word: answerCard.original,
buttons: [ buttons: [
if (questionType.translationAnswers) if (finalQuestionType.translationAnswers)
...answers.map( ...answers.map(
(e) => TestButtonDto.text(e.id, e.translation), (e) => TestButtonDto.text(e.id, e.translation),
) )
else if (questionType.originalAnswers) else if (finalQuestionType.originalAnswers)
...answers.map( ...answers.map(
(e) => TestButtonDto.text(e.id, e.original), (e) => TestButtonDto.text(e.id, e.original),
) )
else if (questionType.imagesAnswers) else if (finalQuestionType.imagesAnswers)
...answers.map( ...answers.map(
(e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)), (e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)),
) )

View file

@ -43,6 +43,7 @@ void main() {
expect(mq.answer, answerCard.id); expect(mq.answer, answerCard.id);
expect(mq.word, answerCard.original); expect(mq.word, answerCard.original);
expect(mq.text, 'Найди слово:');
expect(ids.contains(answerCard.id), isTrue); 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>[]) @JsonKey(name: 'buttons', defaultValue: <MatrixCardDto>[])
final List<MatrixCardDto> cards; 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. /// 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: '') @JsonKey(defaultValue: '')
final String answer; final String answer;
/// Question text (e.g., "Найди слово:")
final String? text;
MatrixTestQuestionBody({ MatrixTestQuestionBody({
super.id, super.id,
required this.matrixSize, required this.matrixSize,
required this.cards, required this.cards,
this.stages = const [],
required this.answer, required this.answer,
required super.word, required super.word,
this.text,
super.questionType = TestQuestionType.matrix, super.questionType = TestQuestionType.matrix,
}); });
@ -42,21 +53,40 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
Map<String, Object?> toJson() => _$MatrixTestQuestionBodyToJson(this); 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() @JsonSerializable()
@CopyWith() @CopyWith()
class MatrixCardDto { class MatrixCardDto {
final String id; 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? imageUrl; // Presigned URL (for display)
final String original; final String? original;
final String translation; final String? translation;
const MatrixCardDto({ const MatrixCardDto({
required this.id, required this.id,
required this.image, this.image,
this.imageUrl, this.imageUrl,
required this.original, this.original,
required this.translation, this.translation,
}); });
factory MatrixCardDto.fromJson(Map<String, dynamic> json) => factory MatrixCardDto.fromJson(Map<String, dynamic> json) =>

View file

@ -13,10 +13,14 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
MatrixTestQuestionBody cards(List<MatrixCardDto> cards); MatrixTestQuestionBody cards(List<MatrixCardDto> cards);
MatrixTestQuestionBody stages(List<MatrixStageDto> stages);
MatrixTestQuestionBody answer(String answer); MatrixTestQuestionBody answer(String answer);
MatrixTestQuestionBody word(String word); MatrixTestQuestionBody word(String word);
MatrixTestQuestionBody text(String? text);
MatrixTestQuestionBody questionType(TestQuestionType questionType); MatrixTestQuestionBody questionType(TestQuestionType questionType);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
@ -30,8 +34,10 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
String? id, String? id,
int matrixSize, int matrixSize,
List<MatrixCardDto> cards, List<MatrixCardDto> cards,
List<MatrixStageDto> stages,
String answer, String answer,
String word, String word,
String? text,
TestQuestionType questionType, TestQuestionType questionType,
}); });
} }
@ -54,12 +60,19 @@ class _$MatrixTestQuestionBodyCWProxyImpl
@override @override
MatrixTestQuestionBody cards(List<MatrixCardDto> cards) => call(cards: cards); MatrixTestQuestionBody cards(List<MatrixCardDto> cards) => call(cards: cards);
@override
MatrixTestQuestionBody stages(List<MatrixStageDto> stages) =>
call(stages: stages);
@override @override
MatrixTestQuestionBody answer(String answer) => call(answer: answer); MatrixTestQuestionBody answer(String answer) => call(answer: answer);
@override @override
MatrixTestQuestionBody word(String word) => call(word: word); MatrixTestQuestionBody word(String word) => call(word: word);
@override
MatrixTestQuestionBody text(String? text) => call(text: text);
@override @override
MatrixTestQuestionBody questionType(TestQuestionType questionType) => MatrixTestQuestionBody questionType(TestQuestionType questionType) =>
call(questionType: questionType); call(questionType: questionType);
@ -76,8 +89,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
Object? id = const $CopyWithPlaceholder(), Object? id = const $CopyWithPlaceholder(),
Object? matrixSize = const $CopyWithPlaceholder(), Object? matrixSize = const $CopyWithPlaceholder(),
Object? cards = const $CopyWithPlaceholder(), Object? cards = const $CopyWithPlaceholder(),
Object? stages = const $CopyWithPlaceholder(),
Object? answer = const $CopyWithPlaceholder(), Object? answer = const $CopyWithPlaceholder(),
Object? word = const $CopyWithPlaceholder(), Object? word = const $CopyWithPlaceholder(),
Object? text = const $CopyWithPlaceholder(),
Object? questionType = const $CopyWithPlaceholder(), Object? questionType = const $CopyWithPlaceholder(),
}) { }) {
return MatrixTestQuestionBody( return MatrixTestQuestionBody(
@ -94,6 +109,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
? _value.cards ? _value.cards
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: cards as List<MatrixCardDto>, : 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 answer: answer == const $CopyWithPlaceholder() || answer == null
? _value.answer ? _value.answer
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
@ -102,6 +121,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
? _value.word ? _value.word
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: word as String, : word as String,
text: text == const $CopyWithPlaceholder()
? _value.text
// ignore: cast_nullable_to_non_nullable
: text as String?,
questionType: questionType:
questionType == const $CopyWithPlaceholder() || questionType == null questionType == const $CopyWithPlaceholder() || questionType == null
? _value.questionType ? _value.questionType
@ -119,16 +142,94 @@ extension $MatrixTestQuestionBodyCopyWith on MatrixTestQuestionBody {
_$MatrixTestQuestionBodyCWProxyImpl(this); _$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 { abstract class _$MatrixCardDtoCWProxy {
MatrixCardDto id(String id); MatrixCardDto id(String id);
MatrixCardDto image(String image); MatrixCardDto image(String? image);
MatrixCardDto imageUrl(String? imageUrl); 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. /// 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)`. /// 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({ MatrixCardDto call({
String id, String id,
String image, String? image,
String? imageUrl, String? imageUrl,
String original, String? original,
String translation, String? translation,
}); });
} }
@ -157,16 +258,16 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
MatrixCardDto id(String id) => call(id: id); MatrixCardDto id(String id) => call(id: id);
@override @override
MatrixCardDto image(String image) => call(image: image); MatrixCardDto image(String? image) => call(image: image);
@override @override
MatrixCardDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl); MatrixCardDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl);
@override @override
MatrixCardDto original(String original) => call(original: original); MatrixCardDto original(String? original) => call(original: original);
@override @override
MatrixCardDto translation(String translation) => MatrixCardDto translation(String? translation) =>
call(translation: translation); call(translation: translation);
@override @override
@ -189,23 +290,22 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
? _value.id ? _value.id
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: id as String, : id as String,
image: image == const $CopyWithPlaceholder() || image == null image: image == const $CopyWithPlaceholder()
? _value.image ? _value.image
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: image as String, : image as String?,
imageUrl: imageUrl == const $CopyWithPlaceholder() imageUrl: imageUrl == const $CopyWithPlaceholder()
? _value.imageUrl ? _value.imageUrl
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: imageUrl as String?, : imageUrl as String?,
original: original == const $CopyWithPlaceholder() || original == null original: original == const $CopyWithPlaceholder()
? _value.original ? _value.original
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: original as String, : original as String?,
translation: translation: translation == const $CopyWithPlaceholder()
translation == const $CopyWithPlaceholder() || translation == null
? _value.translation ? _value.translation
// ignore: cast_nullable_to_non_nullable // 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>)) ?.map((e) => MatrixCardDto.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[], [],
stages:
(json['stages'] as List<dynamic>?)
?.map((e) => MatrixStageDto.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
answer: json['answer'] as String? ?? '', answer: json['answer'] as String? ?? '',
word: json['word'] as String, word: json['word'] as String,
text: json['text'] as String?,
questionType: questionType:
$enumDecodeNullable(_$TestQuestionTypeEnumMap, json['questionType']) ?? $enumDecodeNullable(_$TestQuestionTypeEnumMap, json['questionType']) ??
TestQuestionType.matrix, TestQuestionType.matrix,
@ -246,7 +352,9 @@ Map<String, dynamic> _$MatrixTestQuestionBodyToJson(
'word': instance.word, 'word': instance.word,
'matrixSize': instance.matrixSize, 'matrixSize': instance.matrixSize,
'buttons': instance.cards.map((e) => e.toJson()).toList(), 'buttons': instance.cards.map((e) => e.toJson()).toList(),
'stages': instance.stages.map((e) => e.toJson()).toList(),
'answer': instance.answer, 'answer': instance.answer,
'text': ?instance.text,
}; };
const _$TestQuestionTypeEnumMap = { const _$TestQuestionTypeEnumMap = {
@ -257,13 +365,27 @@ const _$TestQuestionTypeEnumMap = {
TestQuestionType.undefined: 'undefined', 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 _$MatrixCardDtoFromJson(Map<String, dynamic> json) =>
MatrixCardDto( MatrixCardDto(
id: json['id'] as String, id: json['id'] as String,
image: json['image'] as String, image: json['image'] as String?,
imageUrl: json['imageUrl'] as String?, imageUrl: json['imageUrl'] as String?,
original: json['original'] as String, original: json['original'] as String?,
translation: json['translation'] as String, translation: json['translation'] as String?,
); );
Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) => Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) =>

View file

@ -115,21 +115,24 @@ abstract class MatchPair with _$MatchPair {
_$MatchPairFromJson(json); _$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 question is multi-step on a single screen: after each correct selection
/// the chosen card flips (showing translation) and disappears, then a new /// the chosen card flips and a new target is shown from stages array until
/// target word is shown until all cards are removed. /// all cards are processed.
@freezed @freezed
abstract class MatrixQuestion with _$MatrixQuestion { abstract class MatrixQuestion with _$MatrixQuestion {
const factory MatrixQuestion({ const factory MatrixQuestion({
required String id, required String id,
required int matrixSize, required int matrixSize,
required List<MatrixCard> cards, required List<MatrixCard> cards,
required String initialTargetCardId, @Default([]) List<MatrixStage> stages,
required String initialTargetWord, // 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, required String word,
String? text,
@Default('matrix') String type, @Default('matrix') String type,
}) = _MatrixQuestion; }) = _MatrixQuestion;
@ -137,13 +140,25 @@ abstract class MatrixQuestion with _$MatrixQuestion {
_$MatrixQuestionFromJson(json); _$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 @freezed
abstract class MatrixCard with _$MatrixCard { abstract class MatrixCard with _$MatrixCard {
const factory MatrixCard({ const factory MatrixCard({
required String id, required String id,
required String image, String? image,
required String original, String? original,
required String translation, String? translation,
}) = _MatrixCard; }) = _MatrixCard;
factory MatrixCard.fromJson(Map<String, dynamic> json) => factory MatrixCard.fromJson(Map<String, dynamic> json) =>

View file

@ -2262,7 +2262,8 @@ as String,
/// @nodoc /// @nodoc
mixin _$MatrixQuestion { 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 /// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -2275,16 +2276,16 @@ $MatrixQuestionCopyWith<MatrixQuestion> get copyWith => _$MatrixQuestionCopyWith
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory $MatrixQuestionCopyWith(MatrixQuestion value, $Res Function(MatrixQuestion) _then) = _$MatrixQuestionCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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 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 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 List<MatrixCard>,stages: null == stages ? _self.stages : stages // ignore: cast_nullable_to_non_nullable
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // 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, 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) { switch (_that) {
case _MatrixQuestion() when $default != null: 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(); 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) { switch (_that) {
case _MatrixQuestion(): 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'); 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) { switch (_that) {
case _MatrixQuestion() when $default != null: 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; return null;
} }
@ -2462,7 +2465,7 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
@JsonSerializable() @JsonSerializable()
class _MatrixQuestion implements MatrixQuestion { 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); factory _MatrixQuestion.fromJson(Map<String, dynamic> json) => _$MatrixQuestionFromJson(json);
@override final String id; @override final String id;
@ -2474,9 +2477,18 @@ class _MatrixQuestion implements MatrixQuestion {
return EqualUnmodifiableListView(_cards); return EqualUnmodifiableListView(_cards);
} }
@override final String initialTargetCardId; final List<MatrixStage> _stages;
@override final String initialTargetWord; @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 word;
@override final String? text;
@override@JsonKey() final String type; @override@JsonKey() final String type;
/// Create a copy of MatrixQuestion /// Create a copy of MatrixQuestion
@ -2492,16 +2504,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory _$MatrixQuestionCopyWith(_MatrixQuestion value, $Res Function(_MatrixQuestion) _then) = __$MatrixQuestionCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_MatrixQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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 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 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 List<MatrixCard>,stages: null == stages ? _self._stages : stages // ignore: cast_nullable_to_non_nullable
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // 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, 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 /// @nodoc
mixin _$MatrixCard { 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 /// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -2582,7 +2865,7 @@ abstract mixin class $MatrixCardCopyWith<$Res> {
factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl; factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,image: freezed == 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?,original: freezed == 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?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String, 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) { switch (_that) {
case _MatrixCard() when $default != null: case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _: 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) { switch (_that) {
case _MatrixCard(): case _MatrixCard():
return $default(_that.id,_that.image,_that.original,_that.translation);case _: 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) { switch (_that) {
case _MatrixCard() when $default != null: case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _: 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() @JsonSerializable()
class _MatrixCard implements MatrixCard { 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); factory _MatrixCard.fromJson(Map<String, dynamic> json) => _$MatrixCardFromJson(json);
@override final String id; @override final String id;
@override final String image; @override final String? image;
@override final String original; @override final String? original;
@override final String translation; @override final String? translation;
/// Create a copy of MatrixCard /// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values. /// 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; factory _$MatrixCardCopyWith(_MatrixCard value, $Res Function(_MatrixCard) _then) = __$MatrixCardCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_MatrixCard(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,image: freezed == 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?,original: freezed == 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?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String, as String?,
)); ));
} }

View file

@ -196,9 +196,15 @@ _MatrixQuestion _$MatrixQuestionFromJson(Map<String, dynamic> json) =>
cards: (json['cards'] as List<dynamic>) cards: (json['cards'] as List<dynamic>)
.map((e) => MatrixCard.fromJson(e as Map<String, dynamic>)) .map((e) => MatrixCard.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
initialTargetCardId: json['initialTargetCardId'] as String, stages:
initialTargetWord: json['initialTargetWord'] as String, (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, word: json['word'] as String,
text: json['text'] as String?,
type: json['type'] as String? ?? 'matrix', type: json['type'] as String? ?? 'matrix',
); );
@ -207,17 +213,32 @@ Map<String, dynamic> _$MatrixQuestionToJson(_MatrixQuestion instance) =>
'id': instance.id, 'id': instance.id,
'matrixSize': instance.matrixSize, 'matrixSize': instance.matrixSize,
'cards': instance.cards, 'cards': instance.cards,
'stages': instance.stages,
'initialTargetCardId': instance.initialTargetCardId, 'initialTargetCardId': instance.initialTargetCardId,
'initialTargetWord': instance.initialTargetWord, 'initialTargetWord': instance.initialTargetWord,
'word': instance.word, 'word': instance.word,
'text': instance.text,
'type': instance.type, '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( _MatrixCard _$MatrixCardFromJson(Map<String, dynamic> json) => _MatrixCard(
id: json['id'] as String, id: json['id'] as String,
image: json['image'] as String, image: json['image'] as String?,
original: json['original'] as String, original: json['original'] as String?,
translation: json['translation'] as String, translation: json['translation'] as String?,
); );
Map<String, dynamic> _$MatrixCardToJson(_MatrixCard instance) => Map<String, dynamic> _$MatrixCardToJson(_MatrixCard instance) =>

View file

@ -290,11 +290,27 @@ class TestsStateManager extends StateManager<TestsState> {
}); });
/// Complete the game session /// 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; 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 { try {
log('Processing completion...', name: 'TestsStateManager');
// Check if current question has been answered // Check if current question has been answered
final currentQuestion = currentState.questions[currentState.currentQuestionIndex]; final currentQuestion = currentState.questions[currentState.currentQuestionIndex];
final questionId = _getQuestionId(currentQuestion); final questionId = _getQuestionId(currentQuestion);
@ -309,11 +325,15 @@ class TestsStateManager extends StateManager<TestsState> {
_gameSessionManager.submitAnswer(questionId, currentQuestion, ''); _gameSessionManager.submitAnswer(questionId, currentQuestion, '');
} }
log('Calling gameSessionManager.completeSession', name: 'TestsStateManager');
final result = _gameSessionManager.completeSession(_currentTest!.id!); final result = _gameSessionManager.completeSession(_currentTest!.id!);
log('completeSession returned result', name: 'TestsStateManager');
// Play completion sound // Play completion sound
log('Playing completion sound', name: 'TestsStateManager');
await _gameSoundService.playGameComplete(); await _gameSoundService.playGameComplete();
log('Emitting gameSessionCompleted state', name: 'TestsStateManager');
emit(TestsState.gameSessionCompleted( emit(TestsState.gameSessionCompleted(
test: _currentTest!, test: _currentTest!,
result: result, result: result,
@ -331,6 +351,7 @@ class TestsStateManager extends StateManager<TestsState> {
emit(TestsState.error('Failed to complete session: ${e.toString()}')); emit(TestsState.error('Failed to complete session: ${e.toString()}'));
} }
}); });
}
/// Reset game session /// Reset game session
Future<void> resetGameSession() => handle((emit) async { Future<void> resetGameSession() => handle((emit) async {
@ -434,7 +455,7 @@ class TestsStateManager extends StateManager<TestsState> {
questions.add(GameQuestion.multipleChoice( questions.add(GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
id: 'q_${questions.length}', id: 'q_${questions.length}',
question: question.w ?? '', question: question.text ?? '',
image: question.imageUrl ?? question.image, // Use presigned URL if available image: question.imageUrl ?? question.image, // Use presigned URL if available
audio: question.audio, audio: question.audio,
options: options, // Keep for backward compatibility options: options, // Keep for backward compatibility
@ -479,15 +500,36 @@ class TestsStateManager extends StateManager<TestsState> {
) )
.toList(); .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( questions.add(
GameQuestion.matrix( GameQuestion.matrix(
MatrixQuestion( MatrixQuestion(
id: id, id: id,
matrixSize: question.matrixSize, matrixSize: question.matrixSize,
cards: cards, cards: cards,
initialTargetCardId: question.answer, stages: stages,
initialTargetWord: question.word, initialTargetCardId: initialTargetCardId,
initialTargetWord: initialTargetWord,
word: question.word, 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/error_view.dart';
import '../../../presentation/widgets/game/answer_options.dart'; import '../../../presentation/widgets/game/answer_options.dart';
import '../../../presentation/widgets/game/input_letters_widget.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/matrix_widget.dart';
import '../../../presentation/widgets/game/progress_indicator.dart'; import '../../../presentation/widgets/game/progress_indicator.dart';
import '../../../presentation/widgets/game/question_display.dart'; import '../../../presentation/widgets/game/question_display.dart';
@ -133,6 +134,7 @@ class _GamePageState extends State<GamePage> {
} }
Widget _buildBody(TestsState state) { Widget _buildBody(TestsState state) {
log('_buildBody called with state: ${state.runtimeType}', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false); final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope; final userScope = appScope?.userScopeHolder.scope;
final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero; final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero;
@ -229,6 +231,34 @@ class _GamePageState extends State<GamePage> {
builder: (context, constraints) { builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 720; final isNarrow = constraints.maxWidth < 720;
final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth; 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( return Center(
child: ConstrainedBox( child: ConstrainedBox(
@ -236,100 +266,119 @@ class _GamePageState extends State<GamePage> {
child: Column( child: Column(
children: [ children: [
// Progress indicator // Progress indicator
Container( Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
child: Row( child: GameProgressIndicator(
children: [ currentQuestion: currentQuestionIndex,
Expanded( totalQuestions: questions.length,
child: GameProgressIndicator( correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
currentQuestion: currentQuestionIndex, timeElapsed: sessionElapsed,
totalQuestions: questions.length, mode: progressMode,
correctAnswers: questionResults.values.where((r) => r.isCorrect).length, ),
timeElapsed: sessionElapsed,
),
),
],
),
), ),
SizedBox(height: spacingAfterProgress),
// Question content // Question content
Expanded( Expanded(
child: SingleChildScrollView( child: Padding(
padding: EdgeInsets.all(isNarrow ? 12.w : 16.w), padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Question display // Question display
AnimatedSwitcher( Flexible(
duration: const Duration(milliseconds: 300), flex: 2,
child: KeyedSubtree( child: ConstrainedBox(
key: questionKey, constraints: BoxConstraints(maxHeight: questionMaxHeight),
child: Material( child: AnimatedSwitcher(
key: GamePage.questionCardKey, duration: const Duration(milliseconds: 300),
color: colorScheme.surface, child: KeyedSubtree(
surfaceTintColor: colorScheme.surfaceTint, key: questionKey,
elevation: 3, child: Material(
shadowColor: theme.shadowColor.withOpacity( key: GamePage.questionCardKey,
theme.brightness == Brightness.dark ? 0.35 : 0.14, color: colorScheme.surface,
), surfaceTintColor: colorScheme.surfaceTint,
shape: RoundedRectangleBorder( elevation: 3,
borderRadius: BorderRadius.circular(18.r), shadowColor: theme.shadowColor.withOpacity(
side: BorderSide( theme.brightness == Brightness.dark ? 0.35 : 0.14,
color: colorScheme.outlineVariant, ),
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 // Answer input based on question type with smooth transitions
AnimatedSwitcher( Flexible(
duration: const Duration(milliseconds: 400), flex: 3,
switchInCurve: Curves.easeInOut, child: ConstrainedBox(
switchOutCurve: Curves.easeInOut, constraints: BoxConstraints(maxHeight: answerMaxHeight),
transitionBuilder: (child, animation) { child: AnimatedSwitcher(
return FadeTransition( duration: const Duration(milliseconds: 400),
opacity: animation, switchInCurve: Curves.easeInOut,
child: SlideTransition( switchOutCurve: Curves.easeInOut,
position: Tween<Offset>( transitionBuilder: (child, animation) {
begin: const Offset(0.05, 0), return FadeTransition(
end: Offset.zero, opacity: animation,
).animate(animation), child: SlideTransition(
child: child, 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) // Navigation buttons (show when navigation is possible)
if (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ...[ if (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ...[
SizedBox(height: isNarrow ? 18.h : 24.h), SizedBox(height: spacingAfterAnswer),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -342,10 +391,18 @@ class _GamePageState extends State<GamePage> {
SizedBox(width: isNarrow ? 12.w : 16.w), SizedBox(width: isNarrow ? 12.w : 16.w),
], ],
if (_isLastQuestion(state)) ...[ if (_isLastQuestion(state)) ...[
ElevatedButton.icon( Builder(
onPressed: _finishGame, builder: (context) {
icon: const Icon(Icons.check), log('Finish button is being rendered', name: 'GamePage');
label: const Text('Finish'), 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)) ...[ ] else if (_canGoNext(state)) ...[
ElevatedButton.icon( ElevatedButton.icon(
@ -546,11 +603,18 @@ class _GamePageState extends State<GamePage> {
Future<void> _finishGame() async { Future<void> _finishGame() async {
log('Finish button pressed', name: 'GamePage'); log('Finish button pressed', name: 'GamePage');
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false); final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
log('AppScope: ${appScope != null ? 'available' : 'null'}', name: 'GamePage');
final userScope = appScope?.userScopeHolder.scope; final userScope = appScope?.userScopeHolder.scope;
log('UserScope: ${userScope != null ? 'available' : 'null'}', name: 'GamePage');
if (userScope != null) { if (userScope != null) {
log('Completing game session...', name: 'GamePage'); log('Calling completeGameSession...', name: 'GamePage');
await userScope.testsModule.testsStateManager.completeGameSession(); try {
log('Game session completion called', name: 'GamePage'); 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 { } else {
log('No user scope available when finishing game', name: 'GamePage'); log('No user scope available when finishing game', name: 'GamePage');
} }
@ -635,20 +699,28 @@ class _GamePageState extends State<GamePage> {
bool _canGoNext(TestsState state) { bool _canGoNext(TestsState state) {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false); final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope; 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) { bool _canGoPrevious(TestsState state) {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false); final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope; 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) { bool _isLastQuestion(TestsState state) {
return state.maybeWhen( final result = state.maybeWhen(
gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) => gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) {
currentQuestionIndex >= questions.length - 1, final isLast = currentQuestionIndex >= questions.length - 1;
log('Is last question: $isLast (index: $currentQuestionIndex, total: ${questions.length})', name: 'GamePage');
return isLast;
},
orElse: () => false, orElse: () => false,
); );
return result;
} }
} }

View file

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

View file

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

View file

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

View file

@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart';
class MatchWidget extends StatefulWidget { class MatchWidget extends StatefulWidget {
const MatchWidget({ const MatchWidget({
required this.question, required this.question,
this.maxHeight,
super.key, super.key,
}); });
final MatchQuestion question; final MatchQuestion question;
final double? maxHeight;
@override @override
State<MatchWidget> createState() => _MatchWidgetState(); State<MatchWidget> createState() => _MatchWidgetState();
@ -25,118 +27,146 @@ class _MatchWidgetState extends State<MatchWidget> {
@override @override
Widget build(BuildContext context) { 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600; final isWideScreen = constraints.maxWidth > 600;
return Column( return ConstrainedBox(
children: [ constraints: widget.maxHeight != null
// Instructions ? BoxConstraints(maxHeight: widget.maxHeight!)
Container( : const BoxConstraints(),
padding: EdgeInsets.all(16.w), child: Column(
margin: EdgeInsets.only(bottom: 24.h), mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
color: Theme.of(context).colorScheme.surface, // Instructions
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) ...[
Container( Container(
padding: EdgeInsets.all(16.w), padding: EdgeInsets.all(instructionPadding),
margin: EdgeInsets.only(bottom: 16.h), margin: EdgeInsets.only(bottom: instructionMargin),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.r), borderRadius: BorderRadius.circular(12.r),
), boxShadow: [
child: Column( BoxShadow(
crossAxisAlignment: CrossAxisAlignment.start, color: Colors.black.withOpacity(0.1),
children: [ blurRadius: 8,
Text( offset: const Offset(0, 2),
'Connections:',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
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}) { 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( return ListView.builder(
itemCount: items.length, itemCount: items.length,
itemExtent: isCompact ? 52.0 : 64.0, // Fixed item height for better performance
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
final isSelected = isLeft final isSelected = isLeft
@ -147,7 +177,7 @@ class _MatchWidgetState extends State<MatchWidget> {
: _connections.containsValue(item.id); : _connections.containsValue(item.id);
return Container( return Container(
margin: EdgeInsets.only(bottom: 8.h), margin: EdgeInsets.only(bottom: itemMargin),
child: Material( child: Material(
color: isConnected color: isConnected
? Theme.of(context).colorScheme.primary.withOpacity(0.1) ? Theme.of(context).colorScheme.primary.withOpacity(0.1)
@ -159,7 +189,7 @@ class _MatchWidgetState extends State<MatchWidget> {
onTap: isConnected ? null : () => _onItemTap(item.id, isLeft), onTap: isConnected ? null : () => _onItemTap(item.id, isLeft),
borderRadius: BorderRadius.circular(12.r), borderRadius: BorderRadius.circular(12.r),
child: Container( child: Container(
padding: EdgeInsets.all(12.w), padding: EdgeInsets.all(itemPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: isConnected color: isConnected
@ -175,8 +205,8 @@ class _MatchWidgetState extends State<MatchWidget> {
children: [ children: [
if (item.image != null) ...[ if (item.image != null) ...[
Container( Container(
width: 40.w, width: imageSize,
height: 40.h, height: imageSize,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r), borderRadius: BorderRadius.circular(8.r),
image: DecorationImage( image: DecorationImage(
@ -185,13 +215,13 @@ class _MatchWidgetState extends State<MatchWidget> {
), ),
), ),
), ),
SizedBox(width: 12.w), SizedBox(width: isCompact ? 8.w : 12.w),
], ],
Expanded( Expanded(
child: Text( child: Text(
item.text, item.text,
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: fontSize,
fontWeight: isConnected ? FontWeight.w600 : FontWeight.normal, fontWeight: isConnected ? FontWeight.w600 : FontWeight.normal,
color: isConnected color: isConnected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
@ -204,7 +234,7 @@ class _MatchWidgetState extends State<MatchWidget> {
Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
size: 20.sp, size: iconSize,
), ),
], ],
], ],

View file

@ -1,5 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.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) /// Shows a N x N matrix of images, and the current target word (original)
/// under the matrix. User clicks an image to answer: /// 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 /// - wrong: card shakes + wrong sound; test continues
class MatrixWidget extends StatefulWidget { class MatrixWidget extends StatefulWidget {
const MatrixWidget({ const MatrixWidget({
required this.question, required this.question,
this.onWrongAttempt, this.onWrongAttempt,
this.onCompleted, this.onCompleted,
this.maxHeight,
super.key, super.key,
}); });
final MatrixQuestion question; final MatrixQuestion question;
final Future<void> Function()? onWrongAttempt; final Future<void> Function()? onWrongAttempt;
final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted; final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted;
final double? maxHeight;
@override @override
State<MatrixWidget> createState() => _MatrixWidgetState(); State<MatrixWidget> createState() => _MatrixWidgetState();
@ -35,10 +39,8 @@ class _MatrixWidgetState extends State<MatrixWidget>
static const _flipDuration = Duration(milliseconds: 450); static const _flipDuration = Duration(milliseconds: 450);
static const _afterFlipHold = Duration(milliseconds: 650); static const _afterFlipHold = Duration(milliseconds: 650);
late math.Random _random;
late List<MatrixCard?> _slots; // stable positions; null == removed late List<MatrixCard?> _slots; // stable positions; null == removed
late String _targetCardId; var _currentStageIndex = 0;
late String _targetWord;
final _flipped = <String>{}; final _flipped = <String>{};
final _correctIdsInOrder = <String>[]; final _correctIdsInOrder = <String>[];
@ -48,6 +50,9 @@ class _MatrixWidgetState extends State<MatrixWidget>
String? _shakingCardId; String? _shakingCardId;
late final AnimationController _shakeController; late final AnimationController _shakeController;
late final Animation<double> _shakeOffset; late final Animation<double> _shakeOffset;
AudioPlayer? _audioPlayer;
bool _isPlayingAudio = false;
@override @override
void initState() { void initState() {
@ -76,23 +81,78 @@ class _MatrixWidgetState extends State<MatrixWidget>
} }
void _initFromQuestion(MatrixQuestion q) { void _initFromQuestion(MatrixQuestion q) {
_random = math.Random(q.id.hashCode ^ q.matrixSize);
_slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false); _slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false);
_flipped.clear(); _flipped.clear();
_correctIdsInOrder.clear(); _correctIdsInOrder.clear();
_wrongAttempts = 0; _wrongAttempts = 0;
_isCompleting = false; _isCompleting = false;
_shakingCardId = null; _shakingCardId = null;
_targetCardId = q.initialTargetCardId; _currentStageIndex = 0;
_targetWord = q.initialTargetWord;
// Play audio for first stage if available
if (q.stages.isNotEmpty) {
_playStageAudio(q.stages[0]);
}
} }
@override @override
void dispose() { void dispose() {
_shakeController.dispose(); _shakeController.dispose();
_audioPlayer?.dispose();
super.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -110,78 +170,199 @@ class _MatrixWidgetState extends State<MatrixWidget>
...List<MatrixCard?>.filled(total - _slots.length, null), ...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( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
GridView.builder( // Audio button if audio is available
shrinkWrap: true, if (currentStage.targetAudio != null && currentStage.targetAudio!.isNotEmpty)
physics: const NeverScrollableScrollPhysics(), Padding(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( padding: EdgeInsets.only(bottom: 12.h),
crossAxisCount: size, child: IconButton(
crossAxisSpacing: 10.w, icon: _isPlayingAudio
mainAxisSpacing: 10.h, ? SizedBox(
childAspectRatio: 1.0, 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, // Target word
itemBuilder: (context, index) { SelectableText(
final card = slots[index]; currentStage.targetWord,
return _buildSlot(context, card); style: textTheme.titleLarge?.copyWith(
}, fontWeight: FontWeight.w700,
),
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,
),
],
), ),
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) { Widget _buildSlot(BuildContext context, MatrixCard? card) {
final colorScheme = Theme.of(context).colorScheme; if (card == null) {
final isFlipped = card != null && _flipped.contains(card.id); return AspectRatio(
final isShaking = card != null && _shakingCardId == card.id; 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( final base = AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: card == null child: _MatrixFlipCard(
? DecoratedBox( card: card,
decoration: BoxDecoration( isFlipped: isFlipped,
borderRadius: BorderRadius.circular(14.r), onTap: _isCompleting || isFlipped ? null : () => _onCardTap(card),
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),
),
); );
if (!isShaking) return base; if (!isShaking) return base;
@ -200,9 +381,12 @@ class _MatrixWidgetState extends State<MatrixWidget>
Future<void> _onCardTap(MatrixCard card) async { Future<void> _onCardTap(MatrixCard card) async {
if (_isCompleting) return; 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); await _handleCorrect(card);
} else { } else {
await _handleWrong(card); await _handleWrong(card);
@ -234,25 +418,27 @@ class _MatrixWidgetState extends State<MatrixWidget>
await Future<void>.delayed(_flipDuration + _afterFlipHold); await Future<void>.delayed(_flipDuration + _afterFlipHold);
// Remove from matrix (keep slot stable -> set to null) // Check if all cards are flipped or all stages completed
final idx = _slots.indexWhere((c) => c?.id == card.id); final allFlipped = _slots
if (idx >= 0) { .whereType<MatrixCard>()
setState(() { .every((c) => _flipped.contains(c.id));
_slots[idx] = null; final allStagesCompleted = _currentStageIndex >= widget.question.stages.length - 1;
});
} if (allFlipped || allStagesCompleted) {
final remaining = _slots.whereType<MatrixCard>().toList(growable: false);
if (remaining.isEmpty) {
await _completeQuestion(); await _completeQuestion();
return; return;
} }
final next = remaining[_random.nextInt(remaining.length)]; // Move to next stage
setState(() { setState(() {
_targetCardId = next.id; _currentStageIndex++;
_targetWord = next.original;
}); });
// Play audio for next stage if available
final nextStage = _getCurrentStage();
if (nextStage != null) {
_playStageAudio(nextStage);
}
} }
Future<void> _completeQuestion() async { Future<void> _completeQuestion() async {
@ -291,11 +477,10 @@ class _MatrixFlipCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(14.r), borderRadius: BorderRadius.circular(24.r),
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
tween: Tween<double>( tween: Tween<double>(
begin: 0, begin: 0,
@ -312,46 +497,66 @@ class _MatrixFlipCard extends StatelessWidget {
return Transform( return Transform(
transform: transform, transform: transform,
alignment: Alignment.center, alignment: Alignment.center,
child: ClipRRect( child: Container(
borderRadius: BorderRadius.circular(14.r), decoration: BoxDecoration(
child: DecoratedBox( color: colorScheme.surface,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(24.r),
color: colorScheme.surface, border: Border.all(
border: Border.all(color: colorScheme.outlineVariant), 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 child: isBack
? Transform( ? Transform(
alignment: Alignment.center, alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi), transform: Matrix4.identity()..rotateY(math.pi),
child: Center( child: Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(10.w), padding: EdgeInsets.all(24.w),
child: Text( child: Column(
card.translation, mainAxisAlignment: MainAxisAlignment.center,
style: textTheme.titleMedium?.copyWith( children: [
fontWeight: FontWeight.w700, if (card.original != null)
), Text(
textAlign: TextAlign.center, card.original!,
maxLines: 3, style: TextStyle(
overflow: TextOverflow.ellipsis, 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( : _buildCardFront(card, colorScheme),
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,
),
);
},
),
), ),
), ),
); );
@ -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/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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 /// Widget for displaying game progress
class GameProgressIndicator extends StatelessWidget { class GameProgressIndicator extends StatelessWidget {
const GameProgressIndicator({ const GameProgressIndicator({
@ -8,6 +18,7 @@ class GameProgressIndicator extends StatelessWidget {
required this.totalQuestions, required this.totalQuestions,
required this.correctAnswers, required this.correctAnswers,
required this.timeElapsed, required this.timeElapsed,
this.mode = ProgressIndicatorMode.full,
super.key, super.key,
}); });
@ -15,14 +26,25 @@ class GameProgressIndicator extends StatelessWidget {
final int totalQuestions; final int totalQuestions;
final int correctAnswers; final int correctAnswers;
final Duration timeElapsed; final Duration timeElapsed;
final ProgressIndicatorMode mode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progress = currentQuestion / totalQuestions; final progress = currentQuestion / totalQuestions;
final accuracy = currentQuestion > 0 ? correctAnswers / currentQuestion : 0.0; 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( return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16.r), borderRadius: BorderRadius.circular(16.r),
@ -37,7 +59,7 @@ class GameProgressIndicator extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Progress bar // Progress row with question number
Row( Row(
children: [ children: [
Text( Text(
@ -45,30 +67,34 @@ class GameProgressIndicator extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null,
), ),
), ),
Text( Text(
' / $totalQuestions', ' / $totalQuestions',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null,
), ),
), ),
const Spacer(), if (showPercentage) ...[
Text( const Spacer(),
'${(progress * 100).round()}%', Text(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( '${(progress * 100).round()}%',
fontWeight: FontWeight.w500, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500,
), color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
),
],
], ],
), ),
SizedBox(height: 4.h), SizedBox(height: mode == ProgressIndicatorMode.mini ? 4.h : 4.h),
// Progress bar // Progress bar
Container( Container(
height: 6.h, height: progressBarHeight,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(3.r), borderRadius: BorderRadius.circular(3.r),
@ -90,35 +116,36 @@ class GameProgressIndicator extends StatelessWidget {
), ),
), ),
SizedBox(height: 8.h), // Stats row (only in full mode)
if (showStats) ...[
// Stats row SizedBox(height: 8.h),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildStatItem( _buildStatItem(
context: context, context: context,
icon: Icons.check_circle, icon: Icons.check_circle,
value: '$correctAnswers', value: '$correctAnswers',
label: 'Correct', label: 'Correct',
color: Colors.green, color: Colors.green,
), ),
_buildStatItem( _buildStatItem(
context: context, context: context,
icon: Icons.schedule, icon: Icons.schedule,
value: _formatDuration(timeElapsed), value: _formatDuration(timeElapsed),
label: 'Time', label: 'Time',
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
_buildStatItem( _buildStatItem(
context: context, context: context,
icon: Icons.trending_up, icon: Icons.trending_up,
value: '${(accuracy * 100).round()}%', value: '${(accuracy * 100).round()}%',
label: 'Accuracy', label: 'Accuracy',
color: accuracy >= 0.8 ? Colors.green : accuracy >= 0.6 ? Colors.orange : Colors.red, 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({ const QuestionDisplay({
required this.question, required this.question,
this.onPlayAudio, this.onPlayAudio,
this.maxHeight,
super.key, super.key,
}); });
final GameQuestion question; final GameQuestion question;
final QuestionAudioPlayback? onPlayAudio; final QuestionAudioPlayback? onPlayAudio;
final double? maxHeight;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -69,62 +71,93 @@ class QuestionDisplay extends StatelessWidget {
String? audio, String? audio,
}) { }) {
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
// 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;
}
// 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;
// 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 Column( return ConstrainedBox(
mainAxisAlignment: MainAxisAlignment.center, constraints: maxHeight != null
children: [ ? BoxConstraints(maxHeight: maxHeight!)
// Image display : const BoxConstraints(),
if (image != null) ...[ child: Column(
Container( mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints( mainAxisSize: MainAxisSize.min,
maxHeight: 200.h, children: [
maxWidth: double.infinity, // 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,
),
);
},
),
),
), ),
child: Image.network( SizedBox(height: spacingAfterImage),
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),
],
// Text display // Text display
if (text.isNotEmpty) ...[ if (text.isNotEmpty) ...[
Text( Flexible(
text, child: Text(
style: theme.headlineSmall?.copyWith( text,
fontSize: 20.sp, style: theme.headlineSmall?.copyWith(
height: 1.4, fontSize: fontSize,
fontWeight: FontWeight.w600, height: 1.4,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: maxLines,
overflow: TextOverflow.fade,
),
), ),
textAlign: TextAlign.center, ],
),
],
// Audio button // Audio button
if (audio != null) ...[ if (audio != null) ...[
SizedBox(height: 12.h), SizedBox(height: spacingAfterText),
_QuestionAudioButton( _QuestionAudioButton(
audioUrl: audio, audioUrl: audio,
onPlayAudio: onPlayAudio, onPlayAudio: onPlayAudio,
), ),
],
], ],
], ),
); );
} }
} }