tests fix
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
This commit is contained in:
parent
5e3061165f
commit
f5bd53828c
22 changed files with 2394 additions and 642 deletions
13
PROGRESS.md
13
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*
|
||||
|
|
|
|||
8
TODO.md
8
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*
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -5,20 +5,59 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|||
import '../models/creation_test_data.dart';
|
||||
import 'question_generator.dart';
|
||||
|
||||
/// Generates a matrix-image question:
|
||||
/// - shows N x N images (cards from the pack pool)
|
||||
/// - shows a target word (original) under the matrix
|
||||
enum MatrixQuestionType {
|
||||
original_translation, // оригинал снизу, переведенные карточки
|
||||
original_images, // оригинал снизу, изображения на карточках
|
||||
translation_originals, // перевод снизу, карточки с оригиналом
|
||||
audio_images, // аудио снизу, карточки с изображениями
|
||||
audio_translation, // аудио снизу, карточки с переводами
|
||||
}
|
||||
|
||||
extension MatrixQuestionTypeExtension on MatrixQuestionType {
|
||||
bool get originalTarget => [
|
||||
MatrixQuestionType.original_translation,
|
||||
MatrixQuestionType.original_images,
|
||||
].contains(this);
|
||||
|
||||
bool get translationTarget => [
|
||||
MatrixQuestionType.translation_originals,
|
||||
].contains(this);
|
||||
|
||||
bool get audioTarget => [
|
||||
MatrixQuestionType.audio_images,
|
||||
MatrixQuestionType.audio_translation,
|
||||
].contains(this);
|
||||
|
||||
bool get translationCards => [
|
||||
MatrixQuestionType.original_translation,
|
||||
MatrixQuestionType.audio_translation,
|
||||
].contains(this);
|
||||
|
||||
bool get originalCards => [
|
||||
MatrixQuestionType.translation_originals,
|
||||
].contains(this);
|
||||
|
||||
bool get imageCards => [
|
||||
MatrixQuestionType.original_images,
|
||||
MatrixQuestionType.audio_images,
|
||||
].contains(this);
|
||||
}
|
||||
|
||||
/// Generates a matrix question:
|
||||
/// - shows N x N cards (from the pack pool)
|
||||
/// - shows a target word/audio under the matrix
|
||||
/// - user must click the matching card
|
||||
///
|
||||
/// The question is multi-step on the client side: after a correct click,
|
||||
/// the card disappears and a new target word is selected from remaining cards.
|
||||
/// Backend only provides the initial target via [word] + [answer].
|
||||
/// the card flips and a new target is shown from stages array.
|
||||
/// Backend generates all stages upfront.
|
||||
class MatrixQuestionGenerator implements QuestionGenerator {
|
||||
MatrixQuestionGenerator(
|
||||
this.data, {
|
||||
this.seed,
|
||||
this.allowedSizes = const [2, 3, 4],
|
||||
this.fixedMatrixSize,
|
||||
this.possibleTypes,
|
||||
}) : random = Random(seed);
|
||||
|
||||
final CreationTestData data;
|
||||
|
|
@ -32,11 +71,55 @@ class MatrixQuestionGenerator implements QuestionGenerator {
|
|||
/// items exist).
|
||||
final int? fixedMatrixSize;
|
||||
|
||||
/// Allowed question types. If null, all types are allowed.
|
||||
final Set<MatrixQuestionType>? possibleTypes;
|
||||
|
||||
// UUID regex pattern (same as in simple_question_generator.dart)
|
||||
static final _uuidRegex = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String? _imageIdToUrl(String? imageId) {
|
||||
if (imageId == null || data.packId == null) return imageId;
|
||||
return '/api/v2/packs/${data.packId}/cards/$imageId/image';
|
||||
}
|
||||
|
||||
/// Checks if a string is a valid UUID
|
||||
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
|
||||
|
||||
/// Gets audio UUID from answerCard.audio if it's a valid UUID, otherwise returns null
|
||||
String? _getAudioUuid(TestDataItem answerCard) {
|
||||
final audioValue = answerCard.audio;
|
||||
if (audioValue == null || audioValue.trim().isEmpty) return null;
|
||||
return _isUuid(audioValue) ? audioValue.trim() : null;
|
||||
}
|
||||
|
||||
MatrixQuestionType _getRandomQuestionType({
|
||||
required List<TestDataItem> selectedCards,
|
||||
}) {
|
||||
var types = (possibleTypes?.toList() ?? MatrixQuestionType.values);
|
||||
|
||||
// Check if we have audio available
|
||||
final hasAudio = selectedCards.any((card) => _getAudioUuid(card) != null);
|
||||
if (!hasAudio) {
|
||||
types = types.where((type) => !type.audioTarget).toList();
|
||||
}
|
||||
|
||||
// Check if we have images available
|
||||
final hasImages = selectedCards.any((card) => card.image != null);
|
||||
if (!hasImages) {
|
||||
types = types.where((type) => !type.imageCards).toList();
|
||||
}
|
||||
|
||||
// If no types available, fallback to original_translation
|
||||
if (types.isEmpty) {
|
||||
return MatrixQuestionType.original_translation;
|
||||
}
|
||||
|
||||
return types[random.nextInt(types.length)];
|
||||
}
|
||||
|
||||
int _maxPossibleSize(int poolSize) {
|
||||
if (poolSize <= 0) return 0;
|
||||
return sqrt(poolSize).floor();
|
||||
|
|
@ -57,6 +140,40 @@ class MatrixQuestionGenerator implements QuestionGenerator {
|
|||
return allowed[random.nextInt(allowed.length)];
|
||||
}
|
||||
|
||||
List<MatrixStageDto> _generateStages(
|
||||
List<TestDataItem> selectedCards,
|
||||
MatrixQuestionType questionType,
|
||||
) {
|
||||
final stages = <MatrixStageDto>[];
|
||||
final shuffled = List<TestDataItem>.from(selectedCards)..shuffle(random);
|
||||
|
||||
for (final card in shuffled) {
|
||||
String targetWord;
|
||||
String? targetAudio;
|
||||
|
||||
if (questionType.originalTarget) {
|
||||
targetWord = card.original;
|
||||
} else if (questionType.translationTarget) {
|
||||
targetWord = card.translation;
|
||||
} else if (questionType.audioTarget) {
|
||||
targetWord = card.original; // fallback text
|
||||
targetAudio = _getAudioUuid(card);
|
||||
} else {
|
||||
targetWord = card.original; // fallback
|
||||
}
|
||||
|
||||
stages.add(
|
||||
MatrixStageDto(
|
||||
targetCardId: card.id,
|
||||
targetWord: targetWord,
|
||||
targetAudio: targetAudio,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AbstractTestQuestion> generate(TestDataItem answerCard) async {
|
||||
final pool = data.items;
|
||||
|
|
@ -73,23 +190,58 @@ class MatrixQuestionGenerator implements QuestionGenerator {
|
|||
|
||||
final selected = <TestDataItem>[answerCard, ...others]..shuffle(random);
|
||||
|
||||
final cards = selected
|
||||
.map(
|
||||
(c) => MatrixCardDto(
|
||||
id: c.id,
|
||||
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
|
||||
original: c.original,
|
||||
translation: c.translation,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
// Determine question type based on available data
|
||||
final questionType = _getRandomQuestionType(selectedCards: selected);
|
||||
|
||||
// Generate stages for all cards
|
||||
final stages = _generateStages(selected, questionType);
|
||||
|
||||
// Create cards based on question type
|
||||
final cards = selected.map((c) {
|
||||
if (questionType.translationCards) {
|
||||
return MatrixCardDto(
|
||||
id: c.id,
|
||||
translation: c.translation,
|
||||
// Don't include image or original for translation cards
|
||||
);
|
||||
} else if (questionType.originalCards) {
|
||||
return MatrixCardDto(
|
||||
id: c.id,
|
||||
original: c.original,
|
||||
// Don't include image or translation for original cards
|
||||
);
|
||||
} else if (questionType.imageCards) {
|
||||
return MatrixCardDto(
|
||||
id: c.id,
|
||||
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
|
||||
// Don't include text for image cards
|
||||
);
|
||||
} else {
|
||||
// Fallback: include all fields
|
||||
return MatrixCardDto(
|
||||
id: c.id,
|
||||
image: _imageIdToUrl(c.image) ?? c.image ?? c.id,
|
||||
original: c.original,
|
||||
translation: c.translation,
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Get first stage for backward compatibility
|
||||
final firstStage = stages.isNotEmpty ? stages.first : MatrixStageDto(
|
||||
targetCardId: answerCard.id,
|
||||
targetWord: answerCard.original,
|
||||
targetAudio: null,
|
||||
);
|
||||
|
||||
return MatrixTestQuestionBody(
|
||||
matrixSize: matrixSize,
|
||||
cards: cards,
|
||||
// First step: target is the provided answerCard.
|
||||
word: answerCard.original,
|
||||
answer: answerCard.id,
|
||||
stages: stages,
|
||||
// Backward compatibility fields
|
||||
word: firstStage.targetWord,
|
||||
answer: firstStage.targetCardId,
|
||||
text: 'Найди слово:',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||
import 'package:mnemo_cards_backend/tests/generators/question_generators/simple_question_generator.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('SimpleQuestionGenerator - audio generation', () {
|
||||
test('generates audio question with UUID from MinIO', () async {
|
||||
final audioUuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: [
|
||||
TestDataItem(
|
||||
id: 'card_1',
|
||||
original: 'la Gama',
|
||||
translation: 'Лань',
|
||||
image: 'card_1',
|
||||
audio: audioUuid, // UUID from MinIO
|
||||
),
|
||||
TestDataItem(
|
||||
id: 'card_2',
|
||||
original: 'el Cerdo',
|
||||
translation: 'Свинья',
|
||||
image: 'card_2',
|
||||
audio: 'not-a-uuid', // Not a UUID, should be ignored
|
||||
),
|
||||
TestDataItem(
|
||||
id: 'card_3',
|
||||
original: 'la Cabra',
|
||||
translation: 'Коза',
|
||||
image: 'card_3',
|
||||
audio: null, // No audio
|
||||
),
|
||||
TestDataItem(
|
||||
id: 'card_4',
|
||||
original: 'el Pescado',
|
||||
translation: 'Рыба',
|
||||
image: 'card_4',
|
||||
audio: 'another-uuid-not-valid', // Not a valid UUID format
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final generator = SimpleQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.audio_translation,
|
||||
},
|
||||
);
|
||||
|
||||
final answerCard = data.items.first; // card_1 with UUID audio
|
||||
final q = await generator.generate(answerCard);
|
||||
|
||||
expect(q, isA<SimpleTestQuestionBody>());
|
||||
final sq = q as SimpleTestQuestionBody;
|
||||
|
||||
// For audio_translation type, audio should be the UUID
|
||||
expect(sq.audio, equals(audioUuid));
|
||||
expect(sq.text, isNull); // No text for audio questions
|
||||
expect(sq.word, equals('la Gama'));
|
||||
});
|
||||
|
||||
test('generates audio question with null when audio is not UUID', () async {
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: [
|
||||
TestDataItem(
|
||||
id: 'card_1',
|
||||
original: 'la Gama',
|
||||
translation: 'Лань',
|
||||
image: 'card_1',
|
||||
audio: 'es-ES_la Gama', // Old format, not UUID
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final generator = SimpleQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.audio_translation,
|
||||
},
|
||||
);
|
||||
|
||||
final answerCard = data.items.first;
|
||||
final q = await generator.generate(answerCard);
|
||||
|
||||
expect(q, isA<SimpleTestQuestionBody>());
|
||||
final sq = q as SimpleTestQuestionBody;
|
||||
|
||||
// Should be null since audio is not a valid UUID
|
||||
expect(sq.audio, isNull);
|
||||
});
|
||||
|
||||
test('generates audio question with null when audio is null', () async {
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: [
|
||||
TestDataItem(
|
||||
id: 'card_1',
|
||||
original: 'la Gama',
|
||||
translation: 'Лань',
|
||||
image: 'card_1',
|
||||
audio: null, // No audio
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final generator = SimpleQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.audio_translation,
|
||||
},
|
||||
);
|
||||
|
||||
final answerCard = data.items.first;
|
||||
final q = await generator.generate(answerCard);
|
||||
|
||||
expect(q, isA<SimpleTestQuestionBody>());
|
||||
final sq = q as SimpleTestQuestionBody;
|
||||
|
||||
// Should be null when audio is null
|
||||
expect(sq.audio, isNull);
|
||||
});
|
||||
|
||||
test('generates audio_images question with UUID', () async {
|
||||
final audioUuid = '660e8400-e29b-41d4-a716-446655440000';
|
||||
final data = CreationTestData(
|
||||
packId: 'p1',
|
||||
title: 'Pack',
|
||||
color: '#fff',
|
||||
items: [
|
||||
TestDataItem(
|
||||
id: 'card_1',
|
||||
original: 'la Gama',
|
||||
translation: 'Лань',
|
||||
image: 'card_1',
|
||||
audio: audioUuid,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final generator = SimpleQuestionGenerator(
|
||||
data,
|
||||
seed: 1,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.audio_images,
|
||||
},
|
||||
);
|
||||
|
||||
final answerCard = data.items.first;
|
||||
final q = await generator.generate(answerCard);
|
||||
|
||||
expect(q, isA<SimpleTestQuestionBody>());
|
||||
final sq = q as SimpleTestQuestionBody;
|
||||
|
||||
// For audio_images type, audio should be the UUID
|
||||
expect(sq.audio, equals(audioUuid));
|
||||
expect(sq.text, isNull); // No text for audio questions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -20,18 +20,29 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
|
|||
@JsonKey(name: 'buttons', defaultValue: <MatrixCardDto>[])
|
||||
final List<MatrixCardDto> cards;
|
||||
|
||||
/// Stages for multi-step question. Each stage defines the target word/audio
|
||||
/// and the correct card ID.
|
||||
@JsonKey(defaultValue: <MatrixStageDto>[])
|
||||
final List<MatrixStageDto> stages;
|
||||
|
||||
/// Initial correct answer (card id) for the first step.
|
||||
///
|
||||
/// Next steps are handled on the client side.
|
||||
/// DEPRECATED: Use stages[0].targetCardId instead.
|
||||
/// Kept for backward compatibility.
|
||||
@JsonKey(defaultValue: '')
|
||||
final String answer;
|
||||
|
||||
/// Question text (e.g., "Найди слово:")
|
||||
final String? text;
|
||||
|
||||
MatrixTestQuestionBody({
|
||||
super.id,
|
||||
required this.matrixSize,
|
||||
required this.cards,
|
||||
this.stages = const [],
|
||||
required this.answer,
|
||||
required super.word,
|
||||
this.text,
|
||||
super.questionType = TestQuestionType.matrix,
|
||||
});
|
||||
|
||||
|
|
@ -42,21 +53,40 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
|
|||
Map<String, Object?> toJson() => _$MatrixTestQuestionBodyToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@CopyWith()
|
||||
class MatrixStageDto {
|
||||
final String targetCardId;
|
||||
final String targetWord;
|
||||
final String? targetAudio;
|
||||
|
||||
const MatrixStageDto({
|
||||
required this.targetCardId,
|
||||
required this.targetWord,
|
||||
this.targetAudio,
|
||||
});
|
||||
|
||||
factory MatrixStageDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$MatrixStageDtoFromJson(json);
|
||||
|
||||
Map<String, Object?> toJson() => _$MatrixStageDtoToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@CopyWith()
|
||||
class MatrixCardDto {
|
||||
final String id;
|
||||
final String image; // Object ID in MinIO (for admin)
|
||||
final String? image; // Object ID in MinIO (for admin)
|
||||
final String? imageUrl; // Presigned URL (for display)
|
||||
final String original;
|
||||
final String translation;
|
||||
final String? original;
|
||||
final String? translation;
|
||||
|
||||
const MatrixCardDto({
|
||||
required this.id,
|
||||
required this.image,
|
||||
this.image,
|
||||
this.imageUrl,
|
||||
required this.original,
|
||||
required this.translation,
|
||||
this.original,
|
||||
this.translation,
|
||||
});
|
||||
|
||||
factory MatrixCardDto.fromJson(Map<String, dynamic> json) =>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
|
|||
|
||||
MatrixTestQuestionBody cards(List<MatrixCardDto> cards);
|
||||
|
||||
MatrixTestQuestionBody stages(List<MatrixStageDto> stages);
|
||||
|
||||
MatrixTestQuestionBody answer(String answer);
|
||||
|
||||
MatrixTestQuestionBody word(String word);
|
||||
|
||||
MatrixTestQuestionBody text(String? text);
|
||||
|
||||
MatrixTestQuestionBody questionType(TestQuestionType questionType);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
|
|
@ -30,8 +34,10 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
|
|||
String? id,
|
||||
int matrixSize,
|
||||
List<MatrixCardDto> cards,
|
||||
List<MatrixStageDto> stages,
|
||||
String answer,
|
||||
String word,
|
||||
String? text,
|
||||
TestQuestionType questionType,
|
||||
});
|
||||
}
|
||||
|
|
@ -54,12 +60,19 @@ class _$MatrixTestQuestionBodyCWProxyImpl
|
|||
@override
|
||||
MatrixTestQuestionBody cards(List<MatrixCardDto> cards) => call(cards: cards);
|
||||
|
||||
@override
|
||||
MatrixTestQuestionBody stages(List<MatrixStageDto> stages) =>
|
||||
call(stages: stages);
|
||||
|
||||
@override
|
||||
MatrixTestQuestionBody answer(String answer) => call(answer: answer);
|
||||
|
||||
@override
|
||||
MatrixTestQuestionBody word(String word) => call(word: word);
|
||||
|
||||
@override
|
||||
MatrixTestQuestionBody text(String? text) => call(text: text);
|
||||
|
||||
@override
|
||||
MatrixTestQuestionBody questionType(TestQuestionType questionType) =>
|
||||
call(questionType: questionType);
|
||||
|
|
@ -76,8 +89,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
|
|||
Object? id = const $CopyWithPlaceholder(),
|
||||
Object? matrixSize = const $CopyWithPlaceholder(),
|
||||
Object? cards = const $CopyWithPlaceholder(),
|
||||
Object? stages = const $CopyWithPlaceholder(),
|
||||
Object? answer = const $CopyWithPlaceholder(),
|
||||
Object? word = const $CopyWithPlaceholder(),
|
||||
Object? text = const $CopyWithPlaceholder(),
|
||||
Object? questionType = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return MatrixTestQuestionBody(
|
||||
|
|
@ -94,6 +109,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
|
|||
? _value.cards
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: cards as List<MatrixCardDto>,
|
||||
stages: stages == const $CopyWithPlaceholder() || stages == null
|
||||
? _value.stages
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: stages as List<MatrixStageDto>,
|
||||
answer: answer == const $CopyWithPlaceholder() || answer == null
|
||||
? _value.answer
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
|
|
@ -102,6 +121,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
|
|||
? _value.word
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: word as String,
|
||||
text: text == const $CopyWithPlaceholder()
|
||||
? _value.text
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: text as String?,
|
||||
questionType:
|
||||
questionType == const $CopyWithPlaceholder() || questionType == null
|
||||
? _value.questionType
|
||||
|
|
@ -119,16 +142,94 @@ extension $MatrixTestQuestionBodyCopyWith on MatrixTestQuestionBody {
|
|||
_$MatrixTestQuestionBodyCWProxyImpl(this);
|
||||
}
|
||||
|
||||
abstract class _$MatrixStageDtoCWProxy {
|
||||
MatrixStageDto targetCardId(String targetCardId);
|
||||
|
||||
MatrixStageDto targetWord(String targetWord);
|
||||
|
||||
MatrixStageDto targetAudio(String? targetAudio);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// MatrixStageDto(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
MatrixStageDto call({
|
||||
String targetCardId,
|
||||
String targetWord,
|
||||
String? targetAudio,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfMatrixStageDto.copyWith(...)` or call `instanceOfMatrixStageDto.copyWith.fieldName(value)` for a single field.
|
||||
class _$MatrixStageDtoCWProxyImpl implements _$MatrixStageDtoCWProxy {
|
||||
const _$MatrixStageDtoCWProxyImpl(this._value);
|
||||
|
||||
final MatrixStageDto _value;
|
||||
|
||||
@override
|
||||
MatrixStageDto targetCardId(String targetCardId) =>
|
||||
call(targetCardId: targetCardId);
|
||||
|
||||
@override
|
||||
MatrixStageDto targetWord(String targetWord) => call(targetWord: targetWord);
|
||||
|
||||
@override
|
||||
MatrixStageDto targetAudio(String? targetAudio) =>
|
||||
call(targetAudio: targetAudio);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixStageDto(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// MatrixStageDto(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
MatrixStageDto call({
|
||||
Object? targetCardId = const $CopyWithPlaceholder(),
|
||||
Object? targetWord = const $CopyWithPlaceholder(),
|
||||
Object? targetAudio = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return MatrixStageDto(
|
||||
targetCardId:
|
||||
targetCardId == const $CopyWithPlaceholder() || targetCardId == null
|
||||
? _value.targetCardId
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: targetCardId as String,
|
||||
targetWord:
|
||||
targetWord == const $CopyWithPlaceholder() || targetWord == null
|
||||
? _value.targetWord
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: targetWord as String,
|
||||
targetAudio: targetAudio == const $CopyWithPlaceholder()
|
||||
? _value.targetAudio
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: targetAudio as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $MatrixStageDtoCopyWith on MatrixStageDto {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfMatrixStageDto.copyWith(...)` or `instanceOfMatrixStageDto.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$MatrixStageDtoCWProxy get copyWith => _$MatrixStageDtoCWProxyImpl(this);
|
||||
}
|
||||
|
||||
abstract class _$MatrixCardDtoCWProxy {
|
||||
MatrixCardDto id(String id);
|
||||
|
||||
MatrixCardDto image(String image);
|
||||
MatrixCardDto image(String? image);
|
||||
|
||||
MatrixCardDto imageUrl(String? imageUrl);
|
||||
|
||||
MatrixCardDto original(String original);
|
||||
MatrixCardDto original(String? original);
|
||||
|
||||
MatrixCardDto translation(String translation);
|
||||
MatrixCardDto translation(String? translation);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `MatrixCardDto(...).copyWith.fieldName(value)`.
|
||||
|
|
@ -139,10 +240,10 @@ abstract class _$MatrixCardDtoCWProxy {
|
|||
/// ```
|
||||
MatrixCardDto call({
|
||||
String id,
|
||||
String image,
|
||||
String? image,
|
||||
String? imageUrl,
|
||||
String original,
|
||||
String translation,
|
||||
String? original,
|
||||
String? translation,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -157,16 +258,16 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
|
|||
MatrixCardDto id(String id) => call(id: id);
|
||||
|
||||
@override
|
||||
MatrixCardDto image(String image) => call(image: image);
|
||||
MatrixCardDto image(String? image) => call(image: image);
|
||||
|
||||
@override
|
||||
MatrixCardDto imageUrl(String? imageUrl) => call(imageUrl: imageUrl);
|
||||
|
||||
@override
|
||||
MatrixCardDto original(String original) => call(original: original);
|
||||
MatrixCardDto original(String? original) => call(original: original);
|
||||
|
||||
@override
|
||||
MatrixCardDto translation(String translation) =>
|
||||
MatrixCardDto translation(String? translation) =>
|
||||
call(translation: translation);
|
||||
|
||||
@override
|
||||
|
|
@ -189,23 +290,22 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
|
|||
? _value.id
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: id as String,
|
||||
image: image == const $CopyWithPlaceholder() || image == null
|
||||
image: image == const $CopyWithPlaceholder()
|
||||
? _value.image
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: image as String,
|
||||
: image as String?,
|
||||
imageUrl: imageUrl == const $CopyWithPlaceholder()
|
||||
? _value.imageUrl
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: imageUrl as String?,
|
||||
original: original == const $CopyWithPlaceholder() || original == null
|
||||
original: original == const $CopyWithPlaceholder()
|
||||
? _value.original
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: original as String,
|
||||
translation:
|
||||
translation == const $CopyWithPlaceholder() || translation == null
|
||||
: original as String?,
|
||||
translation: translation == const $CopyWithPlaceholder()
|
||||
? _value.translation
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: translation as String,
|
||||
: translation as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -231,8 +331,14 @@ MatrixTestQuestionBody _$MatrixTestQuestionBodyFromJson(
|
|||
?.map((e) => MatrixCardDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
stages:
|
||||
(json['stages'] as List<dynamic>?)
|
||||
?.map((e) => MatrixStageDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
answer: json['answer'] as String? ?? '',
|
||||
word: json['word'] as String,
|
||||
text: json['text'] as String?,
|
||||
questionType:
|
||||
$enumDecodeNullable(_$TestQuestionTypeEnumMap, json['questionType']) ??
|
||||
TestQuestionType.matrix,
|
||||
|
|
@ -246,7 +352,9 @@ Map<String, dynamic> _$MatrixTestQuestionBodyToJson(
|
|||
'word': instance.word,
|
||||
'matrixSize': instance.matrixSize,
|
||||
'buttons': instance.cards.map((e) => e.toJson()).toList(),
|
||||
'stages': instance.stages.map((e) => e.toJson()).toList(),
|
||||
'answer': instance.answer,
|
||||
'text': ?instance.text,
|
||||
};
|
||||
|
||||
const _$TestQuestionTypeEnumMap = {
|
||||
|
|
@ -257,13 +365,27 @@ const _$TestQuestionTypeEnumMap = {
|
|||
TestQuestionType.undefined: 'undefined',
|
||||
};
|
||||
|
||||
MatrixStageDto _$MatrixStageDtoFromJson(Map<String, dynamic> json) =>
|
||||
MatrixStageDto(
|
||||
targetCardId: json['targetCardId'] as String,
|
||||
targetWord: json['targetWord'] as String,
|
||||
targetAudio: json['targetAudio'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MatrixStageDtoToJson(MatrixStageDto instance) =>
|
||||
<String, dynamic>{
|
||||
'targetCardId': instance.targetCardId,
|
||||
'targetWord': instance.targetWord,
|
||||
'targetAudio': instance.targetAudio,
|
||||
};
|
||||
|
||||
MatrixCardDto _$MatrixCardDtoFromJson(Map<String, dynamic> json) =>
|
||||
MatrixCardDto(
|
||||
id: json['id'] as String,
|
||||
image: json['image'] as String,
|
||||
image: json['image'] as String?,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
original: json['original'] as String,
|
||||
translation: json['translation'] as String,
|
||||
original: json['original'] as String?,
|
||||
translation: json['translation'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) =>
|
||||
|
|
|
|||
|
|
@ -115,21 +115,24 @@ abstract class MatchPair with _$MatchPair {
|
|||
_$MatchPairFromJson(json);
|
||||
}
|
||||
|
||||
/// Matrix question - user selects a card from an image matrix
|
||||
/// Matrix question - user selects a card from a matrix
|
||||
///
|
||||
/// UI: a matrix of images (N x N) + current target word (original) under it.
|
||||
/// UI: a matrix of cards (N x N) + current target word/audio under it.
|
||||
/// The question is multi-step on a single screen: after each correct selection
|
||||
/// the chosen card flips (showing translation) and disappears, then a new
|
||||
/// target word is shown until all cards are removed.
|
||||
/// the chosen card flips and a new target is shown from stages array until
|
||||
/// all cards are processed.
|
||||
@freezed
|
||||
abstract class MatrixQuestion with _$MatrixQuestion {
|
||||
const factory MatrixQuestion({
|
||||
required String id,
|
||||
required int matrixSize,
|
||||
required List<MatrixCard> cards,
|
||||
required String initialTargetCardId,
|
||||
required String initialTargetWord,
|
||||
@Default([]) List<MatrixStage> stages,
|
||||
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
|
||||
@Deprecated('Use stages[0] instead') String? initialTargetCardId,
|
||||
@Deprecated('Use stages[0] instead') String? initialTargetWord,
|
||||
required String word,
|
||||
String? text,
|
||||
@Default('matrix') String type,
|
||||
}) = _MatrixQuestion;
|
||||
|
||||
|
|
@ -137,13 +140,25 @@ abstract class MatrixQuestion with _$MatrixQuestion {
|
|||
_$MatrixQuestionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MatrixStage with _$MatrixStage {
|
||||
const factory MatrixStage({
|
||||
required String targetCardId,
|
||||
required String targetWord,
|
||||
String? targetAudio,
|
||||
}) = _MatrixStage;
|
||||
|
||||
factory MatrixStage.fromJson(Map<String, dynamic> json) =>
|
||||
_$MatrixStageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MatrixCard with _$MatrixCard {
|
||||
const factory MatrixCard({
|
||||
required String id,
|
||||
required String image,
|
||||
required String original,
|
||||
required String translation,
|
||||
String? image,
|
||||
String? original,
|
||||
String? translation,
|
||||
}) = _MatrixCard;
|
||||
|
||||
factory MatrixCard.fromJson(Map<String, dynamic> json) =>
|
||||
|
|
|
|||
|
|
@ -2262,7 +2262,8 @@ as String,
|
|||
/// @nodoc
|
||||
mixin _$MatrixQuestion {
|
||||
|
||||
String get id; int get matrixSize; List<MatrixCard> get cards; String get initialTargetCardId; String get initialTargetWord; String get word; String get type;
|
||||
String get id; int get matrixSize; List<MatrixCard> get cards; List<MatrixStage> get stages;// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
|
||||
@Deprecated('Use stages[0] instead') String? get initialTargetCardId;@Deprecated('Use stages[0] instead') String? get initialTargetWord; String get word; String? get text; String get type;
|
||||
/// Create a copy of MatrixQuestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
|
@ -2275,16 +2276,16 @@ $MatrixQuestionCopyWith<MatrixQuestion> get copyWith => _$MatrixQuestionCopyWith
|
|||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&const DeepCollectionEquality().equals(other.stages, stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),initialTargetCardId,initialTargetWord,word,type);
|
||||
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),const DeepCollectionEquality().hash(stages),initialTargetCardId,initialTargetWord,word,text,type);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)';
|
||||
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2295,7 +2296,7 @@ abstract mixin class $MatrixQuestionCopyWith<$Res> {
|
|||
factory $MatrixQuestionCopyWith(MatrixQuestion value, $Res Function(MatrixQuestion) _then) = _$MatrixQuestionCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type
|
||||
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -2312,15 +2313,17 @@ class _$MatrixQuestionCopyWithImpl<$Res>
|
|||
|
||||
/// Create a copy of MatrixQuestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
|
||||
as int,cards: null == cards ? _self.cards : cards // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixCard>,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixCard>,stages: null == stages ? _self.stages : stages // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
|
||||
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
|
@ -2406,10 +2409,10 @@ return $default(_that);case _:
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixQuestion() when $default != null:
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
|
|
@ -2427,10 +2430,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixQuestion():
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
|
|
@ -2447,10 +2450,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages, @Deprecated('Use stages[0] instead') String? initialTargetCardId, @Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixQuestion() when $default != null:
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.type);case _:
|
||||
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
|
|
@ -2462,7 +2465,7 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.initialTargetCardId,
|
|||
@JsonSerializable()
|
||||
|
||||
class _MatrixQuestion implements MatrixQuestion {
|
||||
const _MatrixQuestion({required this.id, required this.matrixSize, required final List<MatrixCard> cards, required this.initialTargetCardId, required this.initialTargetWord, required this.word, this.type = 'matrix'}): _cards = cards;
|
||||
const _MatrixQuestion({required this.id, required this.matrixSize, required final List<MatrixCard> cards, final List<MatrixStage> stages = const [], @Deprecated('Use stages[0] instead') this.initialTargetCardId, @Deprecated('Use stages[0] instead') this.initialTargetWord, required this.word, this.text, this.type = 'matrix'}): _cards = cards,_stages = stages;
|
||||
factory _MatrixQuestion.fromJson(Map<String, dynamic> json) => _$MatrixQuestionFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
|
|
@ -2474,9 +2477,18 @@ class _MatrixQuestion implements MatrixQuestion {
|
|||
return EqualUnmodifiableListView(_cards);
|
||||
}
|
||||
|
||||
@override final String initialTargetCardId;
|
||||
@override final String initialTargetWord;
|
||||
final List<MatrixStage> _stages;
|
||||
@override@JsonKey() List<MatrixStage> get stages {
|
||||
if (_stages is EqualUnmodifiableListView) return _stages;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_stages);
|
||||
}
|
||||
|
||||
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
|
||||
@override@Deprecated('Use stages[0] instead') final String? initialTargetCardId;
|
||||
@override@Deprecated('Use stages[0] instead') final String? initialTargetWord;
|
||||
@override final String word;
|
||||
@override final String? text;
|
||||
@override@JsonKey() final String type;
|
||||
|
||||
/// Create a copy of MatrixQuestion
|
||||
|
|
@ -2492,16 +2504,16 @@ Map<String, dynamic> toJson() {
|
|||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&const DeepCollectionEquality().equals(other._stages, _stages)&&(identical(other.initialTargetCardId, initialTargetCardId) || other.initialTargetCardId == initialTargetCardId)&&(identical(other.initialTargetWord, initialTargetWord) || other.initialTargetWord == initialTargetWord)&&(identical(other.word, word) || other.word == word)&&(identical(other.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),initialTargetCardId,initialTargetWord,word,type);
|
||||
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),const DeepCollectionEquality().hash(_stages),initialTargetCardId,initialTargetWord,word,text,type);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, type: $type)';
|
||||
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2512,7 +2524,7 @@ abstract mixin class _$MatrixQuestionCopyWith<$Res> implements $MatrixQuestionCo
|
|||
factory _$MatrixQuestionCopyWith(_MatrixQuestion value, $Res Function(_MatrixQuestion) _then) = __$MatrixQuestionCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, int matrixSize, List<MatrixCard> cards, String initialTargetCardId, String initialTargetWord, String word, String type
|
||||
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, String type
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -2529,15 +2541,17 @@ class __$MatrixQuestionCopyWithImpl<$Res>
|
|||
|
||||
/// Create a copy of MatrixQuestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? initialTargetCardId = null,Object? initialTargetWord = null,Object? word = null,Object? type = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,Object? type = null,}) {
|
||||
return _then(_MatrixQuestion(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
|
||||
as int,cards: null == cards ? _self._cards : cards // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixCard>,initialTargetCardId: null == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String,initialTargetWord: null == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixCard>,stages: null == stages ? _self._stages : stages // ignore: cast_nullable_to_non_nullable
|
||||
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
|
||||
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
|
@ -2546,10 +2560,279 @@ as String,
|
|||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$MatrixStage {
|
||||
|
||||
String get targetCardId; String get targetWord; String? get targetAudio;
|
||||
/// Create a copy of MatrixStage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$MatrixStageCopyWith<MatrixStage> get copyWith => _$MatrixStageCopyWithImpl<MatrixStage>(this as MatrixStage, _$identity);
|
||||
|
||||
/// Serializes this MatrixStage to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixStage&&(identical(other.targetCardId, targetCardId) || other.targetCardId == targetCardId)&&(identical(other.targetWord, targetWord) || other.targetWord == targetWord)&&(identical(other.targetAudio, targetAudio) || other.targetAudio == targetAudio));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,targetCardId,targetWord,targetAudio);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MatrixStage(targetCardId: $targetCardId, targetWord: $targetWord, targetAudio: $targetAudio)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $MatrixStageCopyWith<$Res> {
|
||||
factory $MatrixStageCopyWith(MatrixStage value, $Res Function(MatrixStage) _then) = _$MatrixStageCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String targetCardId, String targetWord, String? targetAudio
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$MatrixStageCopyWithImpl<$Res>
|
||||
implements $MatrixStageCopyWith<$Res> {
|
||||
_$MatrixStageCopyWithImpl(this._self, this._then);
|
||||
|
||||
final MatrixStage _self;
|
||||
final $Res Function(MatrixStage) _then;
|
||||
|
||||
/// Create a copy of MatrixStage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? targetCardId = null,Object? targetWord = null,Object? targetAudio = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
targetCardId: null == targetCardId ? _self.targetCardId : targetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String,targetWord: null == targetWord ? _self.targetWord : targetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String,targetAudio: freezed == targetAudio ? _self.targetAudio : targetAudio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [MatrixStage].
|
||||
extension MatrixStagePatterns on MatrixStage {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _MatrixStage value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _MatrixStage value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _MatrixStage value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String targetCardId, String targetWord, String? targetAudio)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage() when $default != null:
|
||||
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String targetCardId, String targetWord, String? targetAudio) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage():
|
||||
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String targetCardId, String targetWord, String? targetAudio)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixStage() when $default != null:
|
||||
return $default(_that.targetCardId,_that.targetWord,_that.targetAudio);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _MatrixStage implements MatrixStage {
|
||||
const _MatrixStage({required this.targetCardId, required this.targetWord, this.targetAudio});
|
||||
factory _MatrixStage.fromJson(Map<String, dynamic> json) => _$MatrixStageFromJson(json);
|
||||
|
||||
@override final String targetCardId;
|
||||
@override final String targetWord;
|
||||
@override final String? targetAudio;
|
||||
|
||||
/// Create a copy of MatrixStage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$MatrixStageCopyWith<_MatrixStage> get copyWith => __$MatrixStageCopyWithImpl<_MatrixStage>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$MatrixStageToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixStage&&(identical(other.targetCardId, targetCardId) || other.targetCardId == targetCardId)&&(identical(other.targetWord, targetWord) || other.targetWord == targetWord)&&(identical(other.targetAudio, targetAudio) || other.targetAudio == targetAudio));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,targetCardId,targetWord,targetAudio);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MatrixStage(targetCardId: $targetCardId, targetWord: $targetWord, targetAudio: $targetAudio)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$MatrixStageCopyWith<$Res> implements $MatrixStageCopyWith<$Res> {
|
||||
factory _$MatrixStageCopyWith(_MatrixStage value, $Res Function(_MatrixStage) _then) = __$MatrixStageCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String targetCardId, String targetWord, String? targetAudio
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$MatrixStageCopyWithImpl<$Res>
|
||||
implements _$MatrixStageCopyWith<$Res> {
|
||||
__$MatrixStageCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _MatrixStage _self;
|
||||
final $Res Function(_MatrixStage) _then;
|
||||
|
||||
/// Create a copy of MatrixStage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? targetCardId = null,Object? targetWord = null,Object? targetAudio = freezed,}) {
|
||||
return _then(_MatrixStage(
|
||||
targetCardId: null == targetCardId ? _self.targetCardId : targetCardId // ignore: cast_nullable_to_non_nullable
|
||||
as String,targetWord: null == targetWord ? _self.targetWord : targetWord // ignore: cast_nullable_to_non_nullable
|
||||
as String,targetAudio: freezed == targetAudio ? _self.targetAudio : targetAudio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$MatrixCard {
|
||||
|
||||
String get id; String get image; String get original; String get translation;
|
||||
String get id; String? get image; String? get original; String? get translation;
|
||||
/// Create a copy of MatrixCard
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
|
@ -2582,7 +2865,7 @@ abstract mixin class $MatrixCardCopyWith<$Res> {
|
|||
factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String image, String original, String translation
|
||||
String id, String? image, String? original, String? translation
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -2599,13 +2882,13 @@ class _$MatrixCardCopyWithImpl<$Res>
|
|||
|
||||
/// Create a copy of MatrixCard
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
|
||||
as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
|
||||
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -2690,7 +2973,7 @@ return $default(_that);case _:
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String image, String original, String translation)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? image, String? original, String? translation)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixCard() when $default != null:
|
||||
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
||||
|
|
@ -2711,7 +2994,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String image, String original, String translation) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? image, String? original, String? translation) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixCard():
|
||||
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
||||
|
|
@ -2731,7 +3014,7 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
|||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String image, String original, String translation)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? image, String? original, String? translation)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _MatrixCard() when $default != null:
|
||||
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
||||
|
|
@ -2746,13 +3029,13 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
|
|||
@JsonSerializable()
|
||||
|
||||
class _MatrixCard implements MatrixCard {
|
||||
const _MatrixCard({required this.id, required this.image, required this.original, required this.translation});
|
||||
const _MatrixCard({required this.id, this.image, this.original, this.translation});
|
||||
factory _MatrixCard.fromJson(Map<String, dynamic> json) => _$MatrixCardFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String image;
|
||||
@override final String original;
|
||||
@override final String translation;
|
||||
@override final String? image;
|
||||
@override final String? original;
|
||||
@override final String? translation;
|
||||
|
||||
/// Create a copy of MatrixCard
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
|
@ -2787,7 +3070,7 @@ abstract mixin class _$MatrixCardCopyWith<$Res> implements $MatrixCardCopyWith<$
|
|||
factory _$MatrixCardCopyWith(_MatrixCard value, $Res Function(_MatrixCard) _then) = __$MatrixCardCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String image, String original, String translation
|
||||
String id, String? image, String? original, String? translation
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -2804,13 +3087,13 @@ class __$MatrixCardCopyWithImpl<$Res>
|
|||
|
||||
/// Create a copy of MatrixCard
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = null,Object? original = null,Object? translation = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
|
||||
return _then(_MatrixCard(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String,original: null == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
|
||||
as String,translation: null == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
|
||||
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -196,9 +196,15 @@ _MatrixQuestion _$MatrixQuestionFromJson(Map<String, dynamic> json) =>
|
|||
cards: (json['cards'] as List<dynamic>)
|
||||
.map((e) => MatrixCard.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
initialTargetCardId: json['initialTargetCardId'] as String,
|
||||
initialTargetWord: json['initialTargetWord'] as String,
|
||||
stages:
|
||||
(json['stages'] as List<dynamic>?)
|
||||
?.map((e) => MatrixStage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
initialTargetCardId: json['initialTargetCardId'] as String?,
|
||||
initialTargetWord: json['initialTargetWord'] as String?,
|
||||
word: json['word'] as String,
|
||||
text: json['text'] as String?,
|
||||
type: json['type'] as String? ?? 'matrix',
|
||||
);
|
||||
|
||||
|
|
@ -207,17 +213,32 @@ Map<String, dynamic> _$MatrixQuestionToJson(_MatrixQuestion instance) =>
|
|||
'id': instance.id,
|
||||
'matrixSize': instance.matrixSize,
|
||||
'cards': instance.cards,
|
||||
'stages': instance.stages,
|
||||
'initialTargetCardId': instance.initialTargetCardId,
|
||||
'initialTargetWord': instance.initialTargetWord,
|
||||
'word': instance.word,
|
||||
'text': instance.text,
|
||||
'type': instance.type,
|
||||
};
|
||||
|
||||
_MatrixStage _$MatrixStageFromJson(Map<String, dynamic> json) => _MatrixStage(
|
||||
targetCardId: json['targetCardId'] as String,
|
||||
targetWord: json['targetWord'] as String,
|
||||
targetAudio: json['targetAudio'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MatrixStageToJson(_MatrixStage instance) =>
|
||||
<String, dynamic>{
|
||||
'targetCardId': instance.targetCardId,
|
||||
'targetWord': instance.targetWord,
|
||||
'targetAudio': instance.targetAudio,
|
||||
};
|
||||
|
||||
_MatrixCard _$MatrixCardFromJson(Map<String, dynamic> json) => _MatrixCard(
|
||||
id: json['id'] as String,
|
||||
image: json['image'] as String,
|
||||
original: json['original'] as String,
|
||||
translation: json['translation'] as String,
|
||||
image: json['image'] as String?,
|
||||
original: json['original'] as String?,
|
||||
translation: json['translation'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MatrixCardToJson(_MatrixCard instance) =>
|
||||
|
|
|
|||
|
|
@ -290,11 +290,27 @@ class TestsStateManager extends StateManager<TestsState> {
|
|||
});
|
||||
|
||||
/// Complete the game session
|
||||
Future<void> completeGameSession() => handle((emit) async {
|
||||
Future<void> completeGameSession() {
|
||||
log('completeGameSession method invoked', name: 'TestsStateManager');
|
||||
return handle((emit) async {
|
||||
log('completeGameSession handle callback started', name: 'TestsStateManager');
|
||||
final currentState = state;
|
||||
if (currentState is! _GameSessionActive || _currentTest == null) return;
|
||||
log('Current state type: ${currentState.runtimeType}', name: 'TestsStateManager');
|
||||
log('Current test: ${_currentTest?.id}', name: 'TestsStateManager');
|
||||
|
||||
if (currentState is! _GameSessionActive) {
|
||||
log('State is not GameSessionActive, returning', name: 'TestsStateManager');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentTest == null) {
|
||||
log('Current test is null, returning', name: 'TestsStateManager');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log('Processing completion...', name: 'TestsStateManager');
|
||||
|
||||
// Check if current question has been answered
|
||||
final currentQuestion = currentState.questions[currentState.currentQuestionIndex];
|
||||
final questionId = _getQuestionId(currentQuestion);
|
||||
|
|
@ -309,11 +325,15 @@ class TestsStateManager extends StateManager<TestsState> {
|
|||
_gameSessionManager.submitAnswer(questionId, currentQuestion, '');
|
||||
}
|
||||
|
||||
log('Calling gameSessionManager.completeSession', name: 'TestsStateManager');
|
||||
final result = _gameSessionManager.completeSession(_currentTest!.id!);
|
||||
log('completeSession returned result', name: 'TestsStateManager');
|
||||
|
||||
// Play completion sound
|
||||
log('Playing completion sound', name: 'TestsStateManager');
|
||||
await _gameSoundService.playGameComplete();
|
||||
|
||||
log('Emitting gameSessionCompleted state', name: 'TestsStateManager');
|
||||
emit(TestsState.gameSessionCompleted(
|
||||
test: _currentTest!,
|
||||
result: result,
|
||||
|
|
@ -331,6 +351,7 @@ class TestsStateManager extends StateManager<TestsState> {
|
|||
emit(TestsState.error('Failed to complete session: ${e.toString()}'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset game session
|
||||
Future<void> resetGameSession() => handle((emit) async {
|
||||
|
|
@ -434,7 +455,7 @@ class TestsStateManager extends StateManager<TestsState> {
|
|||
questions.add(GameQuestion.multipleChoice(
|
||||
MultipleChoiceQuestion(
|
||||
id: 'q_${questions.length}',
|
||||
question: question.w ?? '',
|
||||
question: question.text ?? '',
|
||||
image: question.imageUrl ?? question.image, // Use presigned URL if available
|
||||
audio: question.audio,
|
||||
options: options, // Keep for backward compatibility
|
||||
|
|
@ -479,15 +500,36 @@ class TestsStateManager extends StateManager<TestsState> {
|
|||
)
|
||||
.toList();
|
||||
|
||||
// Convert stages from DTO to domain model
|
||||
final stages = question.stages
|
||||
.map(
|
||||
(s) => MatrixStage(
|
||||
targetCardId: s.targetCardId,
|
||||
targetWord: s.targetWord,
|
||||
targetAudio: s.targetAudio,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Use stages if available, otherwise fallback to deprecated fields
|
||||
final initialTargetCardId = stages.isNotEmpty
|
||||
? stages.first.targetCardId
|
||||
: question.answer;
|
||||
final initialTargetWord = stages.isNotEmpty
|
||||
? stages.first.targetWord
|
||||
: question.word;
|
||||
|
||||
questions.add(
|
||||
GameQuestion.matrix(
|
||||
MatrixQuestion(
|
||||
id: id,
|
||||
matrixSize: question.matrixSize,
|
||||
cards: cards,
|
||||
initialTargetCardId: question.answer,
|
||||
initialTargetWord: question.word,
|
||||
stages: stages,
|
||||
initialTargetCardId: initialTargetCardId,
|
||||
initialTargetWord: initialTargetWord,
|
||||
word: question.word,
|
||||
text: question.text,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../../../domain/state/tests_state_manager.dart';
|
|||
import '../../../presentation/widgets/error_view.dart';
|
||||
import '../../../presentation/widgets/game/answer_options.dart';
|
||||
import '../../../presentation/widgets/game/input_letters_widget.dart';
|
||||
import '../../../presentation/widgets/game/match_widget.dart';
|
||||
import '../../../presentation/widgets/game/matrix_widget.dart';
|
||||
import '../../../presentation/widgets/game/progress_indicator.dart';
|
||||
import '../../../presentation/widgets/game/question_display.dart';
|
||||
|
|
@ -133,6 +134,7 @@ class _GamePageState extends State<GamePage> {
|
|||
}
|
||||
|
||||
Widget _buildBody(TestsState state) {
|
||||
log('_buildBody called with state: ${state.runtimeType}', name: 'GamePage');
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
final sessionElapsed = userScope?.testsModule.gameSessionManager.sessionElapsed ?? Duration.zero;
|
||||
|
|
@ -229,6 +231,34 @@ class _GamePageState extends State<GamePage> {
|
|||
builder: (context, constraints) {
|
||||
final isNarrow = constraints.maxWidth < 720;
|
||||
final contentWidth = isNarrow ? constraints.maxWidth : _maxContentWidth;
|
||||
final availableHeight = constraints.maxHeight;
|
||||
|
||||
// Determine progress indicator mode based on available height
|
||||
final progressMode = availableHeight > 800
|
||||
? ProgressIndicatorMode.full
|
||||
: availableHeight > 600
|
||||
? ProgressIndicatorMode.compact
|
||||
: ProgressIndicatorMode.mini;
|
||||
|
||||
// Calculate spacing based on available height
|
||||
final spacingAfterProgress = availableHeight > 700 ? 12.h : 8.h;
|
||||
final spacingAfterQuestion = availableHeight > 700 ? 16.h : 12.h;
|
||||
final spacingAfterAnswer = availableHeight > 700 ? 16.h : 12.h;
|
||||
|
||||
// Calculate max heights for question and answer sections
|
||||
// Reserve space: progress (40-100px), navigation buttons (56px), spacing
|
||||
final reservedHeight = progressMode == ProgressIndicatorMode.full
|
||||
? 120.h
|
||||
: progressMode == ProgressIndicatorMode.compact
|
||||
? 80.h
|
||||
: 60.h;
|
||||
final navigationHeight = (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ? 72.h : 0;
|
||||
final totalSpacing = spacingAfterProgress + spacingAfterQuestion + spacingAfterAnswer;
|
||||
final availableForContent = availableHeight - reservedHeight - navigationHeight - totalSpacing;
|
||||
|
||||
// Distribute: 40% question, 60% answers (adjustable)
|
||||
final questionMaxHeight = availableForContent * 0.4;
|
||||
final answerMaxHeight = availableForContent * 0.6;
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
|
|
@ -236,100 +266,119 @@ class _GamePageState extends State<GamePage> {
|
|||
child: Column(
|
||||
children: [
|
||||
// Progress indicator
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GameProgressIndicator(
|
||||
currentQuestion: currentQuestionIndex,
|
||||
totalQuestions: questions.length,
|
||||
correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
|
||||
timeElapsed: sessionElapsed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
|
||||
child: GameProgressIndicator(
|
||||
currentQuestion: currentQuestionIndex,
|
||||
totalQuestions: questions.length,
|
||||
correctAnswers: questionResults.values.where((r) => r.isCorrect).length,
|
||||
timeElapsed: sessionElapsed,
|
||||
mode: progressMode,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: spacingAfterProgress),
|
||||
|
||||
// Question content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(isNarrow ? 12.w : 16.w),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 12.w : 16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Question display
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: KeyedSubtree(
|
||||
key: questionKey,
|
||||
child: Material(
|
||||
key: GamePage.questionCardKey,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: colorScheme.surfaceTint,
|
||||
elevation: 3,
|
||||
shadowColor: theme.shadowColor.withOpacity(
|
||||
theme.brightness == Brightness.dark ? 0.35 : 0.14,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.r),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: questionMaxHeight),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: KeyedSubtree(
|
||||
key: questionKey,
|
||||
child: Material(
|
||||
key: GamePage.questionCardKey,
|
||||
color: colorScheme.surface,
|
||||
surfaceTintColor: colorScheme.surfaceTint,
|
||||
elevation: 3,
|
||||
shadowColor: theme.shadowColor.withOpacity(
|
||||
theme.brightness == Brightness.dark ? 0.35 : 0.14,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.r),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
|
||||
child: currentQuestion is GameQuestionMatrix
|
||||
? MatrixWidget(
|
||||
question: currentQuestion.question,
|
||||
maxHeight: questionMaxHeight,
|
||||
)
|
||||
: QuestionDisplay(
|
||||
question: currentQuestion,
|
||||
onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio,
|
||||
maxHeight: questionMaxHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isNarrow ? 14.w : 18.w),
|
||||
child: currentQuestion is GameQuestionMatrix
|
||||
? MatrixWidget(question: currentQuestion.question)
|
||||
: QuestionDisplay(
|
||||
question: currentQuestion,
|
||||
onPlayAudio: widget.questionAudioPlayback ?? _playQuestionAudio,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: isNarrow ? 16.h : 20.h),
|
||||
SizedBox(height: spacingAfterQuestion),
|
||||
|
||||
// Answer input based on question type with smooth transitions
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: answerMaxHeight),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.05, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey('question_input_${currentQuestion.hashCode}'),
|
||||
child: currentQuestion.when(
|
||||
multipleChoice: (q) => AnswerOptions(
|
||||
question: q,
|
||||
selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults),
|
||||
onAnswerSelected: _onAnswerSelected,
|
||||
isAnswerSubmitted: isAnswerSubmitted,
|
||||
isCorrect: isCorrect,
|
||||
maxHeight: answerMaxHeight,
|
||||
),
|
||||
inputLetters: (q) => InputLettersWidget(
|
||||
question: q,
|
||||
maxHeight: answerMaxHeight,
|
||||
),
|
||||
match: (q) => MatchWidget(
|
||||
question: q,
|
||||
maxHeight: answerMaxHeight,
|
||||
),
|
||||
matrix: (q) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey('question_input_${currentQuestion.hashCode}'),
|
||||
child: currentQuestion.when(
|
||||
multipleChoice: (q) => AnswerOptions(
|
||||
question: q,
|
||||
selectedAnswer: _getSelectedAnswerForMultipleChoice(q, questionResults),
|
||||
onAnswerSelected: _onAnswerSelected,
|
||||
isAnswerSubmitted: isAnswerSubmitted,
|
||||
isCorrect: isCorrect,
|
||||
),
|
||||
inputLetters: (q) => InputLettersWidget(question: q),
|
||||
match: (q) => const Center(child: Text('Match questions coming soon!')),
|
||||
matrix: (q) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation buttons (show when navigation is possible)
|
||||
if (_canGoPrevious(state) || _canGoNext(state) || _isLastQuestion(state)) ...[
|
||||
SizedBox(height: isNarrow ? 18.h : 24.h),
|
||||
SizedBox(height: spacingAfterAnswer),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -342,10 +391,18 @@ class _GamePageState extends State<GamePage> {
|
|||
SizedBox(width: isNarrow ? 12.w : 16.w),
|
||||
],
|
||||
if (_isLastQuestion(state)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _finishGame,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Finish'),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
log('Finish button is being rendered', name: 'GamePage');
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
log('Finish button onPressed triggered', name: 'GamePage');
|
||||
_finishGame();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Finish'),
|
||||
);
|
||||
},
|
||||
),
|
||||
] else if (_canGoNext(state)) ...[
|
||||
ElevatedButton.icon(
|
||||
|
|
@ -546,11 +603,18 @@ class _GamePageState extends State<GamePage> {
|
|||
Future<void> _finishGame() async {
|
||||
log('Finish button pressed', name: 'GamePage');
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||
log('AppScope: ${appScope != null ? 'available' : 'null'}', name: 'GamePage');
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
log('UserScope: ${userScope != null ? 'available' : 'null'}', name: 'GamePage');
|
||||
|
||||
if (userScope != null) {
|
||||
log('Completing game session...', name: 'GamePage');
|
||||
await userScope.testsModule.testsStateManager.completeGameSession();
|
||||
log('Game session completion called', name: 'GamePage');
|
||||
log('Calling completeGameSession...', name: 'GamePage');
|
||||
try {
|
||||
await userScope.testsModule.testsStateManager.completeGameSession();
|
||||
log('Game session completion finished successfully', name: 'GamePage');
|
||||
} catch (e, s) {
|
||||
log('Error during completeGameSession', error: e, stackTrace: s, name: 'GamePage');
|
||||
}
|
||||
} else {
|
||||
log('No user scope available when finishing game', name: 'GamePage');
|
||||
}
|
||||
|
|
@ -635,20 +699,28 @@ class _GamePageState extends State<GamePage> {
|
|||
bool _canGoNext(TestsState state) {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
return userScope?.testsModule.testsStateManager.canGoNext ?? false;
|
||||
final canNext = userScope?.testsModule.testsStateManager.canGoNext ?? false;
|
||||
log('Can go next: $canNext', name: 'GamePage');
|
||||
return canNext;
|
||||
}
|
||||
|
||||
bool _canGoPrevious(TestsState state) {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
return userScope?.testsModule.testsStateManager.canGoPrevious ?? false;
|
||||
final canPrev = userScope?.testsModule.testsStateManager.canGoPrevious ?? false;
|
||||
log('Can go previous: $canPrev', name: 'GamePage');
|
||||
return canPrev;
|
||||
}
|
||||
|
||||
bool _isLastQuestion(TestsState state) {
|
||||
return state.maybeWhen(
|
||||
gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) =>
|
||||
currentQuestionIndex >= questions.length - 1,
|
||||
final result = state.maybeWhen(
|
||||
gameSessionActive: (test, questions, currentQuestionIndex, _, __, ___, ____, _____) {
|
||||
final isLast = currentQuestionIndex >= questions.length - 1;
|
||||
log('Is last question: $isLast (index: $currentQuestionIndex, total: ${questions.length})', name: 'GamePage');
|
||||
return isLast;
|
||||
},
|
||||
orElse: () => false,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -443,7 +443,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
children: [
|
||||
// Модуль "проверка знаний" для мобильных
|
||||
// Hide tests section for mobile devices for now
|
||||
// _buildMobileTestsSection(packColor),
|
||||
_buildMobileTestsSection(packColor),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -457,9 +457,11 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
// ignore: unused_element
|
||||
Widget _buildMobileTestsSection(Color packColor) {
|
||||
final tests = _getTests();
|
||||
|
||||
if (tests.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart';
|
|||
class InputLettersWidget extends StatefulWidget {
|
||||
const InputLettersWidget({
|
||||
required this.question,
|
||||
this.maxHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final InputLettersQuestion question;
|
||||
final double? maxHeight;
|
||||
|
||||
@override
|
||||
State<InputLettersWidget> createState() => _InputLettersWidgetState();
|
||||
|
|
@ -73,101 +75,123 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Adapt spacing and padding based on maxHeight
|
||||
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
|
||||
final containerPadding = isCompact ? 12.w : 16.w;
|
||||
final containerMargin = isCompact ? 12.h : 16.h;
|
||||
final spacingAfterTitle = isCompact ? 8.h : 12.h;
|
||||
final spacingAfterWord = isCompact ? 8.h : 12.h;
|
||||
final spacingAfterGrid = isCompact ? 12.h : 16.h;
|
||||
final showFillInstruction = !isCompact; // Hide on very compact screens
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Display the template with current input
|
||||
Container(
|
||||
padding: EdgeInsets.all(24.w),
|
||||
margin: EdgeInsets.only(bottom: 24.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
return ConstrainedBox(
|
||||
constraints: widget.maxHeight != null
|
||||
? BoxConstraints(maxHeight: widget.maxHeight!)
|
||||
: const BoxConstraints(),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Display the template with current input
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(containerPadding),
|
||||
margin: EdgeInsets.only(bottom: containerMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Fill in the blanks:',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showFillInstruction)
|
||||
Text(
|
||||
'Fill in the blanks:',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (showFillInstruction && widget.question.word.isNotEmpty)
|
||||
SizedBox(height: spacingAfterTitle),
|
||||
if (widget.question.word.isNotEmpty) ...[
|
||||
Text(
|
||||
widget.question.word,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
SizedBox(height: spacingAfterWord),
|
||||
_buildTemplateDisplay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Buttons with images or text (if available)
|
||||
if (widget.question.buttons.isNotEmpty) ...[
|
||||
Flexible(
|
||||
child: _buildButtonsGrid(constraints),
|
||||
),
|
||||
SizedBox(height: spacingAfterGrid),
|
||||
] else ...[
|
||||
// Input field (only if no buttons)
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.8,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type the missing letters...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: widget.question.correctAnswer.length,
|
||||
onSubmitted: _submitAnswer,
|
||||
),
|
||||
if (widget.question.word.isNotEmpty) ...[
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
widget.question.word,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
SizedBox(height: 16.h),
|
||||
_buildTemplateDisplay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: spacingAfterGrid),
|
||||
],
|
||||
|
||||
// Buttons with images or text (if available)
|
||||
if (widget.question.buttons.isNotEmpty) ...[
|
||||
_buildButtonsGrid(constraints),
|
||||
SizedBox(height: 24.h),
|
||||
] else ...[
|
||||
// Input field (only if no buttons)
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.8,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type the missing letters...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: widget.question.correctAnswer.length,
|
||||
onSubmitted: _submitAnswer,
|
||||
// Submit button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('Submit Answer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(200.w, 48.h),
|
||||
textStyle: TextStyle(fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
|
||||
// Submit button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _currentAnswer.isNotEmpty ? _submitAnswer : null,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('Submit Answer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(200.w, 48.h),
|
||||
textStyle: TextStyle(fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -187,6 +211,13 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
List<Widget> _buildTemplateParts(String template, String currentInput) {
|
||||
final parts = <Widget>[];
|
||||
int inputIndex = 0;
|
||||
|
||||
// Adapt sizes based on maxHeight
|
||||
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
|
||||
final cellWidth = isCompact ? 24.w : 28.w;
|
||||
final cellHeight = isCompact ? 36.h : 48.h;
|
||||
final cellFontSize = isCompact ? 16.sp : 20.sp;
|
||||
final blankCellWidth = isCompact ? 28.w : 32.w;
|
||||
|
||||
for (int i = 0; i < template.length; i++) {
|
||||
final char = template[i];
|
||||
|
|
@ -198,8 +229,8 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
|
||||
parts.add(
|
||||
Container(
|
||||
width: 32.w,
|
||||
height: 48.h,
|
||||
width: blankCellWidth,
|
||||
height: cellHeight,
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.w),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
|
|
@ -215,7 +246,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
child: Text(
|
||||
letter.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontSize: cellFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
|
|
@ -232,14 +263,14 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
// This is a regular character
|
||||
parts.add(
|
||||
Container(
|
||||
width: 28.w,
|
||||
height: 48.h,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.w),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
char,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontSize: cellFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
|
|
@ -256,15 +287,19 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
|
|||
Widget _buildButtonsGrid(BoxConstraints constraints) {
|
||||
final hasImages = widget.question.buttons.any((b) => b.image != null);
|
||||
final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3;
|
||||
final childAspectRatio = hasImages ? 1.2 : 2.0;
|
||||
final isCompact = widget.maxHeight != null && widget.maxHeight! < 400;
|
||||
final childAspectRatio = hasImages
|
||||
? (isCompact ? 1.0 : 1.2)
|
||||
: (isCompact ? 2.0 : 2.0);
|
||||
final spacing = isCompact ? 8.0 : 12.0;
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
childAspectRatio: childAspectRatio,
|
||||
),
|
||||
itemCount: widget.question.buttons.length,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import '../../../domain/models/game_question.dart';
|
|||
class MatchWidget extends StatefulWidget {
|
||||
const MatchWidget({
|
||||
required this.question,
|
||||
this.maxHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MatchQuestion question;
|
||||
final double? maxHeight;
|
||||
|
||||
@override
|
||||
State<MatchWidget> createState() => _MatchWidgetState();
|
||||
|
|
@ -25,118 +27,146 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Adapt spacing and padding based on maxHeight
|
||||
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
|
||||
final instructionPadding = isCompact ? 12.w : 16.w;
|
||||
final instructionMargin = isCompact ? 12.h : 16.h;
|
||||
final connectionPadding = isCompact ? 12.w : 16.w;
|
||||
final connectionMargin = isCompact ? 12.h : 16.h;
|
||||
final spacingAfterColumns = isCompact ? 12.h : 16.h;
|
||||
final instructionText = isCompact
|
||||
? 'Connect matching items'
|
||||
: 'Connect the matching items by tapping them in order';
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWideScreen = constraints.maxWidth > 600;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Instructions
|
||||
Container(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
margin: EdgeInsets.only(bottom: 24.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'Connect the matching items by tapping them in order',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Connection display
|
||||
if (_connections.isNotEmpty) ...[
|
||||
return ConstrainedBox(
|
||||
constraints: widget.maxHeight != null
|
||||
? BoxConstraints(maxHeight: widget.maxHeight!)
|
||||
: const BoxConstraints(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Instructions
|
||||
Container(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
margin: EdgeInsets.only(bottom: 16.h),
|
||||
padding: EdgeInsets.all(instructionPadding),
|
||||
margin: EdgeInsets.only(bottom: instructionMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Connections:',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
..._buildConnectionDisplay(),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
instructionText,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Connection display
|
||||
if (_connections.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.all(connectionPadding),
|
||||
margin: EdgeInsets.only(bottom: connectionMargin),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Connected: ${_connections.length}/${widget.question.correctPairs.length}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (!isCompact) ...[
|
||||
SizedBox(height: 8.h),
|
||||
..._buildConnectionDisplay(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Two columns layout
|
||||
Flexible(
|
||||
child: isWideScreen
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
|
||||
SizedBox(width: isCompact ? 16.w : 24.w),
|
||||
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Left Column',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: isCompact ? 8.h : 12.h),
|
||||
Flexible(child: _buildColumn(widget.question.leftItems, isLeft: true)),
|
||||
SizedBox(height: isCompact ? 16.h : 24.h),
|
||||
Text(
|
||||
'Right Column',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: isCompact ? 8.h : 12.h),
|
||||
Flexible(child: _buildColumn(widget.question.rightItems, isLeft: false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: spacingAfterColumns),
|
||||
|
||||
// Submit button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _canSubmit ? _submitAnswer : null,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('Submit Answer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(200.w, 48.h),
|
||||
textStyle: TextStyle(fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Two columns layout
|
||||
Expanded(
|
||||
child: isWideScreen
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
|
||||
SizedBox(width: 24.w),
|
||||
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Left Column',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Expanded(child: _buildColumn(widget.question.leftItems, isLeft: true)),
|
||||
SizedBox(height: 24.h),
|
||||
Text(
|
||||
'Right Column',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Expanded(child: _buildColumn(widget.question.rightItems, isLeft: false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Submit button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _canSubmit ? _submitAnswer : null,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('Submit Answer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(200.w, 48.h),
|
||||
textStyle: TextStyle(fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColumn(List<MatchItem> items, {required bool isLeft}) {
|
||||
final isCompact = widget.maxHeight != null && widget.maxHeight! < 500;
|
||||
final itemMargin = isCompact ? 4.h : 6.h;
|
||||
final itemPadding = isCompact ? 8.w : 12.w;
|
||||
final imageSize = isCompact ? 32.0 : 40.0;
|
||||
final fontSize = isCompact ? 14.sp : 16.sp;
|
||||
final iconSize = isCompact ? 18.sp : 20.sp;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemExtent: isCompact ? 52.0 : 64.0, // Fixed item height for better performance
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isSelected = isLeft
|
||||
|
|
@ -147,7 +177,7 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
: _connections.containsValue(item.id);
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 8.h),
|
||||
margin: EdgeInsets.only(bottom: itemMargin),
|
||||
child: Material(
|
||||
color: isConnected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
|
||||
|
|
@ -159,7 +189,7 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
onTap: isConnected ? null : () => _onItemTap(item.id, isLeft),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
padding: EdgeInsets.all(itemPadding),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isConnected
|
||||
|
|
@ -175,8 +205,8 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
children: [
|
||||
if (item.image != null) ...[
|
||||
Container(
|
||||
width: 40.w,
|
||||
height: 40.h,
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
image: DecorationImage(
|
||||
|
|
@ -185,13 +215,13 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(width: isCompact ? 8.w : 12.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.text,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontSize: fontSize,
|
||||
fontWeight: isConnected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isConnected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
|
|
@ -204,7 +234,7 @@ class _MatchWidgetState extends State<MatchWidget> {
|
|||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20.sp,
|
||||
size: iconSize,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
|
|
@ -12,19 +14,21 @@ import '../../../domain/models/matrix_image_select_answer.dart';
|
|||
///
|
||||
/// Shows a N x N matrix of images, and the current target word (original)
|
||||
/// under the matrix. User clicks an image to answer:
|
||||
/// - correct: card flips (translation on the back) and disappears; target word updates
|
||||
/// - correct: card flips (showing original and translation on the back) and stays flipped; target word updates
|
||||
/// - wrong: card shakes + wrong sound; test continues
|
||||
class MatrixWidget extends StatefulWidget {
|
||||
const MatrixWidget({
|
||||
required this.question,
|
||||
this.onWrongAttempt,
|
||||
this.onCompleted,
|
||||
this.maxHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MatrixQuestion question;
|
||||
final Future<void> Function()? onWrongAttempt;
|
||||
final Future<void> Function(MatrixImageSelectAnswer answer)? onCompleted;
|
||||
final double? maxHeight;
|
||||
|
||||
@override
|
||||
State<MatrixWidget> createState() => _MatrixWidgetState();
|
||||
|
|
@ -35,10 +39,8 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
static const _flipDuration = Duration(milliseconds: 450);
|
||||
static const _afterFlipHold = Duration(milliseconds: 650);
|
||||
|
||||
late math.Random _random;
|
||||
late List<MatrixCard?> _slots; // stable positions; null == removed
|
||||
late String _targetCardId;
|
||||
late String _targetWord;
|
||||
var _currentStageIndex = 0;
|
||||
|
||||
final _flipped = <String>{};
|
||||
final _correctIdsInOrder = <String>[];
|
||||
|
|
@ -48,6 +50,9 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
String? _shakingCardId;
|
||||
late final AnimationController _shakeController;
|
||||
late final Animation<double> _shakeOffset;
|
||||
|
||||
AudioPlayer? _audioPlayer;
|
||||
bool _isPlayingAudio = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -76,23 +81,78 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
}
|
||||
|
||||
void _initFromQuestion(MatrixQuestion q) {
|
||||
_random = math.Random(q.id.hashCode ^ q.matrixSize);
|
||||
_slots = q.cards.map<MatrixCard?>((c) => c).toList(growable: false);
|
||||
_flipped.clear();
|
||||
_correctIdsInOrder.clear();
|
||||
_wrongAttempts = 0;
|
||||
_isCompleting = false;
|
||||
_shakingCardId = null;
|
||||
_targetCardId = q.initialTargetCardId;
|
||||
_targetWord = q.initialTargetWord;
|
||||
_currentStageIndex = 0;
|
||||
|
||||
// Play audio for first stage if available
|
||||
if (q.stages.isNotEmpty) {
|
||||
_playStageAudio(q.stages[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
_audioPlayer?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _playStageAudio(MatrixStage stage) async {
|
||||
if (stage.targetAudio == null || stage.targetAudio!.isEmpty) return;
|
||||
|
||||
_audioPlayer ??= AudioPlayer();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Get appScope before async operations
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
|
||||
if (appScope == null) {
|
||||
debugPrint('MatrixWidget: Scope not available');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isPlayingAudio = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await _audioPlayer!.stop();
|
||||
|
||||
final audioValue = stage.targetAudio!;
|
||||
Uint8List bytes;
|
||||
|
||||
// Check if it's already a presigned URL (contains query params)
|
||||
final uri = Uri.tryParse(audioValue);
|
||||
if (uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.queryParameters.isNotEmpty) {
|
||||
// Likely a presigned URL, download directly
|
||||
bytes = await appScope.httpRepository.downloadBytesFromUrl(audioValue);
|
||||
} else {
|
||||
// Assume it's a UUID, use voice API endpoint
|
||||
bytes = await appScope.httpRepository.getVoiceFileBytes(audioValue);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await _audioPlayer!.play(BytesSource(bytes));
|
||||
} catch (e) {
|
||||
debugPrint('MatrixWidget: Failed to play audio: $e');
|
||||
// Could show error to user here if needed
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayingAudio = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -110,78 +170,199 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
...List<MatrixCard?>.filled(total - _slots.length, null),
|
||||
];
|
||||
|
||||
// Calculate available height for grid (reserve space for target container)
|
||||
// Target container: ~100-120px (compact) to 140-160px (full)
|
||||
final targetContainerHeight = widget.maxHeight != null && widget.maxHeight! < 350
|
||||
? 100.0
|
||||
: 140.0;
|
||||
final spacingBetween = widget.maxHeight != null && widget.maxHeight! < 350
|
||||
? 12.h
|
||||
: 16.h;
|
||||
|
||||
final gridMaxHeight = widget.maxHeight != null
|
||||
? widget.maxHeight! - targetContainerHeight - spacingBetween
|
||||
: null;
|
||||
|
||||
// Adapt spacing for grid cells
|
||||
final cellSpacing = widget.maxHeight != null && widget.maxHeight! < 350
|
||||
? 6.0
|
||||
: 10.0;
|
||||
|
||||
// Adapt padding for target container
|
||||
final targetPaddingH = widget.maxHeight != null && widget.maxHeight! < 350
|
||||
? 16.w
|
||||
: 20.w;
|
||||
final targetPaddingV = widget.maxHeight != null && widget.maxHeight! < 350
|
||||
? 12.h
|
||||
: 16.h;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: widget.maxHeight != null
|
||||
? BoxConstraints(maxHeight: widget.maxHeight!)
|
||||
: const BoxConstraints(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: gridMaxHeight != null
|
||||
? BoxConstraints(maxHeight: gridMaxHeight)
|
||||
: const BoxConstraints(),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size,
|
||||
crossAxisSpacing: cellSpacing,
|
||||
mainAxisSpacing: cellSpacing,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: total,
|
||||
itemBuilder: (context, index) {
|
||||
final card = slots[index];
|
||||
return _buildSlot(context, card);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: spacingBetween),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: targetPaddingH,
|
||||
vertical: targetPaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.question.text != null && widget.question.text!.isNotEmpty)
|
||||
Text(
|
||||
widget.question.text!,
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.question.text != null && widget.question.text!.isNotEmpty)
|
||||
SizedBox(height: 8.h),
|
||||
_buildTargetContent(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTargetContent(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final currentStage = _getCurrentStage();
|
||||
if (currentStage == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: size,
|
||||
crossAxisSpacing: 10.w,
|
||||
mainAxisSpacing: 10.h,
|
||||
childAspectRatio: 1.0,
|
||||
// Audio button if audio is available
|
||||
if (currentStage.targetAudio != null && currentStage.targetAudio!.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: IconButton(
|
||||
icon: _isPlayingAudio
|
||||
? SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.h,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.volume_up,
|
||||
size: 32.sp,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
onPressed: _isPlayingAudio
|
||||
? null
|
||||
: () => _playStageAudio(currentStage),
|
||||
),
|
||||
),
|
||||
itemCount: total,
|
||||
itemBuilder: (context, index) {
|
||||
final card = slots[index];
|
||||
return _buildSlot(context, card);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Найди слово:',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SelectableText(
|
||||
_targetWord,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
// Target word
|
||||
SelectableText(
|
||||
currentStage.targetWord,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
MatrixStage? _getCurrentStage() {
|
||||
final stages = widget.question.stages;
|
||||
if (stages.isEmpty) {
|
||||
// Fallback to deprecated fields
|
||||
if (widget.question.initialTargetCardId != null &&
|
||||
widget.question.initialTargetWord != null) {
|
||||
return MatrixStage(
|
||||
targetCardId: widget.question.initialTargetCardId!,
|
||||
targetWord: widget.question.initialTargetWord!,
|
||||
targetAudio: null,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_currentStageIndex >= stages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stages[_currentStageIndex];
|
||||
}
|
||||
|
||||
Widget _buildSlot(BuildContext context, MatrixCard? card) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isFlipped = card != null && _flipped.contains(card.id);
|
||||
final isShaking = card != null && _shakingCardId == card.id;
|
||||
if (card == null) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isFlipped = _flipped.contains(card.id);
|
||||
final isShaking = _shakingCardId == card.id;
|
||||
|
||||
final base = AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: card == null
|
||||
? DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.7),
|
||||
),
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.15),
|
||||
),
|
||||
)
|
||||
: _MatrixFlipCard(
|
||||
card: card,
|
||||
isFlipped: isFlipped,
|
||||
onTap: _isCompleting ? null : () => _onCardTap(card),
|
||||
),
|
||||
child: _MatrixFlipCard(
|
||||
card: card,
|
||||
isFlipped: isFlipped,
|
||||
onTap: _isCompleting || isFlipped ? null : () => _onCardTap(card),
|
||||
),
|
||||
);
|
||||
|
||||
if (!isShaking) return base;
|
||||
|
|
@ -200,9 +381,12 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
|
||||
Future<void> _onCardTap(MatrixCard card) async {
|
||||
if (_isCompleting) return;
|
||||
if (_flipped.contains(card.id)) return;
|
||||
if (_flipped.contains(card.id)) return; // Already flipped, ignore tap
|
||||
|
||||
if (card.id == _targetCardId) {
|
||||
final currentStage = _getCurrentStage();
|
||||
if (currentStage == null) return;
|
||||
|
||||
if (card.id == currentStage.targetCardId) {
|
||||
await _handleCorrect(card);
|
||||
} else {
|
||||
await _handleWrong(card);
|
||||
|
|
@ -234,25 +418,27 @@ class _MatrixWidgetState extends State<MatrixWidget>
|
|||
|
||||
await Future<void>.delayed(_flipDuration + _afterFlipHold);
|
||||
|
||||
// Remove from matrix (keep slot stable -> set to null)
|
||||
final idx = _slots.indexWhere((c) => c?.id == card.id);
|
||||
if (idx >= 0) {
|
||||
setState(() {
|
||||
_slots[idx] = null;
|
||||
});
|
||||
}
|
||||
|
||||
final remaining = _slots.whereType<MatrixCard>().toList(growable: false);
|
||||
if (remaining.isEmpty) {
|
||||
// Check if all cards are flipped or all stages completed
|
||||
final allFlipped = _slots
|
||||
.whereType<MatrixCard>()
|
||||
.every((c) => _flipped.contains(c.id));
|
||||
final allStagesCompleted = _currentStageIndex >= widget.question.stages.length - 1;
|
||||
|
||||
if (allFlipped || allStagesCompleted) {
|
||||
await _completeQuestion();
|
||||
return;
|
||||
}
|
||||
|
||||
final next = remaining[_random.nextInt(remaining.length)];
|
||||
// Move to next stage
|
||||
setState(() {
|
||||
_targetCardId = next.id;
|
||||
_targetWord = next.original;
|
||||
_currentStageIndex++;
|
||||
});
|
||||
|
||||
// Play audio for next stage if available
|
||||
final nextStage = _getCurrentStage();
|
||||
if (nextStage != null) {
|
||||
_playStageAudio(nextStage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completeQuestion() async {
|
||||
|
|
@ -291,11 +477,10 @@ class _MatrixFlipCard extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(
|
||||
begin: 0,
|
||||
|
|
@ -312,46 +497,66 @@ class _MatrixFlipCard extends StatelessWidget {
|
|||
return Transform(
|
||||
transform: transform,
|
||||
alignment: Alignment.center,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(21.r),
|
||||
child: isBack
|
||||
? Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()..rotateY(math.pi),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10.w),
|
||||
child: Text(
|
||||
card.translation,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
padding: EdgeInsets.all(24.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (card.original != null)
|
||||
Text(
|
||||
card.original!,
|
||||
style: TextStyle(
|
||||
fontSize: 28.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (card.original != null && card.translation != null)
|
||||
SizedBox(height: 12.h),
|
||||
if (card.translation != null)
|
||||
Text(
|
||||
card.translation!,
|
||||
style: TextStyle(
|
||||
fontSize: 22.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
card.image, // Already contains presigned URL or objectId from tests_state_manager
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
: _buildCardFront(card, colorScheme),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -359,4 +564,123 @@ class _MatrixFlipCard extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardFront(MatrixCard card, ColorScheme colorScheme) {
|
||||
final hasImage = card.image != null && card.image!.isNotEmpty;
|
||||
final hasOriginal = card.original != null && card.original!.isNotEmpty;
|
||||
final hasTranslation = card.translation != null && card.translation!.isNotEmpty;
|
||||
final hasText = hasOriginal || hasTranslation;
|
||||
|
||||
// If only text (no image), show text centered and large
|
||||
if (!hasImage && hasText) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (hasOriginal)
|
||||
Text(
|
||||
card.original!,
|
||||
style: TextStyle(
|
||||
fontSize: 28.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasOriginal && hasTranslation) SizedBox(height: 8.h),
|
||||
if (hasTranslation)
|
||||
Text(
|
||||
card.translation!,
|
||||
style: TextStyle(
|
||||
fontSize: 22.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If only image (no text), show image full size
|
||||
if (hasImage && !hasText) {
|
||||
return Image.network(
|
||||
card.image!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// If both image and text, show text on top and image below
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasText)
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 8.h),
|
||||
child: Column(
|
||||
children: [
|
||||
if (hasOriginal)
|
||||
Text(
|
||||
card.original!,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasOriginal && hasTranslation) SizedBox(height: 4.h),
|
||||
if (hasTranslation)
|
||||
Text(
|
||||
card.translation!,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasImage)
|
||||
Expanded(
|
||||
child: Image.network(
|
||||
card.image!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue