diff --git a/PROGRESS.md b/PROGRESS.md index 5e3bedb..6f73fa7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -128,6 +128,12 @@ - Backend generator picks cards from pack pool and stores `matrixSize` in `uiData` - Web UI: image grid + target `original` word; wrong = shake + error sound; correct = flip to translation then disappear - Admin: question type selector + matrix size form; JSON (de)serialization + vitest coverage + - **Matrix Question Types**: Added support for different matrix question types + - Created `MatrixQuestionType` enum with 5 types: original_translation, original_images, translation_originals, audio_images, audio_translation + - Backend generator now creates stages array with all target words/audio upfront + - MatrixCardDto adaptively fills only needed fields based on question type (text-only, image-only, or both) + - Web UI supports stages navigation, audio playback with repeat button, and adaptive card display + - Added MatrixStageDto for backend and MatrixStage domain model for client - **CardViewer Carousel Looping**: Implemented infinite carousel scrolling for card viewer - Cards now loop seamlessly: last card → first card and first card → last card - Works for both swipe gestures and navigation buttons @@ -170,6 +176,11 @@ - Added DAO helpers and a focused unit test for the cleanup logic - Added regression test to ensure `updateGeneratedTests()` creates a pack link - **Matrix Test (image selection)**: Added matrix question generator + API storage support (cards auto-generated if missing) + - **Matrix Question Types**: Enhanced matrix question generator with multiple question types + - Added MatrixQuestionType enum with extension methods for type checking + - Generator creates stages array with all target words/audio for multi-step questions + - Supports original_translation, original_images, translation_originals, audio_images, audio_translation types + - MatrixCardDto adaptively fills fields based on question type (only needed content) - **MinIO Migration**: Migrated file storage from local filesystem to MinIO object storage - Created MinioService for MinIO operations (upload, presigned URLs, delete, fileExists) - Created MediaApiV2 with endpoints for file uploads (card-image, test-image, voice) and presigned URL generation @@ -267,4 +278,4 @@ --- -*Last updated: December 19, 2025* +*Last updated: December 20, 2025* diff --git a/TODO.md b/TODO.md index 6456be8..cef961f 100644 --- a/TODO.md +++ b/TODO.md @@ -176,6 +176,12 @@ - Auto-generate matrix images from pack card pool (generator framework) - Web: multi-stage single question (shake on wrong, flip+reveal translation on correct, then disappear) - Admin: matrix size configuration + JSON (de)serialization + tests +- [x] **Matrix Question Types**: Added support for different matrix question types + - Created MatrixQuestionType enum with 5 types (original_translation, original_images, translation_originals, audio_images, audio_translation) + - Backend generates stages array with all target words/audio upfront + - MatrixCardDto adaptively fills only needed fields based on question type + - Web UI supports stages navigation, audio playback, and adaptive card display + - Need to add unit tests for generator and all question types - [x] **CardViewer Carousel Looping**: Implement infinite carousel scrolling for card viewer - Cards loop seamlessly: last card → first card and first card → last card - Works for both swipe gestures and navigation buttons @@ -239,4 +245,4 @@ --- -*Last updated: December 19, 2025* +*Last updated: December 20, 2025* diff --git a/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart b/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart index ea5b1c4..9721faf 100644 --- a/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart +++ b/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart @@ -37,8 +37,24 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { return '/api/v2/packs/${data.packId}/cards/$imageId/image'; } - InputButtonsQuestionType _getRandomQuestionType() { - final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values); + InputButtonsQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) { + var types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values); + + // Exclude audio types if audio is not available + if (excludeAudioTypes) { + types = types.where((type) => !type.audioQuestion).toList(); + } + + // If no types available, fallback to non-audio types + if (types.isEmpty) { + types = InputButtonsQuestionType.values.where((type) => !type.audioQuestion).toList(); + } + + // If still empty, use translation_original as fallback + if (types.isEmpty) { + return InputButtonsQuestionType.translation_original; + } + return types[random.nextInt(types.length)]; } @@ -66,35 +82,44 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { .take(4) .toList(); + // Check if audio is available before selecting question type + final hasAudio = _getAudioUuid(answerItem) != null; + // mb use custom ids - final questionType = type ?? _getRandomQuestionType(); + // If no type specified, select random type excluding audio types if audio is not available + final questionType = type ?? _getRandomQuestionType(excludeAudioTypes: !hasAudio); + + // If audio type was selected but audio is not available, select a different type + final finalQuestionType = (questionType.audioQuestion && !hasAudio) + ? _getRandomQuestionType(excludeAudioTypes: true) + : questionType; final questionText = - questionType.translationQuestion ? answerItem.translation : null; + finalQuestionType.translationQuestion ? answerItem.translation : null; final questionImage = - questionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null; + finalQuestionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null; String? questionAudio = null; - if (questionType.audioQuestion) { + if (finalQuestionType.audioQuestion) { // Use UUID from MinIO if available, otherwise null questionAudio = _getAudioUuid(answerItem); } else if (withAudio) { // nothing to fo here } - final answer = questionType.translationAnswer + final answer = finalQuestionType.translationAnswer ? answerItem.translation : answerItem.original; String template = answer.asTemplate; if (showArticle && answer.contains(' ') && - !questionType.translationAnswer) { + !finalQuestionType.translationAnswer) { final article = answer.split(' ').firstOrNull; if (article != null) { template = '$article ${answer.substring(article.length + 1).asTemplate}'; } } - if (visibleButtonsPercent > 0 && !questionType.translationAnswer) { + if (visibleButtonsPercent > 0 && !finalQuestionType.translationAnswer) { var matches = '_'.allMatches(template).toList(); int visibleLetters = (visibleButtonsPercent * matches.length).floor(); while (visibleLetters-- > 0) { @@ -115,7 +140,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { word: answerItem.original, audio: questionAudio, template: template, - buttons: (questionType.translationAnswer + buttons: (finalQuestionType.translationAnswer ? [ ...answerItem.translation.split(''), ...otherCards.expand((c) => c.translation.split('').toSet()) @@ -124,7 +149,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { ...answerItem.original.split(''), ...otherCards.expand((c) => c.original.split('').toSet()), ]) - .map((e) => e.trim()) + .map((e) => e.trim().toLowerCase()) .where((ch) => ch.isNotEmpty) .take(20) .mapIndexed((index, ch) => TestButtonDto('${ch}_$index', null, ch)) diff --git a/mnemo_cards_backend/lib/tests/generators/question_generators/matrix_question_generator.dart b/mnemo_cards_backend/lib/tests/generators/question_generators/matrix_question_generator.dart index 324cca4..a604dbf 100644 --- a/mnemo_cards_backend/lib/tests/generators/question_generators/matrix_question_generator.dart +++ b/mnemo_cards_backend/lib/tests/generators/question_generators/matrix_question_generator.dart @@ -5,20 +5,59 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import '../models/creation_test_data.dart'; import 'question_generator.dart'; -/// Generates a matrix-image question: -/// - shows N x N images (cards from the pack pool) -/// - shows a target word (original) under the matrix +enum MatrixQuestionType { + original_translation, // оригинал снизу, переведенные карточки + original_images, // оригинал снизу, изображения на карточках + translation_originals, // перевод снизу, карточки с оригиналом + audio_images, // аудио снизу, карточки с изображениями + audio_translation, // аудио снизу, карточки с переводами +} + +extension MatrixQuestionTypeExtension on MatrixQuestionType { + bool get originalTarget => [ + MatrixQuestionType.original_translation, + MatrixQuestionType.original_images, + ].contains(this); + + bool get translationTarget => [ + MatrixQuestionType.translation_originals, + ].contains(this); + + bool get audioTarget => [ + MatrixQuestionType.audio_images, + MatrixQuestionType.audio_translation, + ].contains(this); + + bool get translationCards => [ + MatrixQuestionType.original_translation, + MatrixQuestionType.audio_translation, + ].contains(this); + + bool get originalCards => [ + MatrixQuestionType.translation_originals, + ].contains(this); + + bool get imageCards => [ + MatrixQuestionType.original_images, + MatrixQuestionType.audio_images, + ].contains(this); +} + +/// Generates a matrix question: +/// - shows N x N cards (from the pack pool) +/// - shows a target word/audio under the matrix /// - user must click the matching card /// /// The question is multi-step on the client side: after a correct click, -/// the card disappears and a new target word is selected from remaining cards. -/// Backend only provides the initial target via [word] + [answer]. +/// the card flips and a new target is shown from stages array. +/// Backend generates all stages upfront. class MatrixQuestionGenerator implements QuestionGenerator { MatrixQuestionGenerator( this.data, { this.seed, this.allowedSizes = const [2, 3, 4], this.fixedMatrixSize, + this.possibleTypes, }) : random = Random(seed); final CreationTestData data; @@ -32,11 +71,55 @@ class MatrixQuestionGenerator implements QuestionGenerator { /// items exist). final int? fixedMatrixSize; + /// Allowed question types. If null, all types are allowed. + final Set? possibleTypes; + + // UUID regex pattern (same as in simple_question_generator.dart) + static final _uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + String? _imageIdToUrl(String? imageId) { if (imageId == null || data.packId == null) return imageId; return '/api/v2/packs/${data.packId}/cards/$imageId/image'; } + /// Checks if a string is a valid UUID + bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim()); + + /// Gets audio UUID from answerCard.audio if it's a valid UUID, otherwise returns null + String? _getAudioUuid(TestDataItem answerCard) { + final audioValue = answerCard.audio; + if (audioValue == null || audioValue.trim().isEmpty) return null; + return _isUuid(audioValue) ? audioValue.trim() : null; + } + + MatrixQuestionType _getRandomQuestionType({ + required List selectedCards, + }) { + var types = (possibleTypes?.toList() ?? MatrixQuestionType.values); + + // Check if we have audio available + final hasAudio = selectedCards.any((card) => _getAudioUuid(card) != null); + if (!hasAudio) { + types = types.where((type) => !type.audioTarget).toList(); + } + + // Check if we have images available + final hasImages = selectedCards.any((card) => card.image != null); + if (!hasImages) { + types = types.where((type) => !type.imageCards).toList(); + } + + // If no types available, fallback to original_translation + if (types.isEmpty) { + return MatrixQuestionType.original_translation; + } + + return types[random.nextInt(types.length)]; + } + int _maxPossibleSize(int poolSize) { if (poolSize <= 0) return 0; return sqrt(poolSize).floor(); @@ -57,6 +140,40 @@ class MatrixQuestionGenerator implements QuestionGenerator { return allowed[random.nextInt(allowed.length)]; } + List _generateStages( + List selectedCards, + MatrixQuestionType questionType, + ) { + final stages = []; + final shuffled = List.from(selectedCards)..shuffle(random); + + for (final card in shuffled) { + String targetWord; + String? targetAudio; + + if (questionType.originalTarget) { + targetWord = card.original; + } else if (questionType.translationTarget) { + targetWord = card.translation; + } else if (questionType.audioTarget) { + targetWord = card.original; // fallback text + targetAudio = _getAudioUuid(card); + } else { + targetWord = card.original; // fallback + } + + stages.add( + MatrixStageDto( + targetCardId: card.id, + targetWord: targetWord, + targetAudio: targetAudio, + ), + ); + } + + return stages; + } + @override Future generate(TestDataItem answerCard) async { final pool = data.items; @@ -73,23 +190,58 @@ class MatrixQuestionGenerator implements QuestionGenerator { final selected = [answerCard, ...others]..shuffle(random); - final cards = selected - .map( - (c) => MatrixCardDto( - id: c.id, - image: _imageIdToUrl(c.image) ?? c.image ?? c.id, - original: c.original, - translation: c.translation, - ), - ) - .toList(); + // Determine question type based on available data + final questionType = _getRandomQuestionType(selectedCards: selected); + + // Generate stages for all cards + final stages = _generateStages(selected, questionType); + + // Create cards based on question type + final cards = selected.map((c) { + if (questionType.translationCards) { + return MatrixCardDto( + id: c.id, + translation: c.translation, + // Don't include image or original for translation cards + ); + } else if (questionType.originalCards) { + return MatrixCardDto( + id: c.id, + original: c.original, + // Don't include image or translation for original cards + ); + } else if (questionType.imageCards) { + return MatrixCardDto( + id: c.id, + image: _imageIdToUrl(c.image) ?? c.image ?? c.id, + // Don't include text for image cards + ); + } else { + // Fallback: include all fields + return MatrixCardDto( + id: c.id, + image: _imageIdToUrl(c.image) ?? c.image ?? c.id, + original: c.original, + translation: c.translation, + ); + } + }).toList(); + + // Get first stage for backward compatibility + final firstStage = stages.isNotEmpty ? stages.first : MatrixStageDto( + targetCardId: answerCard.id, + targetWord: answerCard.original, + targetAudio: null, + ); return MatrixTestQuestionBody( matrixSize: matrixSize, cards: cards, - // First step: target is the provided answerCard. - word: answerCard.original, - answer: answerCard.id, + stages: stages, + // Backward compatibility fields + word: firstStage.targetWord, + answer: firstStage.targetCardId, + text: 'Найди слово:', ); } } diff --git a/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart b/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart index 2bc96c8..e34bf2c 100644 --- a/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart +++ b/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart @@ -37,8 +37,24 @@ class SimpleQuestionGenerator implements QuestionGenerator { return '/api/v2/packs/${data.packId}/cards/$imageId/image'; } - SimpleQuestionType _getRandomQuestionType() { - final types = (possibleTypes?.toList() ?? SimpleQuestionType.values); + SimpleQuestionType _getRandomQuestionType({bool excludeAudioTypes = false}) { + var types = (possibleTypes?.toList() ?? SimpleQuestionType.values); + + // Exclude audio types if audio is not available + if (excludeAudioTypes) { + types = types.where((type) => !type.audioQuestion).toList(); + } + + // If no types available, fallback to non-audio types + if (types.isEmpty) { + types = SimpleQuestionType.values.where((type) => !type.audioQuestion).toList(); + } + + // If still empty, use original_translation as fallback + if (types.isEmpty) { + return SimpleQuestionType.original_translation; + } + return types[random.nextInt(types.length)]; } @@ -62,28 +78,38 @@ class SimpleQuestionGenerator implements QuestionGenerator { (data.items.where((element) => element.id != answerCard.id).toList() ..shuffle()) .take(3); + + // Check if audio is available before selecting question type + final hasAudio = _getAudioUuid(answerCard) != null; + // mb use custom ids - final questionType = type ?? _getRandomQuestionType(); - final questionText = questionType.originalQuestion + // If no type specified, select random type excluding audio types if audio is not available + final questionType = type ?? _getRandomQuestionType(excludeAudioTypes: !hasAudio); + + // If audio type was selected but audio is not available, select a different type + final finalQuestionType = (questionType.audioQuestion && !hasAudio) + ? _getRandomQuestionType(excludeAudioTypes: true) + : questionType; + final questionText = finalQuestionType.originalQuestion ? answerCard.original - : questionType.translationQuestion + : finalQuestionType.translationQuestion ? answerCard.translation : null; final questionImage = - questionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null; + finalQuestionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null; String? questionAudio = null; - if (questionType.audioQuestion) { - if (questionType.translationAnswers || questionType.imagesAnswers) { + if (finalQuestionType.audioQuestion) { + if (finalQuestionType.translationAnswers || finalQuestionType.imagesAnswers) { // Use UUID from MinIO if available, otherwise null questionAudio = _getAudioUuid(answerCard); } } else if (withAudio) { - if (questionText != null && questionType.originalQuestion) { + if (questionText != null && finalQuestionType.originalQuestion) { // For original questions with text, audio is handled separately // Use UUID if available, otherwise null questionAudio = _getAudioUuid(answerCard); - } else if (questionType.translationAnswers || - questionType.imagesAnswers) { + } else if (finalQuestionType.translationAnswers || + finalQuestionType.imagesAnswers) { // Use UUID from MinIO if available, otherwise null questionAudio = _getAudioUuid(answerCard); } @@ -97,15 +123,15 @@ class SimpleQuestionGenerator implements QuestionGenerator { audio: questionAudio, word: answerCard.original, buttons: [ - if (questionType.translationAnswers) + if (finalQuestionType.translationAnswers) ...answers.map( (e) => TestButtonDto.text(e.id, e.translation), ) - else if (questionType.originalAnswers) + else if (finalQuestionType.originalAnswers) ...answers.map( (e) => TestButtonDto.text(e.id, e.original), ) - else if (questionType.imagesAnswers) + else if (finalQuestionType.imagesAnswers) ...answers.map( (e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)), ) diff --git a/mnemo_cards_backend/test/tests/generators/question_generators/matrix_question_generator_test.dart b/mnemo_cards_backend/test/tests/generators/question_generators/matrix_question_generator_test.dart index b55cded..49b0edd 100644 --- a/mnemo_cards_backend/test/tests/generators/question_generators/matrix_question_generator_test.dart +++ b/mnemo_cards_backend/test/tests/generators/question_generators/matrix_question_generator_test.dart @@ -43,6 +43,7 @@ void main() { expect(mq.answer, answerCard.id); expect(mq.word, answerCard.original); + expect(mq.text, 'Найди слово:'); expect(ids.contains(answerCard.id), isTrue); }); diff --git a/mnemo_cards_backend/test/tests/generators/question_generators/simple_question_generator_test.dart b/mnemo_cards_backend/test/tests/generators/question_generators/simple_question_generator_test.dart new file mode 100644 index 0000000..619aaf6 --- /dev/null +++ b/mnemo_cards_backend/test/tests/generators/question_generators/simple_question_generator_test.dart @@ -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()); + 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()); + 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()); + 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()); + 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 + }); + }); +} \ No newline at end of file diff --git a/mnemo_cards_backend/test/tests/test_manager_image_conversion_test.dart b/mnemo_cards_backend/test/tests/test_manager_image_conversion_test.dart new file mode 100644 index 0000000..2090223 --- /dev/null +++ b/mnemo_cards_backend/test/tests/test_manager_image_conversion_test.dart @@ -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')); + }); + }); +} diff --git a/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.dart b/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.dart index caf6322..6ed94d0 100644 --- a/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.dart +++ b/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.dart @@ -20,18 +20,29 @@ class MatrixTestQuestionBody extends AbstractTestQuestion { @JsonKey(name: 'buttons', defaultValue: []) final List cards; + /// Stages for multi-step question. Each stage defines the target word/audio + /// and the correct card ID. + @JsonKey(defaultValue: []) + final List stages; + /// Initial correct answer (card id) for the first step. /// - /// Next steps are handled on the client side. + /// DEPRECATED: Use stages[0].targetCardId instead. + /// Kept for backward compatibility. @JsonKey(defaultValue: '') final String answer; + /// Question text (e.g., "Найди слово:") + final String? text; + MatrixTestQuestionBody({ super.id, required this.matrixSize, required this.cards, + this.stages = const [], required this.answer, required super.word, + this.text, super.questionType = TestQuestionType.matrix, }); @@ -42,21 +53,40 @@ class MatrixTestQuestionBody extends AbstractTestQuestion { Map 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 json) => + _$MatrixStageDtoFromJson(json); + + Map toJson() => _$MatrixStageDtoToJson(this); +} + @JsonSerializable() @CopyWith() class MatrixCardDto { final String id; - final String image; // Object ID in MinIO (for admin) + final String? image; // Object ID in MinIO (for admin) final String? imageUrl; // Presigned URL (for display) - final String original; - final String translation; + final String? original; + final String? translation; const MatrixCardDto({ required this.id, - required this.image, + this.image, this.imageUrl, - required this.original, - required this.translation, + this.original, + this.translation, }); factory MatrixCardDto.fromJson(Map json) => diff --git a/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.g.dart b/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.g.dart index 9f007d1..56f104b 100644 --- a/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.g.dart +++ b/mnemo_cards_common/lib/src/dtos/game_tests/test_bodies/matrix_test_question_body.g.dart @@ -13,10 +13,14 @@ abstract class _$MatrixTestQuestionBodyCWProxy { MatrixTestQuestionBody cards(List cards); + MatrixTestQuestionBody stages(List stages); + MatrixTestQuestionBody answer(String answer); MatrixTestQuestionBody word(String word); + MatrixTestQuestionBody text(String? text); + MatrixTestQuestionBody questionType(TestQuestionType questionType); /// Creates a new instance with the provided field values. @@ -30,8 +34,10 @@ abstract class _$MatrixTestQuestionBodyCWProxy { String? id, int matrixSize, List cards, + List stages, String answer, String word, + String? text, TestQuestionType questionType, }); } @@ -54,12 +60,19 @@ class _$MatrixTestQuestionBodyCWProxyImpl @override MatrixTestQuestionBody cards(List cards) => call(cards: cards); + @override + MatrixTestQuestionBody stages(List stages) => + call(stages: stages); + @override MatrixTestQuestionBody answer(String answer) => call(answer: answer); @override MatrixTestQuestionBody word(String word) => call(word: word); + @override + MatrixTestQuestionBody text(String? text) => call(text: text); + @override MatrixTestQuestionBody questionType(TestQuestionType questionType) => call(questionType: questionType); @@ -76,8 +89,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl Object? id = const $CopyWithPlaceholder(), Object? matrixSize = const $CopyWithPlaceholder(), Object? cards = const $CopyWithPlaceholder(), + Object? stages = const $CopyWithPlaceholder(), Object? answer = const $CopyWithPlaceholder(), Object? word = const $CopyWithPlaceholder(), + Object? text = const $CopyWithPlaceholder(), Object? questionType = const $CopyWithPlaceholder(), }) { return MatrixTestQuestionBody( @@ -94,6 +109,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl ? _value.cards // ignore: cast_nullable_to_non_nullable : cards as List, + stages: stages == const $CopyWithPlaceholder() || stages == null + ? _value.stages + // ignore: cast_nullable_to_non_nullable + : stages as List, answer: answer == const $CopyWithPlaceholder() || answer == null ? _value.answer // ignore: cast_nullable_to_non_nullable @@ -102,6 +121,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl ? _value.word // ignore: cast_nullable_to_non_nullable : word as String, + text: text == const $CopyWithPlaceholder() + ? _value.text + // ignore: cast_nullable_to_non_nullable + : text as String?, questionType: questionType == const $CopyWithPlaceholder() || questionType == null ? _value.questionType @@ -119,16 +142,94 @@ extension $MatrixTestQuestionBodyCopyWith on MatrixTestQuestionBody { _$MatrixTestQuestionBodyCWProxyImpl(this); } +abstract class _$MatrixStageDtoCWProxy { + MatrixStageDto targetCardId(String targetCardId); + + MatrixStageDto targetWord(String targetWord); + + MatrixStageDto targetAudio(String? targetAudio); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// MatrixStageDto(...).copyWith(id: 12, name: "My name") + /// ``` + MatrixStageDto call({ + String targetCardId, + String targetWord, + String? targetAudio, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfMatrixStageDto.copyWith(...)` or call `instanceOfMatrixStageDto.copyWith.fieldName(value)` for a single field. +class _$MatrixStageDtoCWProxyImpl implements _$MatrixStageDtoCWProxy { + const _$MatrixStageDtoCWProxyImpl(this._value); + + final MatrixStageDto _value; + + @override + MatrixStageDto targetCardId(String targetCardId) => + call(targetCardId: targetCardId); + + @override + MatrixStageDto targetWord(String targetWord) => call(targetWord: targetWord); + + @override + MatrixStageDto targetAudio(String? targetAudio) => + call(targetAudio: targetAudio); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// MatrixStageDto(...).copyWith(id: 12, name: "My name") + /// ``` + MatrixStageDto call({ + Object? targetCardId = const $CopyWithPlaceholder(), + Object? targetWord = const $CopyWithPlaceholder(), + Object? targetAudio = const $CopyWithPlaceholder(), + }) { + return MatrixStageDto( + targetCardId: + targetCardId == const $CopyWithPlaceholder() || targetCardId == null + ? _value.targetCardId + // ignore: cast_nullable_to_non_nullable + : targetCardId as String, + targetWord: + targetWord == const $CopyWithPlaceholder() || targetWord == null + ? _value.targetWord + // ignore: cast_nullable_to_non_nullable + : targetWord as String, + targetAudio: targetAudio == const $CopyWithPlaceholder() + ? _value.targetAudio + // ignore: cast_nullable_to_non_nullable + : targetAudio as String?, + ); + } +} + +extension $MatrixStageDtoCopyWith on MatrixStageDto { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfMatrixStageDto.copyWith(...)` or `instanceOfMatrixStageDto.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$MatrixStageDtoCWProxy get copyWith => _$MatrixStageDtoCWProxyImpl(this); +} + abstract class _$MatrixCardDtoCWProxy { MatrixCardDto id(String id); - MatrixCardDto image(String image); + MatrixCardDto image(String? image); MatrixCardDto imageUrl(String? imageUrl); - MatrixCardDto original(String original); + MatrixCardDto original(String? original); - MatrixCardDto translation(String translation); + MatrixCardDto translation(String? translation); /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixCardDto(...).copyWith.fieldName(value)`. @@ -139,10 +240,10 @@ abstract class _$MatrixCardDtoCWProxy { /// ``` MatrixCardDto call({ String id, - String image, + String? image, String? imageUrl, - String original, - String translation, + String? original, + String? translation, }); } @@ -157,16 +258,16 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy { MatrixCardDto id(String id) => call(id: id); @override - MatrixCardDto image(String image) => call(image: image); + MatrixCardDto image(String? image) => call(image: image); @override MatrixCardDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl); @override - MatrixCardDto original(String original) => call(original: original); + MatrixCardDto original(String? original) => call(original: original); @override - MatrixCardDto translation(String translation) => + MatrixCardDto translation(String? translation) => call(translation: translation); @override @@ -189,23 +290,22 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy { ? _value.id // ignore: cast_nullable_to_non_nullable : id as String, - image: image == const $CopyWithPlaceholder() || image == null + image: image == const $CopyWithPlaceholder() ? _value.image // ignore: cast_nullable_to_non_nullable - : image as String, + : image as String?, imageUrl: imageUrl == const $CopyWithPlaceholder() ? _value.imageUrl // ignore: cast_nullable_to_non_nullable : imageUrl as String?, - original: original == const $CopyWithPlaceholder() || original == null + original: original == const $CopyWithPlaceholder() ? _value.original // ignore: cast_nullable_to_non_nullable - : original as String, - translation: - translation == const $CopyWithPlaceholder() || translation == null + : original as String?, + translation: translation == const $CopyWithPlaceholder() ? _value.translation // ignore: cast_nullable_to_non_nullable - : translation as String, + : translation as String?, ); } } @@ -231,8 +331,14 @@ MatrixTestQuestionBody _$MatrixTestQuestionBodyFromJson( ?.map((e) => MatrixCardDto.fromJson(e as Map)) .toList() ?? [], + stages: + (json['stages'] as List?) + ?.map((e) => MatrixStageDto.fromJson(e as Map)) + .toList() ?? + [], answer: json['answer'] as String? ?? '', word: json['word'] as String, + text: json['text'] as String?, questionType: $enumDecodeNullable(_$TestQuestionTypeEnumMap, json['questionType']) ?? TestQuestionType.matrix, @@ -246,7 +352,9 @@ Map _$MatrixTestQuestionBodyToJson( 'word': instance.word, 'matrixSize': instance.matrixSize, 'buttons': instance.cards.map((e) => e.toJson()).toList(), + 'stages': instance.stages.map((e) => e.toJson()).toList(), 'answer': instance.answer, + 'text': ?instance.text, }; const _$TestQuestionTypeEnumMap = { @@ -257,13 +365,27 @@ const _$TestQuestionTypeEnumMap = { TestQuestionType.undefined: 'undefined', }; +MatrixStageDto _$MatrixStageDtoFromJson(Map json) => + MatrixStageDto( + targetCardId: json['targetCardId'] as String, + targetWord: json['targetWord'] as String, + targetAudio: json['targetAudio'] as String?, + ); + +Map _$MatrixStageDtoToJson(MatrixStageDto instance) => + { + 'targetCardId': instance.targetCardId, + 'targetWord': instance.targetWord, + 'targetAudio': instance.targetAudio, + }; + MatrixCardDto _$MatrixCardDtoFromJson(Map json) => MatrixCardDto( id: json['id'] as String, - image: json['image'] as String, + image: json['image'] as String?, imageUrl: json['imageUrl'] as String?, - original: json['original'] as String, - translation: json['translation'] as String, + original: json['original'] as String?, + translation: json['translation'] as String?, ); Map _$MatrixCardDtoToJson(MatrixCardDto instance) => diff --git a/mnemo_cards_web_v2/lib/domain/models/game_question.dart b/mnemo_cards_web_v2/lib/domain/models/game_question.dart index 6206c37..3b7289c 100644 --- a/mnemo_cards_web_v2/lib/domain/models/game_question.dart +++ b/mnemo_cards_web_v2/lib/domain/models/game_question.dart @@ -115,21 +115,24 @@ abstract class MatchPair with _$MatchPair { _$MatchPairFromJson(json); } -/// Matrix question - user selects a card from an image matrix +/// Matrix question - user selects a card from a matrix /// -/// UI: a matrix of images (N x N) + current target word (original) under it. +/// UI: a matrix of cards (N x N) + current target word/audio under it. /// The question is multi-step on a single screen: after each correct selection -/// the chosen card flips (showing translation) and disappears, then a new -/// target word is shown until all cards are removed. +/// the chosen card flips and a new target is shown from stages array until +/// all cards are processed. @freezed abstract class MatrixQuestion with _$MatrixQuestion { const factory MatrixQuestion({ required String id, required int matrixSize, required List cards, - required String initialTargetCardId, - required String initialTargetWord, + @Default([]) List stages, + // DEPRECATED: Use stages[0] instead. Kept for backward compatibility. + @Deprecated('Use stages[0] instead') String? initialTargetCardId, + @Deprecated('Use stages[0] instead') String? initialTargetWord, required String word, + String? text, @Default('matrix') String type, }) = _MatrixQuestion; @@ -137,13 +140,25 @@ abstract class MatrixQuestion with _$MatrixQuestion { _$MatrixQuestionFromJson(json); } +@freezed +abstract class MatrixStage with _$MatrixStage { + const factory MatrixStage({ + required String targetCardId, + required String targetWord, + String? targetAudio, + }) = _MatrixStage; + + factory MatrixStage.fromJson(Map json) => + _$MatrixStageFromJson(json); +} + @freezed abstract class MatrixCard with _$MatrixCard { const factory MatrixCard({ required String id, - required String image, - required String original, - required String translation, + String? image, + String? original, + String? translation, }) = _MatrixCard; factory MatrixCard.fromJson(Map json) => diff --git a/mnemo_cards_web_v2/lib/domain/models/game_question.freezed.dart b/mnemo_cards_web_v2/lib/domain/models/game_question.freezed.dart index 21f842e..4e44e1a 100644 --- a/mnemo_cards_web_v2/lib/domain/models/game_question.freezed.dart +++ b/mnemo_cards_web_v2/lib/domain/models/game_question.freezed.dart @@ -2262,7 +2262,8 @@ as String, /// @nodoc mixin _$MatrixQuestion { - String get id; int get matrixSize; List get cards; String get initialTargetCardId; String get initialTargetWord; String get word; String get type; + String get id; int get matrixSize; List get cards; List get stages;// DEPRECATED: Use stages[0] instead. Kept for backward compatibility. +@Deprecated('Use stages[0] instead') String? get initialTargetCardId;@Deprecated('Use stages[0] instead') String? get initialTargetWord; String get word; String? get text; String get type; /// Create a copy of MatrixQuestion /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2275,16 +2276,16 @@ $MatrixQuestionCopyWith get copyWith => _$MatrixQuestionCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&const DeepCollectionEquality().equals(other.stages, stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),initialTargetCardId,initialTargetWord,word,type); +int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),const DeepCollectionEquality().hash(stages),initialTargetCardId,initialTargetWord,word,text,type); @override String toString() { - return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)'; + return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)'; } @@ -2295,7 +2296,7 @@ abstract mixin class $MatrixQuestionCopyWith<$Res> { factory $MatrixQuestionCopyWith(MatrixQuestion value, $Res Function(MatrixQuestion) _then) = _$MatrixQuestionCopyWithImpl; @useResult $Res call({ - String id, int matrixSize, List cards, String initialTargetCardId, String initialTargetWord, String word, String type + String id, int matrixSize, List cards, List stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type }); @@ -2312,15 +2313,17 @@ class _$MatrixQuestionCopyWithImpl<$Res> /// Create a copy of MatrixQuestion /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable as int,cards: null == cards ? _self.cards : cards // ignore: cast_nullable_to_non_nullable -as List,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable -as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable -as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable -as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as List,stages: null == stages ? _self.stages : stages // ignore: cast_nullable_to_non_nullable +as List,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable +as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable +as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable +as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as String, )); } @@ -2406,10 +2409,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, int matrixSize, List cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, int matrixSize, List cards, List stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _MatrixQuestion() when $default != null: -return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _: +return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _: return orElse(); } @@ -2427,10 +2430,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, int matrixSize, List cards, String initialTargetCardId, String initialTargetWord, String word, String type) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, int matrixSize, List cards, List stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type) $default,) {final _that = this; switch (_that) { case _MatrixQuestion(): -return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _: +return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _: throw StateError('Unexpected subclass'); } @@ -2447,10 +2450,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, int matrixSize, List cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, int matrixSize, List cards, List stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,) {final _that = this; switch (_that) { case _MatrixQuestion() when $default != null: -return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _: +return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _: return null; } @@ -2462,7 +2465,7 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId, @JsonSerializable() class _MatrixQuestion implements MatrixQuestion { - const _MatrixQuestion({required this.id, required this.matrixSize, required final List 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 cards, final List 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 json) => _$MatrixQuestionFromJson(json); @override final String id; @@ -2474,9 +2477,18 @@ class _MatrixQuestion implements MatrixQuestion { return EqualUnmodifiableListView(_cards); } -@override final String initialTargetCardId; -@override final String initialTargetWord; + final List _stages; +@override@JsonKey() List get stages { + if (_stages is EqualUnmodifiableListView) return _stages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_stages); +} + +// DEPRECATED: Use stages[0] instead. Kept for backward compatibility. +@override@Deprecated('Use stages[0] instead') final String? initialTargetCardId; +@override@Deprecated('Use stages[0] instead') final String? initialTargetWord; @override final String word; +@override final String? text; @override@JsonKey() final String type; /// Create a copy of MatrixQuestion @@ -2492,16 +2504,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&const DeepCollectionEquality().equals(other._stages, _stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),initialTargetCardId,initialTargetWord,word,type); +int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),const DeepCollectionEquality().hash(_stages),initialTargetCardId,initialTargetWord,word,text,type); @override String toString() { - return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)'; + return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)'; } @@ -2512,7 +2524,7 @@ abstract mixin class _$MatrixQuestionCopyWith<$Res> implements $MatrixQuestionCo factory _$MatrixQuestionCopyWith(_MatrixQuestion value, $Res Function(_MatrixQuestion) _then) = __$MatrixQuestionCopyWithImpl; @override @useResult $Res call({ - String id, int matrixSize, List cards, String initialTargetCardId, String initialTargetWord, String word, String type + String id, int matrixSize, List cards, List stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type }); @@ -2529,15 +2541,17 @@ class __$MatrixQuestionCopyWithImpl<$Res> /// Create a copy of MatrixQuestion /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) { return _then(_MatrixQuestion( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable as int,cards: null == cards ? _self._cards : cards // ignore: cast_nullable_to_non_nullable -as List,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable -as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable -as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable -as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as List,stages: null == stages ? _self._stages : stages // ignore: cast_nullable_to_non_nullable +as List,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable +as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable +as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable +as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as String, )); } @@ -2546,10 +2560,279 @@ as String, } +/// @nodoc +mixin _$MatrixStage { + + String get targetCardId; String get targetWord; String? get targetAudio; +/// Create a copy of MatrixStage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MatrixStageCopyWith get copyWith => _$MatrixStageCopyWithImpl(this as MatrixStage, _$identity); + + /// Serializes this MatrixStage to a JSON map. + Map 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 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 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? 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 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 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? 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 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 toJson() { + return _$MatrixStageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixStage&&(identical(other.targetCardId, targetCardId) || other.targetCardId == targetCardId)&&(identical(other.targetWord, targetWord) || other.targetWord == targetWord)&&(identical(other.targetAudio, targetAudio) || other.targetAudio == targetAudio)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,targetCardId,targetWord,targetAudio); + +@override +String toString() { + return 'MatrixStage(targetCardId: $targetCardId, targetWord: $targetWord, targetAudio: $targetAudio)'; +} + + +} + +/// @nodoc +abstract mixin class _$MatrixStageCopyWith<$Res> implements $MatrixStageCopyWith<$Res> { + factory _$MatrixStageCopyWith(_MatrixStage value, $Res Function(_MatrixStage) _then) = __$MatrixStageCopyWithImpl; +@override @useResult +$Res call({ + String targetCardId, String targetWord, String? targetAudio +}); + + + + +} +/// @nodoc +class __$MatrixStageCopyWithImpl<$Res> + implements _$MatrixStageCopyWith<$Res> { + __$MatrixStageCopyWithImpl(this._self, this._then); + + final _MatrixStage _self; + final $Res Function(_MatrixStage) _then; + +/// Create a copy of MatrixStage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? targetCardId = null,Object? targetWord = null,Object? targetAudio = freezed,}) { + return _then(_MatrixStage( +targetCardId: null == targetCardId ? _self.targetCardId : targetCardId // ignore: cast_nullable_to_non_nullable +as String,targetWord: null == targetWord ? _self.targetWord : targetWord // ignore: cast_nullable_to_non_nullable +as String,targetAudio: freezed == targetAudio ? _self.targetAudio : targetAudio // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + /// @nodoc mixin _$MatrixCard { - String get id; String get image; String get original; String get translation; + String get id; String? get image; String? get original; String? get translation; /// Create a copy of MatrixCard /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2582,7 +2865,7 @@ abstract mixin class $MatrixCardCopyWith<$Res> { factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl; @useResult $Res call({ - String id, String image, String original, String translation + String id, String? image, String? original, String? translation }); @@ -2599,13 +2882,13 @@ class _$MatrixCardCopyWithImpl<$Res> /// Create a copy of MatrixCard /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable -as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable -as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable -as String, +as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable +as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable +as String?, )); } @@ -2690,7 +2973,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String image, String original, String translation)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String? image, String? original, String? translation)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _MatrixCard() when $default != null: return $default(_that.id,_that.image,_that.original,_that.translation);case _: @@ -2711,7 +2994,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String image, String original, String translation) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String? image, String? original, String? translation) $default,) {final _that = this; switch (_that) { case _MatrixCard(): return $default(_that.id,_that.image,_that.original,_that.translation);case _: @@ -2731,7 +3014,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String image, String original, String translation)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String? image, String? original, String? translation)? $default,) {final _that = this; switch (_that) { case _MatrixCard() when $default != null: return $default(_that.id,_that.image,_that.original,_that.translation);case _: @@ -2746,13 +3029,13 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _: @JsonSerializable() class _MatrixCard implements MatrixCard { - const _MatrixCard({required this.id, required this.image, required this.original, required this.translation}); + const _MatrixCard({required this.id, this.image, this.original, this.translation}); factory _MatrixCard.fromJson(Map json) => _$MatrixCardFromJson(json); @override final String id; -@override final String image; -@override final String original; -@override final String translation; +@override final String? image; +@override final String? original; +@override final String? translation; /// Create a copy of MatrixCard /// with the given fields replaced by the non-null parameter values. @@ -2787,7 +3070,7 @@ abstract mixin class _$MatrixCardCopyWith<$Res> implements $MatrixCardCopyWith<$ factory _$MatrixCardCopyWith(_MatrixCard value, $Res Function(_MatrixCard) _then) = __$MatrixCardCopyWithImpl; @override @useResult $Res call({ - String id, String image, String original, String translation + String id, String? image, String? original, String? translation }); @@ -2804,13 +3087,13 @@ class __$MatrixCardCopyWithImpl<$Res> /// Create a copy of MatrixCard /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) { return _then(_MatrixCard( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable -as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable -as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable -as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable -as String, +as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable +as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable +as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/mnemo_cards_web_v2/lib/domain/models/game_question.g.dart b/mnemo_cards_web_v2/lib/domain/models/game_question.g.dart index 3545d0b..96f7f66 100644 --- a/mnemo_cards_web_v2/lib/domain/models/game_question.g.dart +++ b/mnemo_cards_web_v2/lib/domain/models/game_question.g.dart @@ -196,9 +196,15 @@ _MatrixQuestion _$MatrixQuestionFromJson(Map json) => cards: (json['cards'] as List) .map((e) => MatrixCard.fromJson(e as Map)) .toList(), - initialTargetCardId: json['initialTargetCardId'] as String, - initialTargetWord: json['initialTargetWord'] as String, + stages: + (json['stages'] as List?) + ?.map((e) => MatrixStage.fromJson(e as Map)) + .toList() ?? + const [], + initialTargetCardId: json['initialTargetCardId'] as String?, + initialTargetWord: json['initialTargetWord'] as String?, word: json['word'] as String, + text: json['text'] as String?, type: json['type'] as String? ?? 'matrix', ); @@ -207,17 +213,32 @@ Map _$MatrixQuestionToJson(_MatrixQuestion instance) => 'id': instance.id, 'matrixSize': instance.matrixSize, 'cards': instance.cards, + 'stages': instance.stages, 'initialTargetCardId': instance.initialTargetCardId, 'initialTargetWord': instance.initialTargetWord, 'word': instance.word, + 'text': instance.text, 'type': instance.type, }; +_MatrixStage _$MatrixStageFromJson(Map json) => _MatrixStage( + targetCardId: json['targetCardId'] as String, + targetWord: json['targetWord'] as String, + targetAudio: json['targetAudio'] as String?, +); + +Map _$MatrixStageToJson(_MatrixStage instance) => + { + 'targetCardId': instance.targetCardId, + 'targetWord': instance.targetWord, + 'targetAudio': instance.targetAudio, + }; + _MatrixCard _$MatrixCardFromJson(Map json) => _MatrixCard( id: json['id'] as String, - image: json['image'] as String, - original: json['original'] as String, - translation: json['translation'] as String, + image: json['image'] as String?, + original: json['original'] as String?, + translation: json['translation'] as String?, ); Map _$MatrixCardToJson(_MatrixCard instance) => diff --git a/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart b/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart index 55a6229..90b6b86 100644 --- a/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart +++ b/mnemo_cards_web_v2/lib/domain/state/tests_state_manager.dart @@ -290,11 +290,27 @@ class TestsStateManager extends StateManager { }); /// Complete the game session - Future completeGameSession() => handle((emit) async { + Future completeGameSession() { + log('completeGameSession method invoked', name: 'TestsStateManager'); + return handle((emit) async { + log('completeGameSession handle callback started', name: 'TestsStateManager'); final currentState = state; - if (currentState is! _GameSessionActive || _currentTest == null) return; + log('Current state type: ${currentState.runtimeType}', name: 'TestsStateManager'); + log('Current test: ${_currentTest?.id}', name: 'TestsStateManager'); + + if (currentState is! _GameSessionActive) { + log('State is not GameSessionActive, returning', name: 'TestsStateManager'); + return; + } + + if (_currentTest == null) { + log('Current test is null, returning', name: 'TestsStateManager'); + return; + } try { + log('Processing completion...', name: 'TestsStateManager'); + // Check if current question has been answered final currentQuestion = currentState.questions[currentState.currentQuestionIndex]; final questionId = _getQuestionId(currentQuestion); @@ -309,11 +325,15 @@ class TestsStateManager extends StateManager { _gameSessionManager.submitAnswer(questionId, currentQuestion, ''); } + log('Calling gameSessionManager.completeSession', name: 'TestsStateManager'); final result = _gameSessionManager.completeSession(_currentTest!.id!); + log('completeSession returned result', name: 'TestsStateManager'); // Play completion sound + log('Playing completion sound', name: 'TestsStateManager'); await _gameSoundService.playGameComplete(); + log('Emitting gameSessionCompleted state', name: 'TestsStateManager'); emit(TestsState.gameSessionCompleted( test: _currentTest!, result: result, @@ -331,6 +351,7 @@ class TestsStateManager extends StateManager { emit(TestsState.error('Failed to complete session: ${e.toString()}')); } }); + } /// Reset game session Future resetGameSession() => handle((emit) async { @@ -434,7 +455,7 @@ class TestsStateManager extends StateManager { questions.add(GameQuestion.multipleChoice( MultipleChoiceQuestion( id: 'q_${questions.length}', - question: question.w ?? '', + question: question.text ?? '', image: question.imageUrl ?? question.image, // Use presigned URL if available audio: question.audio, options: options, // Keep for backward compatibility @@ -479,15 +500,36 @@ class TestsStateManager extends StateManager { ) .toList(); + // Convert stages from DTO to domain model + final stages = question.stages + .map( + (s) => MatrixStage( + targetCardId: s.targetCardId, + targetWord: s.targetWord, + targetAudio: s.targetAudio, + ), + ) + .toList(); + + // Use stages if available, otherwise fallback to deprecated fields + final initialTargetCardId = stages.isNotEmpty + ? stages.first.targetCardId + : question.answer; + final initialTargetWord = stages.isNotEmpty + ? stages.first.targetWord + : question.word; + questions.add( GameQuestion.matrix( MatrixQuestion( id: id, matrixSize: question.matrixSize, cards: cards, - initialTargetCardId: question.answer, - initialTargetWord: question.word, + stages: stages, + initialTargetCardId: initialTargetCardId, + initialTargetWord: initialTargetWord, word: question.word, + text: question.text, ), ), ); diff --git a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart index 6783d60..863df45 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/game/game_page.dart @@ -17,6 +17,7 @@ import '../../../domain/state/tests_state_manager.dart'; import '../../../presentation/widgets/error_view.dart'; import '../../../presentation/widgets/game/answer_options.dart'; import '../../../presentation/widgets/game/input_letters_widget.dart'; +import '../../../presentation/widgets/game/match_widget.dart'; import '../../../presentation/widgets/game/matrix_widget.dart'; import '../../../presentation/widgets/game/progress_indicator.dart'; import '../../../presentation/widgets/game/question_display.dart'; @@ -133,6 +134,7 @@ class _GamePageState extends State { } Widget _buildBody(TestsState state) { + log('_buildBody called with state: ${state.runtimeType}', name: 'GamePage'); final appScope = ScopeProvider.of(context, listen: false); final userScope = appScope?.userScopeHolder.scope; final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero; @@ -229,6 +231,34 @@ class _GamePageState extends State { builder: (context, constraints) { final isNarrow = constraints.maxWidth < 720; final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth; + final availableHeight = constraints.maxHeight; + + // Determine progress indicator mode based on available height + final progressMode = availableHeight > 800 + ? ProgressIndicatorMode.full + : availableHeight > 600 + ? ProgressIndicatorMode.compact + : ProgressIndicatorMode.mini; + + // Calculate spacing based on available height + final spacingAfterProgress = availableHeight > 700 ? 12.h : 8.h; + final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h; + final spacingAfterAnswer = availableHeight > 700 ? 16.h : 12.h; + + // Calculate max heights for question and answer sections + // Reserve space: progress (40-100px), navigation buttons (56px), spacing + final reservedHeight = progressMode == ProgressIndicatorMode.full + ? 120.h + : progressMode == ProgressIndicatorMode.compact + ? 80.h + : 60.h; + final navigationHeight = (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ? 72.h : 0; + final totalSpacing = spacingAfterProgress + spacingAfterQuestion + spacingAfterAnswer; + final availableForContent = availableHeight - reservedHeight - navigationHeight - totalSpacing; + + // Distribute: 40% question, 60% answers (adjustable) + final questionMaxHeight = availableForContent * 0.4; + final answerMaxHeight = availableForContent * 0.6; return Center( child: ConstrainedBox( @@ -236,100 +266,119 @@ class _GamePageState extends State { child: Column( children: [ // Progress indicator - Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - child: Row( - children: [ - Expanded( - child: GameProgressIndicator( - currentQuestion: currentQuestionIndex, - totalQuestions: questions.length, - correctAnswers: questionResults.values.where((r) => r.isCorrect).length, - timeElapsed: sessionElapsed, - ), - ), - ], - ), + Padding( + padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w), + child: GameProgressIndicator( + currentQuestion: currentQuestionIndex, + totalQuestions: questions.length, + correctAnswers: questionResults.values.where((r) => r.isCorrect).length, + timeElapsed: sessionElapsed, + mode: progressMode, + ), ), + SizedBox(height: spacingAfterProgress), + // Question content Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(isNarrow ? 12.w : 16.w), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Question display - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: KeyedSubtree( - key: questionKey, - child: Material( - key: GamePage.questionCardKey, - color: colorScheme.surface, - surfaceTintColor: colorScheme.surfaceTint, - elevation: 3, - shadowColor: theme.shadowColor.withOpacity( - theme.brightness == Brightness.dark ? 0.35 : 0.14, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18.r), - side: BorderSide( - color: colorScheme.outlineVariant, + Flexible( + flex: 2, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: questionMaxHeight), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: questionKey, + child: Material( + key: GamePage.questionCardKey, + color: colorScheme.surface, + surfaceTintColor: colorScheme.surfaceTint, + elevation: 3, + shadowColor: theme.shadowColor.withOpacity( + theme.brightness == Brightness.dark ? 0.35 : 0.14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.r), + side: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + child: Padding( + padding: EdgeInsets.all(isNarrow ? 14.w : 18.w), + child: currentQuestion is GameQuestionMatrix + ? MatrixWidget( + question: currentQuestion.question, + maxHeight: questionMaxHeight, + ) + : QuestionDisplay( + question: currentQuestion, + onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio, + maxHeight: questionMaxHeight, + ), + ), ), ), - child: Padding( - padding: EdgeInsets.all(isNarrow ? 14.w : 18.w), - child: currentQuestion is GameQuestionMatrix - ? MatrixWidget(question: currentQuestion.question) - : QuestionDisplay( - question: currentQuestion, - onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio, - ), - ), ), ), ), - SizedBox(height: isNarrow ? 16.h : 20.h), + SizedBox(height: spacingAfterQuestion), // Answer input based on question type with smooth transitions - AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.05, 0), - end: Offset.zero, - ).animate(animation), - child: child, + Flexible( + flex: 3, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: answerMaxHeight), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.05, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Container( + key: ValueKey('question_input_${currentQuestion.hashCode}'), + child: currentQuestion.when( + multipleChoice: (q) => AnswerOptions( + question: q, + selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults), + onAnswerSelected: _onAnswerSelected, + isAnswerSubmitted: isAnswerSubmitted, + isCorrect: isCorrect, + maxHeight: answerMaxHeight, + ), + inputLetters: (q) => InputLettersWidget( + question: q, + maxHeight: answerMaxHeight, + ), + match: (q) => MatchWidget( + question: q, + maxHeight: answerMaxHeight, + ), + matrix: (q) => const SizedBox.shrink(), + ), ), - ); - }, - child: Container( - key: ValueKey('question_input_${currentQuestion.hashCode}'), - child: currentQuestion.when( - multipleChoice: (q) => AnswerOptions( - question: q, - selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults), - onAnswerSelected: _onAnswerSelected, - isAnswerSubmitted: isAnswerSubmitted, - isCorrect: isCorrect, - ), - inputLetters: (q) => InputLettersWidget(question: q), - match: (q) => const Center(child: Text('Match questions coming soon!')), - matrix: (q) => const SizedBox.shrink(), ), ), ), // Navigation buttons (show when navigation is possible) if (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ...[ - SizedBox(height: isNarrow ? 18.h : 24.h), + SizedBox(height: spacingAfterAnswer), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -342,10 +391,18 @@ class _GamePageState extends State { SizedBox(width: isNarrow ? 12.w : 16.w), ], if (_isLastQuestion(state)) ...[ - ElevatedButton.icon( - onPressed: _finishGame, - icon: const Icon(Icons.check), - label: const Text('Finish'), + Builder( + builder: (context) { + log('Finish button is being rendered', name: 'GamePage'); + return ElevatedButton.icon( + onPressed: () { + log('Finish button onPressed triggered', name: 'GamePage'); + _finishGame(); + }, + icon: const Icon(Icons.check), + label: const Text('Finish'), + ); + }, ), ] else if (_canGoNext(state)) ...[ ElevatedButton.icon( @@ -546,11 +603,18 @@ class _GamePageState extends State { Future _finishGame() async { log('Finish button pressed', name: 'GamePage'); final appScope = ScopeProvider.of(context, listen: false); + log('AppScope: ${appScope != null ? 'available' : 'null'}', name: 'GamePage'); final userScope = appScope?.userScopeHolder.scope; + log('UserScope: ${userScope != null ? 'available' : 'null'}', name: 'GamePage'); + if (userScope != null) { - log('Completing game session...', name: 'GamePage'); - await userScope.testsModule.testsStateManager.completeGameSession(); - log('Game session completion called', name: 'GamePage'); + log('Calling completeGameSession...', name: 'GamePage'); + try { + await userScope.testsModule.testsStateManager.completeGameSession(); + log('Game session completion finished successfully', name: 'GamePage'); + } catch (e, s) { + log('Error during completeGameSession', error: e, stackTrace: s, name: 'GamePage'); + } } else { log('No user scope available when finishing game', name: 'GamePage'); } @@ -635,20 +699,28 @@ class _GamePageState extends State { bool _canGoNext(TestsState state) { final appScope = ScopeProvider.of(context, listen: false); final userScope = appScope?.userScopeHolder.scope; - return userScope?.testsModule.testsStateManager.canGoNext ?? false; + final canNext = userScope?.testsModule.testsStateManager.canGoNext ?? false; + log('Can go next: $canNext', name: 'GamePage'); + return canNext; } bool _canGoPrevious(TestsState state) { final appScope = ScopeProvider.of(context, listen: false); final userScope = appScope?.userScopeHolder.scope; - return userScope?.testsModule.testsStateManager.canGoPrevious ?? false; + final canPrev = userScope?.testsModule.testsStateManager.canGoPrevious ?? false; + log('Can go previous: $canPrev', name: 'GamePage'); + return canPrev; } bool _isLastQuestion(TestsState state) { - return state.maybeWhen( - gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) => - currentQuestionIndex >= questions.length - 1, + final result = state.maybeWhen( + gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) { + final isLast = currentQuestionIndex >= questions.length - 1; + log('Is last question: $isLast (index: $currentQuestionIndex, total: ${questions.length})', name: 'GamePage'); + return isLast; + }, orElse: () => false, ); + return result; } } \ No newline at end of file diff --git a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart index d5ca802..e833948 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart @@ -443,7 +443,7 @@ class _PackDetailsPageState extends State { children: [ // Модуль "проверка знаний" для мобильных // Hide tests section for mobile devices for now - // _buildMobileTestsSection(packColor), + _buildMobileTestsSection(packColor), const SizedBox(height: 16), @@ -457,9 +457,11 @@ class _PackDetailsPageState extends State { // ignore: unused_element Widget _buildMobileTestsSection(Color packColor) { final tests = _getTests(); - + if (tests.isEmpty) { + return const SizedBox.shrink(); + } return Container( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Wrap( spacing: 8, runSpacing: 8, diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart index 654ae0f..9ddfe3f 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/answer_options.dart @@ -11,6 +11,7 @@ class AnswerOptions extends StatelessWidget { required this.isAnswerSubmitted, required this.isCorrect, this.enabled = true, + this.maxHeight, super.key, }); @@ -20,6 +21,7 @@ class AnswerOptions extends StatelessWidget { final bool isAnswerSubmitted; final bool isCorrect; final bool enabled; + final double? maxHeight; void _onAnswerSelected(BuildContext context, String option) { // Note: Sound service access would be implemented through proper DI injection @@ -44,27 +46,42 @@ class AnswerOptions extends StatelessWidget { // Adjust aspect ratio for image buttons (they need more space) final hasImages = hasOptionItems && question.optionItems.any((item) => item.image != null); - final childAspectRatio = hasImages ? 1.1 : 4.0; + + // 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( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: childAspectRatio, + return ConstrainedBox( + constraints: maxHeight != null + ? BoxConstraints(maxHeight: maxHeight!) + : const BoxConstraints(), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, + childAspectRatio: childAspectRatio, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + if (hasOptionItems) { + final optionItem = question.optionItems[index]; + return _buildAnswerOptionFromItem(context, optionItem); + } else { + final option = question.options[index]; + return _buildAnswerOption(context, option); + } + }, ), - itemCount: itemCount, - itemBuilder: (context, index) { - if (hasOptionItems) { - final optionItem = question.optionItems[index]; - return _buildAnswerOptionFromItem(context, optionItem); - } else { - final option = question.options[index]; - return _buildAnswerOption(context, option); - } - }, ); }, ); @@ -166,7 +183,10 @@ class AnswerOptions extends StatelessWidget { splashColor: borderColor?.withOpacity(0.1), child: AnimatedContainer( duration: const Duration(milliseconds: 300), - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( border: Border.all( color: @@ -303,7 +323,7 @@ class AnswerOptions extends StatelessWidget { duration: const Duration(milliseconds: 300), style: Theme.of(context).textTheme.bodySmall!.copyWith( color: textColor, - fontWeight: isSelected || isCorrectOption + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart index 42aa008..4677f43 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/input_letters_widget.dart @@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart'; class InputLettersWidget extends StatefulWidget { const InputLettersWidget({ required this.question, + this.maxHeight, super.key, }); final InputLettersQuestion question; + final double? maxHeight; @override State createState() => _InputLettersWidgetState(); @@ -73,101 +75,123 @@ class _InputLettersWidgetState extends State { @override Widget build(BuildContext context) { + // Adapt spacing and padding based on maxHeight + final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; + final containerPadding = isCompact ? 12.w : 16.w; + final containerMargin = isCompact ? 12.h : 16.h; + final spacingAfterTitle = isCompact ? 8.h : 12.h; + final spacingAfterWord = isCompact ? 8.h : 12.h; + final spacingAfterGrid = isCompact ? 12.h : 16.h; + final showFillInstruction = !isCompact; // Hide on very compact screens + return LayoutBuilder( builder: (context, constraints) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Display the template with current input - Container( - padding: EdgeInsets.all(24.w), - margin: EdgeInsets.only(bottom: 24.h), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 2), + return ConstrainedBox( + constraints: widget.maxHeight != null + ? BoxConstraints(maxHeight: widget.maxHeight!) + : const BoxConstraints(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Display the template with current input + Flexible( + child: Container( + padding: EdgeInsets.all(containerPadding), + margin: EdgeInsets.only(bottom: containerMargin), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - ], - ), - child: Column( - children: [ - Text( - 'Fill in the blanks:', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showFillInstruction) + Text( + 'Fill in the blanks:', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, ), + if (showFillInstruction && widget.question.word.isNotEmpty) + SizedBox(height: spacingAfterTitle), + if (widget.question.word.isNotEmpty) ...[ + Text( + widget.question.word, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: spacingAfterWord), + _buildTemplateDisplay(), + ], + ), + ), + ), + + // Buttons with images or text (if available) + if (widget.question.buttons.isNotEmpty) ...[ + Flexible( + child: _buildButtonsGrid(constraints), + ), + SizedBox(height: spacingAfterGrid), + ] else ...[ + // Input field (only if no buttons) + Container( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * 0.8, + ), + child: TextField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Type the missing letters...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 12.h, + ), + ), + style: TextStyle( + fontSize: 18.sp, + letterSpacing: 2, + fontWeight: FontWeight.w500, + ), textAlign: TextAlign.center, + maxLength: widget.question.correctAnswer.length, + onSubmitted: _submitAnswer, ), - if (widget.question.word.isNotEmpty) ...[ - SizedBox(height: 16.h), - Text( - widget.question.word, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - ], - SizedBox(height: 16.h), - _buildTemplateDisplay(), - ], - ), - ), + ), + SizedBox(height: spacingAfterGrid), + ], - // Buttons with images or text (if available) - if (widget.question.buttons.isNotEmpty) ...[ - _buildButtonsGrid(constraints), - SizedBox(height: 24.h), - ] else ...[ - // Input field (only if no buttons) - Container( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.8, - ), - child: TextField( - controller: _controller, - focusNode: _focusNode, - decoration: InputDecoration( - hintText: 'Type the missing letters...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.r), - ), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - contentPadding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 12.h, - ), - ), - style: TextStyle( - fontSize: 18.sp, - letterSpacing: 2, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - maxLength: widget.question.correctAnswer.length, - onSubmitted: _submitAnswer, + // Submit button + ElevatedButton.icon( + onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null, + icon: const Icon(Icons.send), + label: const Text('Submit Answer'), + style: ElevatedButton.styleFrom( + minimumSize: Size(200.w, 48.h), + textStyle: TextStyle(fontSize: 16.sp), ), ), - SizedBox(height: 24.h), ], - - // Submit button - ElevatedButton.icon( - onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null, - icon: const Icon(Icons.send), - label: const Text('Submit Answer'), - style: ElevatedButton.styleFrom( - minimumSize: Size(200.w, 48.h), - textStyle: TextStyle(fontSize: 16.sp), - ), - ), - ], + ), ); }, ); @@ -187,6 +211,13 @@ class _InputLettersWidgetState extends State { List _buildTemplateParts(String template, String currentInput) { final parts = []; int inputIndex = 0; + + // Adapt sizes based on maxHeight + final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; + final cellWidth = isCompact ? 24.w : 28.w; + final cellHeight = isCompact ? 36.h : 48.h; + final cellFontSize = isCompact ? 16.sp : 20.sp; + final blankCellWidth = isCompact ? 28.w : 32.w; for (int i = 0; i < template.length; i++) { final char = template[i]; @@ -198,8 +229,8 @@ class _InputLettersWidgetState extends State { parts.add( Container( - width: 32.w, - height: 48.h, + width: blankCellWidth, + height: cellHeight, margin: EdgeInsets.symmetric(horizontal: 2.w), decoration: BoxDecoration( border: Border.all( @@ -215,7 +246,7 @@ class _InputLettersWidgetState extends State { child: Text( letter.toUpperCase(), style: TextStyle( - fontSize: 20.sp, + fontSize: cellFontSize, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, fontFeatures: const [FontFeature.tabularFigures()], @@ -232,14 +263,14 @@ class _InputLettersWidgetState extends State { // This is a regular character parts.add( Container( - width: 28.w, - height: 48.h, + width: cellWidth, + height: cellHeight, margin: EdgeInsets.symmetric(horizontal: 2.w), alignment: Alignment.center, child: Text( char, style: TextStyle( - fontSize: 20.sp, + fontSize: cellFontSize, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurface, fontFeatures: const [FontFeature.tabularFigures()], @@ -256,15 +287,19 @@ class _InputLettersWidgetState extends State { Widget _buildButtonsGrid(BoxConstraints constraints) { final hasImages = widget.question.buttons.any((b) => b.image != null); final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3; - final childAspectRatio = hasImages ? 1.2 : 2.0; + final isCompact = widget.maxHeight != null && widget.maxHeight! < 400; + final childAspectRatio = hasImages + ? (isCompact ? 1.0 : 1.2) + : (isCompact ? 2.0 : 2.0); + final spacing = isCompact ? 8.0 : 12.0; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, childAspectRatio: childAspectRatio, ), itemCount: widget.question.buttons.length, diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart index bb5ce82..2b9f7bf 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/match_widget.dart @@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart'; class MatchWidget extends StatefulWidget { const MatchWidget({ required this.question, + this.maxHeight, super.key, }); final MatchQuestion question; + final double? maxHeight; @override State createState() => _MatchWidgetState(); @@ -25,118 +27,146 @@ class _MatchWidgetState extends State { @override Widget build(BuildContext context) { + // Adapt spacing and padding based on maxHeight + final isCompact = widget.maxHeight != null && widget.maxHeight! < 500; + final instructionPadding = isCompact ? 12.w : 16.w; + final instructionMargin = isCompact ? 12.h : 16.h; + final connectionPadding = isCompact ? 12.w : 16.w; + final connectionMargin = isCompact ? 12.h : 16.h; + final spacingAfterColumns = isCompact ? 12.h : 16.h; + final instructionText = isCompact + ? 'Connect matching items' + : 'Connect the matching items by tapping them in order'; + return LayoutBuilder( builder: (context, constraints) { final isWideScreen = constraints.maxWidth > 600; - return Column( - children: [ - // Instructions - Container( - padding: EdgeInsets.all(16.w), - margin: EdgeInsets.only(bottom: 24.h), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Text( - 'Connect the matching items by tapping them in order', - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ), - - // Connection display - if (_connections.isNotEmpty) ...[ + return ConstrainedBox( + constraints: widget.maxHeight != null + ? BoxConstraints(maxHeight: widget.maxHeight!) + : const BoxConstraints(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Instructions Container( - padding: EdgeInsets.all(16.w), - margin: EdgeInsets.only(bottom: 16.h), + padding: EdgeInsets.all(instructionPadding), + margin: EdgeInsets.only(bottom: instructionMargin), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Connections:', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), ), - SizedBox(height: 8.h), - ..._buildConnectionDisplay(), ], ), + child: Text( + instructionText, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + + // Connection display + if (_connections.isNotEmpty) ...[ + Container( + padding: EdgeInsets.all(connectionPadding), + margin: EdgeInsets.only(bottom: connectionMargin), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Connected: ${_connections.length}/${widget.question.correctPairs.length}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (!isCompact) ...[ + SizedBox(height: 8.h), + ..._buildConnectionDisplay(), + ], + ], + ), + ), + ], + + // Two columns layout + Flexible( + child: isWideScreen + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)), + SizedBox(width: isCompact ? 16.w : 24.w), + Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)), + ], + ) + : Column( + children: [ + Text( + 'Left Column', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isCompact ? 8.h : 12.h), + Flexible(child: _buildColumn(widget.question.leftItems, isLeft: true)), + SizedBox(height: isCompact ? 16.h : 24.h), + Text( + 'Right Column', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isCompact ? 8.h : 12.h), + Flexible(child: _buildColumn(widget.question.rightItems, isLeft: false)), + ], + ), + ), + + SizedBox(height: spacingAfterColumns), + + // Submit button + ElevatedButton.icon( + onPressed: _canSubmit ? _submitAnswer : null, + icon: const Icon(Icons.check_circle), + label: const Text('Submit Answer'), + style: ElevatedButton.styleFrom( + minimumSize: Size(200.w, 48.h), + textStyle: TextStyle(fontSize: 16.sp), + ), ), ], - - // Two columns layout - Expanded( - child: isWideScreen - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)), - SizedBox(width: 24.w), - Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)), - ], - ) - : Column( - children: [ - Text( - 'Left Column', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 12.h), - Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)), - SizedBox(height: 24.h), - Text( - 'Right Column', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.secondary, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 12.h), - Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)), - ], - ), - ), - - SizedBox(height: 24.h), - - // Submit button - ElevatedButton.icon( - onPressed: _canSubmit ? _submitAnswer : null, - icon: const Icon(Icons.check_circle), - label: const Text('Submit Answer'), - style: ElevatedButton.styleFrom( - minimumSize: Size(200.w, 48.h), - textStyle: TextStyle(fontSize: 16.sp), - ), - ), - ], + ), ); }, ); } Widget _buildColumn(List items, {required bool isLeft}) { + final isCompact = widget.maxHeight != null && widget.maxHeight! < 500; + final itemMargin = isCompact ? 4.h : 6.h; + final itemPadding = isCompact ? 8.w : 12.w; + final imageSize = isCompact ? 32.0 : 40.0; + final fontSize = isCompact ? 14.sp : 16.sp; + final iconSize = isCompact ? 18.sp : 20.sp; + return ListView.builder( itemCount: items.length, + itemExtent: isCompact ? 52.0 : 64.0, // Fixed item height for better performance itemBuilder: (context, index) { final item = items[index]; final isSelected = isLeft @@ -147,7 +177,7 @@ class _MatchWidgetState extends State { : _connections.containsValue(item.id); return Container( - margin: EdgeInsets.only(bottom: 8.h), + margin: EdgeInsets.only(bottom: itemMargin), child: Material( color: isConnected ? Theme.of(context).colorScheme.primary.withOpacity(0.1) @@ -159,7 +189,7 @@ class _MatchWidgetState extends State { onTap: isConnected ? null : () => _onItemTap(item.id, isLeft), borderRadius: BorderRadius.circular(12.r), child: Container( - padding: EdgeInsets.all(12.w), + padding: EdgeInsets.all(itemPadding), decoration: BoxDecoration( border: Border.all( color: isConnected @@ -175,8 +205,8 @@ class _MatchWidgetState extends State { children: [ if (item.image != null) ...[ Container( - width: 40.w, - height: 40.h, + width: imageSize, + height: imageSize, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.r), image: DecorationImage( @@ -185,13 +215,13 @@ class _MatchWidgetState extends State { ), ), ), - SizedBox(width: 12.w), + SizedBox(width: isCompact ? 8.w : 12.w), ], Expanded( child: Text( item.text, style: TextStyle( - fontSize: 16.sp, + fontSize: fontSize, fontWeight: isConnected ? FontWeight.w600 : FontWeight.normal, color: isConnected ? Theme.of(context).colorScheme.primary @@ -204,7 +234,7 @@ class _MatchWidgetState extends State { Icon( Icons.check_circle, color: Theme.of(context).colorScheme.primary, - size: 20.sp, + size: iconSize, ), ], ], diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart index d0d7014..dead224 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/matrix_widget.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yx_scope_flutter/yx_scope_flutter.dart'; @@ -12,19 +14,21 @@ import '../../../domain/models/matrix_image_select_answer.dart'; /// /// Shows a N x N matrix of images, and the current target word (original) /// under the matrix. User clicks an image to answer: -/// - correct: card flips (translation on the back) and disappears; target word updates +/// - correct: card flips (showing original and translation on the back) and stays flipped; target word updates /// - wrong: card shakes + wrong sound; test continues class MatrixWidget extends StatefulWidget { const MatrixWidget({ required this.question, this.onWrongAttempt, this.onCompleted, + this.maxHeight, super.key, }); final MatrixQuestion question; final Future Function()? onWrongAttempt; final Future Function(MatrixImageSelectAnswer answer)? onCompleted; + final double? maxHeight; @override State createState() => _MatrixWidgetState(); @@ -35,10 +39,8 @@ class _MatrixWidgetState extends State static const _flipDuration = Duration(milliseconds: 450); static const _afterFlipHold = Duration(milliseconds: 650); - late math.Random _random; late List _slots; // stable positions; null == removed - late String _targetCardId; - late String _targetWord; + var _currentStageIndex = 0; final _flipped = {}; final _correctIdsInOrder = []; @@ -48,6 +50,9 @@ class _MatrixWidgetState extends State String? _shakingCardId; late final AnimationController _shakeController; late final Animation _shakeOffset; + + AudioPlayer? _audioPlayer; + bool _isPlayingAudio = false; @override void initState() { @@ -76,23 +81,78 @@ class _MatrixWidgetState extends State } void _initFromQuestion(MatrixQuestion q) { - _random = math.Random(q.id.hashCode ^ q.matrixSize); _slots = q.cards.map((c) => c).toList(growable: false); _flipped.clear(); _correctIdsInOrder.clear(); _wrongAttempts = 0; _isCompleting = false; _shakingCardId = null; - _targetCardId = q.initialTargetCardId; - _targetWord = q.initialTargetWord; + _currentStageIndex = 0; + + // Play audio for first stage if available + if (q.stages.isNotEmpty) { + _playStageAudio(q.stages[0]); + } } @override void dispose() { _shakeController.dispose(); + _audioPlayer?.dispose(); super.dispose(); } + Future _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(context, listen: false); + if (appScope == null) { + debugPrint('MatrixWidget: Scope not available'); + return; + } + + setState(() { + _isPlayingAudio = true; + }); + + try { + await _audioPlayer!.stop(); + + final audioValue = stage.targetAudio!; + Uint8List bytes; + + // Check if it's already a presigned URL (contains query params) + final uri = Uri.tryParse(audioValue); + if (uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.queryParameters.isNotEmpty) { + // Likely a presigned URL, download directly + bytes = await appScope.httpRepository.downloadBytesFromUrl(audioValue); + } else { + // Assume it's a UUID, use voice API endpoint + bytes = await appScope.httpRepository.getVoiceFileBytes(audioValue); + } + + if (!mounted) return; + + await _audioPlayer!.play(BytesSource(bytes)); + } catch (e) { + debugPrint('MatrixWidget: Failed to play audio: $e'); + // Could show error to user here if needed + } finally { + if (mounted) { + setState(() { + _isPlayingAudio = false; + }); + } + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -110,78 +170,199 @@ class _MatrixWidgetState extends State ...List.filled(total - _slots.length, null), ]; + // Calculate available height for grid (reserve space for target container) + // Target container: ~100-120px (compact) to 140-160px (full) + final targetContainerHeight = widget.maxHeight != null && widget.maxHeight! < 350 + ? 100.0 + : 140.0; + final spacingBetween = widget.maxHeight != null && widget.maxHeight! < 350 + ? 12.h + : 16.h; + + final gridMaxHeight = widget.maxHeight != null + ? widget.maxHeight! - targetContainerHeight - spacingBetween + : null; + + // Adapt spacing for grid cells + final cellSpacing = widget.maxHeight != null && widget.maxHeight! < 350 + ? 6.0 + : 10.0; + + // Adapt padding for target container + final targetPaddingH = widget.maxHeight != null && widget.maxHeight! < 350 + ? 16.w + : 20.w; + final targetPaddingV = widget.maxHeight != null && widget.maxHeight! < 350 + ? 12.h + : 16.h; + + return ConstrainedBox( + constraints: widget.maxHeight != null + ? BoxConstraints(maxHeight: widget.maxHeight!) + : const BoxConstraints(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ConstrainedBox( + constraints: gridMaxHeight != null + ? BoxConstraints(maxHeight: gridMaxHeight) + : const BoxConstraints(), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: size, + crossAxisSpacing: cellSpacing, + mainAxisSpacing: cellSpacing, + childAspectRatio: 1.0, + ), + itemCount: total, + itemBuilder: (context, index) { + final card = slots[index]; + return _buildSlot(context, card); + }, + ), + ), + ), + SizedBox(height: spacingBetween), + Container( + padding: EdgeInsets.symmetric( + horizontal: targetPaddingH, + vertical: targetPaddingV, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(24.r), + border: Border.all( + color: colorScheme.primary, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.question.text != null && widget.question.text!.isNotEmpty) + Text( + widget.question.text!, + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (widget.question.text != null && widget.question.text!.isNotEmpty) + SizedBox(height: 8.h), + _buildTargetContent(context), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTargetContent(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + final currentStage = _getCurrentStage(); + if (currentStage == null) { + return const SizedBox.shrink(); + } + return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: size, - crossAxisSpacing: 10.w, - mainAxisSpacing: 10.h, - childAspectRatio: 1.0, + // Audio button if audio is available + if (currentStage.targetAudio != null && currentStage.targetAudio!.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 12.h), + child: IconButton( + icon: _isPlayingAudio + ? SizedBox( + width: 24.w, + height: 24.h, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), + ), + ) + : Icon( + Icons.volume_up, + size: 32.sp, + color: colorScheme.primary, + ), + onPressed: _isPlayingAudio + ? null + : () => _playStageAudio(currentStage), + ), ), - itemCount: total, - itemBuilder: (context, index) { - final card = slots[index]; - return _buildSlot(context, card); - }, - ), - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withOpacity(0.35), - borderRadius: BorderRadius.circular(14.r), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - children: [ - Text( - 'Найди слово:', - style: textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 6.h), - SelectableText( - _targetWord, - style: textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - ), - ], + // Target word + SelectableText( + currentStage.targetWord, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, ), + textAlign: TextAlign.center, ), ], ); } + MatrixStage? _getCurrentStage() { + final stages = widget.question.stages; + if (stages.isEmpty) { + // Fallback to deprecated fields + if (widget.question.initialTargetCardId != null && + widget.question.initialTargetWord != null) { + return MatrixStage( + targetCardId: widget.question.initialTargetCardId!, + targetWord: widget.question.initialTargetWord!, + targetAudio: null, + ); + } + return null; + } + + if (_currentStageIndex >= stages.length) { + return null; + } + + return stages[_currentStageIndex]; + } + Widget _buildSlot(BuildContext context, MatrixCard? card) { - final colorScheme = Theme.of(context).colorScheme; - final isFlipped = card != null && _flipped.contains(card.id); - final isShaking = card != null && _shakingCardId == card.id; + if (card == null) { + return AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24.r), + ), + ), + ); + } + + final isFlipped = _flipped.contains(card.id); + final isShaking = _shakingCardId == card.id; final base = AspectRatio( aspectRatio: 1, - child: card == null - ? DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14.r), - border: Border.all( - color: colorScheme.outlineVariant.withOpacity(0.7), - ), - color: colorScheme.surfaceContainerHighest.withOpacity(0.15), - ), - ) - : _MatrixFlipCard( - card: card, - isFlipped: isFlipped, - onTap: _isCompleting ? null : () => _onCardTap(card), - ), + child: _MatrixFlipCard( + card: card, + isFlipped: isFlipped, + onTap: _isCompleting || isFlipped ? null : () => _onCardTap(card), + ), ); if (!isShaking) return base; @@ -200,9 +381,12 @@ class _MatrixWidgetState extends State Future _onCardTap(MatrixCard card) async { if (_isCompleting) return; - if (_flipped.contains(card.id)) return; + if (_flipped.contains(card.id)) return; // Already flipped, ignore tap - if (card.id == _targetCardId) { + final currentStage = _getCurrentStage(); + if (currentStage == null) return; + + if (card.id == currentStage.targetCardId) { await _handleCorrect(card); } else { await _handleWrong(card); @@ -234,25 +418,27 @@ class _MatrixWidgetState extends State await Future.delayed(_flipDuration + _afterFlipHold); - // Remove from matrix (keep slot stable -> set to null) - final idx = _slots.indexWhere((c) => c?.id == card.id); - if (idx >= 0) { - setState(() { - _slots[idx] = null; - }); - } - - final remaining = _slots.whereType().toList(growable: false); - if (remaining.isEmpty) { + // Check if all cards are flipped or all stages completed + final allFlipped = _slots + .whereType() + .every((c) => _flipped.contains(c.id)); + final allStagesCompleted = _currentStageIndex >= widget.question.stages.length - 1; + + if (allFlipped || allStagesCompleted) { await _completeQuestion(); return; } - final next = remaining[_random.nextInt(remaining.length)]; + // Move to next stage setState(() { - _targetCardId = next.id; - _targetWord = next.original; + _currentStageIndex++; }); + + // Play audio for next stage if available + final nextStage = _getCurrentStage(); + if (nextStage != null) { + _playStageAudio(nextStage); + } } Future _completeQuestion() async { @@ -291,11 +477,10 @@ class _MatrixFlipCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; return InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(14.r), + borderRadius: BorderRadius.circular(24.r), child: TweenAnimationBuilder( tween: Tween( begin: 0, @@ -312,46 +497,66 @@ class _MatrixFlipCard extends StatelessWidget { return Transform( transform: transform, alignment: Alignment.center, - child: ClipRRect( - borderRadius: BorderRadius.circular(14.r), - child: DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border.all(color: colorScheme.outlineVariant), + child: Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(24.r), + border: Border.all( + color: colorScheme.primary, + width: 3, ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(21.r), child: isBack ? Transform( alignment: Alignment.center, transform: Matrix4.identity()..rotateY(math.pi), child: Center( child: Padding( - padding: EdgeInsets.all(10.w), - child: Text( - card.translation, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (card.original != null) + Text( + card.original!, + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (card.original != null && card.translation != null) + SizedBox(height: 12.h), + if (card.translation != null) + Text( + card.translation!, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w400, + color: colorScheme.onSurface.withOpacity(0.5), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), ) - : Image.network( - card.image, // Already contains presigned URL or objectId from tests_state_manager - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - errorBuilder: (context, error, stackTrace) { - return Center( - child: Icon( - Icons.image_not_supported, - color: colorScheme.onSurfaceVariant, - ), - ); - }, - ), + : _buildCardFront(card, colorScheme), ), ), ); @@ -359,4 +564,123 @@ class _MatrixFlipCard extends StatelessWidget { ), ); } + + Widget _buildCardFront(MatrixCard card, ColorScheme colorScheme) { + final hasImage = card.image != null && card.image!.isNotEmpty; + final hasOriginal = card.original != null && card.original!.isNotEmpty; + final hasTranslation = card.translation != null && card.translation!.isNotEmpty; + final hasText = hasOriginal || hasTranslation; + + // If only text (no image), show text centered and large + if (!hasImage && hasText) { + return Center( + child: Padding( + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (hasOriginal) + Text( + card.original!, + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (hasOriginal && hasTranslation) SizedBox(height: 8.h), + if (hasTranslation) + Text( + card.translation!, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w400, + color: colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + // If only image (no text), show image full size + if (hasImage && !hasText) { + return Image.network( + card.image!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.image_not_supported, + color: colorScheme.onSurfaceVariant, + ), + ); + }, + ); + } + + // If both image and text, show text on top and image below + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasText) + Padding( + padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 8.h), + child: Column( + children: [ + if (hasOriginal) + Text( + card.original!, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (hasOriginal && hasTranslation) SizedBox(height: 4.h), + if (hasTranslation) + Text( + card.translation!, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: colorScheme.onSurface.withOpacity(0.5), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (hasImage) + Expanded( + child: Image.network( + card.image!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Icon( + Icons.image_not_supported, + color: colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ), + ], + ); + } } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/progress_indicator.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/progress_indicator.dart index 0dbff3d..ee0a2bc 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/progress_indicator.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/progress_indicator.dart @@ -1,6 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +/// Compactness mode for progress indicator +enum ProgressIndicatorMode { + /// Full mode: shows all statistics (correct, time, accuracy) + full, + /// Compact mode: shows progress bar and question number with minimal stats + compact, + /// Mini mode: shows only progress bar and question number + mini, +} + /// Widget for displaying game progress class GameProgressIndicator extends StatelessWidget { const GameProgressIndicator({ @@ -8,6 +18,7 @@ class GameProgressIndicator extends StatelessWidget { required this.totalQuestions, required this.correctAnswers, required this.timeElapsed, + this.mode = ProgressIndicatorMode.full, super.key, }); @@ -15,14 +26,25 @@ class GameProgressIndicator extends StatelessWidget { final int totalQuestions; final int correctAnswers; final Duration timeElapsed; + final ProgressIndicatorMode mode; @override Widget build(BuildContext context) { final progress = currentQuestion / totalQuestions; final accuracy = currentQuestion > 0 ? correctAnswers / currentQuestion : 0.0; + // Adjust padding based on mode + final horizontalPadding = mode == ProgressIndicatorMode.mini ? 12.w : 16.w; + final verticalPadding = mode == ProgressIndicatorMode.mini ? 4.h : (mode == ProgressIndicatorMode.compact ? 6.h : 8.h); + final progressBarHeight = mode == ProgressIndicatorMode.mini ? 4.h : 6.h; + final showStats = mode == ProgressIndicatorMode.full; + final showPercentage = mode != ProgressIndicatorMode.mini; + return Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16.r), @@ -37,7 +59,7 @@ class GameProgressIndicator extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Progress bar + // Progress row with question number Row( children: [ Text( @@ -45,30 +67,34 @@ class GameProgressIndicator extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, + fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null, ), ), Text( ' / $totalQuestions', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: mode == ProgressIndicatorMode.mini ? 14.sp : null, ), ), - const Spacer(), - Text( - '${(progress * 100).round()}%', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + if (showPercentage) ...[ + const Spacer(), + Text( + '${(progress * 100).round()}%', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ], ), - SizedBox(height: 4.h), + SizedBox(height: mode == ProgressIndicatorMode.mini ? 4.h : 4.h), // Progress bar Container( - height: 6.h, + height: progressBarHeight, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(3.r), @@ -90,35 +116,36 @@ class GameProgressIndicator extends StatelessWidget { ), ), - SizedBox(height: 8.h), - - // Stats row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildStatItem( - context: context, - icon: Icons.check_circle, - value: '$correctAnswers', - label: 'Correct', - color: Colors.green, - ), - _buildStatItem( - context: context, - icon: Icons.schedule, - value: _formatDuration(timeElapsed), - label: 'Time', - color: Theme.of(context).colorScheme.primary, - ), - _buildStatItem( - context: context, - icon: Icons.trending_up, - value: '${(accuracy * 100).round()}%', - label: 'Accuracy', - color: accuracy >= 0.8 ? Colors.green : accuracy >= 0.6 ? Colors.orange : Colors.red, - ), - ], - ), + // Stats row (only in full mode) + if (showStats) ...[ + SizedBox(height: 8.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatItem( + context: context, + icon: Icons.check_circle, + value: '$correctAnswers', + label: 'Correct', + color: Colors.green, + ), + _buildStatItem( + context: context, + icon: Icons.schedule, + value: _formatDuration(timeElapsed), + label: 'Time', + color: Theme.of(context).colorScheme.primary, + ), + _buildStatItem( + context: context, + icon: Icons.trending_up, + value: '${(accuracy * 100).round()}%', + label: 'Accuracy', + color: accuracy >= 0.8 ? Colors.green : accuracy >= 0.6 ? Colors.orange : Colors.red, + ), + ], + ), + ], ], ), ); diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart b/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart index 071193d..5354ca0 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/game/question_display.dart @@ -10,11 +10,13 @@ class QuestionDisplay extends StatelessWidget { const QuestionDisplay({ required this.question, this.onPlayAudio, + this.maxHeight, super.key, }); final GameQuestion question; final QuestionAudioPlayback? onPlayAudio; + final double? maxHeight; @override Widget build(BuildContext context) { @@ -69,62 +71,93 @@ class QuestionDisplay extends StatelessWidget { String? audio, }) { 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( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Image display - if (image != null) ...[ - Container( - constraints: BoxConstraints( - maxHeight: 200.h, - maxWidth: double.infinity, + return ConstrainedBox( + constraints: maxHeight != null + ? BoxConstraints(maxHeight: maxHeight!) + : const BoxConstraints(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Image display + if (image != null) ...[ + Flexible( + child: Container( + constraints: BoxConstraints( + maxHeight: imageMaxHeight, + maxWidth: double.infinity, + ), + child: Image.network( + image, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120.h, + width: 120.w, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8.r), + ), + child: Icon( + Icons.image_not_supported, + size: 48.sp, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ), ), - child: Image.network( - image, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 120.h, - width: 120.w, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon( - Icons.image_not_supported, - size: 48.sp, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); - }, - ), - ), - SizedBox(height: 16.h), - ], + SizedBox(height: spacingAfterImage), + ], - // Text display - if (text.isNotEmpty) ...[ - Text( - text, - style: theme.headlineSmall?.copyWith( - fontSize: 20.sp, - height: 1.4, - fontWeight: FontWeight.w600, + // Text display + if (text.isNotEmpty) ...[ + Flexible( + child: Text( + text, + style: theme.headlineSmall?.copyWith( + fontSize: fontSize, + height: 1.4, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: maxLines, + overflow: TextOverflow.fade, + ), ), - textAlign: TextAlign.center, - ), - ], + ], - // Audio button - if (audio != null) ...[ - SizedBox(height: 12.h), - _QuestionAudioButton( - audioUrl: audio, - onPlayAudio: onPlayAudio, - ), + // Audio button + if (audio != null) ...[ + SizedBox(height: spacingAfterText), + _QuestionAudioButton( + audioUrl: audio, + onPlayAudio: onPlayAudio, + ), + ], ], - ], + ), ); } }