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