stuff
Some checks failed
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
Backend CI / test (push) Has been cancelled
Mobile App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Backend CI / build (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled

This commit is contained in:
Dmitry 2026-01-10 21:48:31 +03:00
parent a71f265a8e
commit cb781741f3
13 changed files with 622 additions and 211 deletions

View file

@ -194,19 +194,31 @@ class MatrixQuestionGenerator implements QuestionGenerator {
// Generate stages for all cards
final stages = _generateStages(selected, questionType);
// Create cards - always include original and translation for back side display
// Create cards with explicit front and back side fields
// Front side will show only relevant fields based on question type,
// but back side needs both original and translation
// Back side always shows original and translation
final cards = selected.map((c) {
// Always include original and translation for back side
// Include image if available (for image-based question types)
final frontImage = questionType.imageCards
? (_imageIdToUrl(c.image) ?? c.image ?? c.id)
: null;
final frontImageUrl = frontImage != null && _isUuid(frontImage)
? null
: frontImage;
return MatrixCardDto(
id: c.id,
image: questionType.imageCards
? (_imageIdToUrl(c.image) ?? c.image ?? c.id)
: null,
// Front side fields
image: frontImage != null && _isUuid(frontImage) ? frontImage : null,
imageUrl: frontImageUrl,
original: c.original,
translation: c.translation,
// Back side fields - always include original and translation
originalBack: c.original,
translationBack: c.translation,
imageBack: c.image != null && _isUuid(c.image!) ? c.image : null,
imageBackUrl: c.image != null && !_isUuid(c.image!)
? c.image
: (c.image != null ? _imageIdToUrl(c.image) : null),
);
}).toList();
@ -225,7 +237,8 @@ class MatrixQuestionGenerator implements QuestionGenerator {
stages: stages,
// Backward compatibility fields
word: firstStage.targetWord,
answer: firstStage.targetCardId,
answer: firstStage
.targetCardId, // Опциональное, но заполняем для совместимости
text: 'Найди слово:',
);
}

View file

@ -484,10 +484,14 @@ class TestManager {
.map(
(c) => <String, dynamic>{
'id': c.id,
// store image as objectId (MinIO) or filename
// Front side fields
'image': c.image,
'original': c.original,
'translation': c.translation,
// Back side fields - always include original and translation
'originalBack': c.original,
'translationBack': c.translation,
'imageBack': c.image,
},
)
.toList();
@ -503,16 +507,56 @@ class TestManager {
questionJson['matrixSize'] = resolvedSize;
} else if ((questionJson['answer'] as String?)?.isEmpty != false) {
// If matrix cards exist but answer is missing, derive initial target
// from the first card.
final first = currentButtons.firstOrNull;
if (first is Map<String, dynamic>) {
final id = first['id']?.toString();
final original = first['original']?.toString();
if (id != null && id.isNotEmpty) {
questionJson['answer'] = id;
// from the first card or from stages.
final stages = (uiDataForResponse['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
// Извлекаем из stages[0]
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
final targetCardId = firstStage['targetCardId']?.toString();
final targetWord = firstStage['targetWord']?.toString();
if (targetCardId != null && targetCardId.isNotEmpty) {
questionJson['answer'] = targetCardId;
}
if (targetWord != null && targetWord.isNotEmpty) {
questionJson['word'] = targetWord;
}
}
if (original != null && original.isNotEmpty) {
questionJson['word'] = original;
} else {
// Fallback: извлекаем из первого card
final first = currentButtons.firstOrNull;
if (first is Map<String, dynamic>) {
final id = first['id']?.toString();
final original = first['original']?.toString();
if (id != null && id.isNotEmpty) {
questionJson['answer'] = id;
}
if (original != null && original.isNotEmpty) {
questionJson['word'] = original;
}
}
}
}
// Также проверяем word и answer из stages, если они пусты из БД
if ((questionJson['word'] as String?)?.isEmpty != false ||
(questionJson['answer'] as String?)?.isEmpty != false) {
final stages = (uiDataForResponse['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
if ((questionJson['word'] as String?)?.isEmpty != false) {
final targetWord = firstStage['targetWord']?.toString();
if (targetWord != null && targetWord.isNotEmpty) {
questionJson['word'] = targetWord;
}
}
if ((questionJson['answer'] as String?)?.isEmpty != false) {
final targetCardId = firstStage['targetCardId']?.toString();
if (targetCardId != null && targetCardId.isNotEmpty) {
questionJson['answer'] = targetCardId;
}
}
}
}
}
@ -523,19 +567,33 @@ class TestManager {
// All images now use the same cardImagesBucket
final updatedButtons = await Future.wait(
(questionJson['buttons'] as List<dynamic>? ?? []).map((button) async {
if (button is Map<String, dynamic> && button['image'] != null) {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString();
// Convert image to presigned URL
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
// Convert front image to presigned URL
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image']?.toString();
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
}
}
// Convert back image to presigned URL (for matrix cards)
if (buttonMap['imageBack'] != null) {
final imageBackValue = buttonMap['imageBack']?.toString();
final imageBackUrl = await _imageValueToApiUrl(
imageBackValue,
packId: packId,
);
if (imageBackUrl != null) {
buttonMap['imageBackUrl'] = imageBackUrl;
}
}
return buttonMap;
}
return button;
@ -834,8 +892,8 @@ class TestManager {
final questionJson = question.toJson();
// Extract key fields
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
var word = questionJson['word'] as String? ?? '';
var answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// UI data (image, text, audio, template)
@ -852,6 +910,29 @@ class TestManager {
if (questionJson['stages'] != null)
uiData['stages'] = questionJson['stages'];
// Для матричных вопросов извлекаем word и answer из stages[0] если они пусты
if (question.questionType == TestQuestionType.matrix) {
final stages = (questionJson['stages'] as List<dynamic>?) ?? [];
if (stages.isNotEmpty) {
final firstStage = stages.first;
if (firstStage is Map<String, dynamic>) {
if (word.isEmpty) {
final targetWord = firstStage['targetWord']?.toString() ?? '';
if (targetWord.isNotEmpty) {
word = targetWord;
}
}
if (answer.isEmpty) {
final targetCardId =
firstStage['targetCardId']?.toString() ?? '';
if (targetCardId.isNotEmpty) {
answer = targetCardId;
}
}
}
}
}
final questionCompanion = TestQuestionsCompanion.insert(
testId: testId,
orderIndex: drift.Value(orderIndex++),

View file

@ -30,7 +30,7 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
/// DEPRECATED: Use stages[0].targetCardId instead.
/// Kept for backward compatibility.
@JsonKey(defaultValue: '')
final String answer;
final String? answer;
/// Question text (e.g., "Найди слово:")
final String? text;
@ -40,7 +40,7 @@ class MatrixTestQuestionBody extends AbstractTestQuestion {
required this.matrixSize,
required this.cards,
this.stages = const [],
required this.answer,
this.answer,
required super.word,
this.text,
super.questionType = TestQuestionType.matrix,
@ -80,6 +80,10 @@ class MatrixCardDto {
final String? imageUrl; // Presigned URL (for display)
final String? original;
final String? translation;
final String? originalBack; // Original on back side
final String? translationBack; // Translation on back side
final String? imageBack; // Object ID in MinIO for back side (for admin)
final String? imageBackUrl; // Presigned URL for back side (for display)
const MatrixCardDto({
required this.id,
@ -87,6 +91,10 @@ class MatrixCardDto {
this.imageUrl,
this.original,
this.translation,
this.originalBack,
this.translationBack,
this.imageBack,
this.imageBackUrl,
});
factory MatrixCardDto.fromJson(Map<String, dynamic> json) =>

View file

@ -15,7 +15,7 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
MatrixTestQuestionBody stages(List<MatrixStageDto> stages);
MatrixTestQuestionBody answer(String answer);
MatrixTestQuestionBody answer(String? answer);
MatrixTestQuestionBody word(String word);
@ -35,7 +35,7 @@ abstract class _$MatrixTestQuestionBodyCWProxy {
int matrixSize,
List<MatrixCardDto> cards,
List<MatrixStageDto> stages,
String answer,
String? answer,
String word,
String? text,
TestQuestionType questionType,
@ -65,7 +65,7 @@ class _$MatrixTestQuestionBodyCWProxyImpl
call(stages: stages);
@override
MatrixTestQuestionBody answer(String answer) => call(answer: answer);
MatrixTestQuestionBody answer(String? answer) => call(answer: answer);
@override
MatrixTestQuestionBody word(String word) => call(word: word);
@ -113,10 +113,10 @@ class _$MatrixTestQuestionBodyCWProxyImpl
? _value.stages
// ignore: cast_nullable_to_non_nullable
: stages as List<MatrixStageDto>,
answer: answer == const $CopyWithPlaceholder() || answer == null
answer: answer == const $CopyWithPlaceholder()
? _value.answer
// ignore: cast_nullable_to_non_nullable
: answer as String,
: answer as String?,
word: word == const $CopyWithPlaceholder() || word == null
? _value.word
// ignore: cast_nullable_to_non_nullable
@ -231,6 +231,14 @@ abstract class _$MatrixCardDtoCWProxy {
MatrixCardDto translation(String? translation);
MatrixCardDto originalBack(String? originalBack);
MatrixCardDto translationBack(String? translationBack);
MatrixCardDto imageBack(String? imageBack);
MatrixCardDto imageBackUrl(String? imageBackUrl);
/// 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)`.
///
@ -244,6 +252,10 @@ abstract class _$MatrixCardDtoCWProxy {
String? imageUrl,
String? original,
String? translation,
String? originalBack,
String? translationBack,
String? imageBack,
String? imageBackUrl,
});
}
@ -270,6 +282,21 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
MatrixCardDto translation(String? translation) =>
call(translation: translation);
@override
MatrixCardDto originalBack(String? originalBack) =>
call(originalBack: originalBack);
@override
MatrixCardDto translationBack(String? translationBack) =>
call(translationBack: translationBack);
@override
MatrixCardDto imageBack(String? imageBack) => call(imageBack: imageBack);
@override
MatrixCardDto imageBackUrl(String? imageBackUrl) =>
call(imageBackUrl: imageBackUrl);
@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 `MatrixCardDto(...).copyWith.fieldName(value)`.
@ -284,6 +311,10 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
Object? imageUrl = const $CopyWithPlaceholder(),
Object? original = const $CopyWithPlaceholder(),
Object? translation = const $CopyWithPlaceholder(),
Object? originalBack = const $CopyWithPlaceholder(),
Object? translationBack = const $CopyWithPlaceholder(),
Object? imageBack = const $CopyWithPlaceholder(),
Object? imageBackUrl = const $CopyWithPlaceholder(),
}) {
return MatrixCardDto(
id: id == const $CopyWithPlaceholder() || id == null
@ -306,6 +337,22 @@ class _$MatrixCardDtoCWProxyImpl implements _$MatrixCardDtoCWProxy {
? _value.translation
// ignore: cast_nullable_to_non_nullable
: translation as String?,
originalBack: originalBack == const $CopyWithPlaceholder()
? _value.originalBack
// ignore: cast_nullable_to_non_nullable
: originalBack as String?,
translationBack: translationBack == const $CopyWithPlaceholder()
? _value.translationBack
// ignore: cast_nullable_to_non_nullable
: translationBack as String?,
imageBack: imageBack == const $CopyWithPlaceholder()
? _value.imageBack
// ignore: cast_nullable_to_non_nullable
: imageBack as String?,
imageBackUrl: imageBackUrl == const $CopyWithPlaceholder()
? _value.imageBackUrl
// ignore: cast_nullable_to_non_nullable
: imageBackUrl as String?,
);
}
}
@ -353,7 +400,7 @@ Map<String, dynamic> _$MatrixTestQuestionBodyToJson(
'matrixSize': instance.matrixSize,
'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,
};
@ -386,6 +433,10 @@ MatrixCardDto _$MatrixCardDtoFromJson(Map<String, dynamic> json) =>
imageUrl: json['imageUrl'] as String?,
original: json['original'] as String?,
translation: json['translation'] as String?,
originalBack: json['originalBack'] as String?,
translationBack: json['translationBack'] as String?,
imageBack: json['imageBack'] as String?,
imageBackUrl: json['imageBackUrl'] as String?,
);
Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) =>
@ -395,4 +446,8 @@ Map<String, dynamic> _$MatrixCardDtoToJson(MatrixCardDto instance) =>
'imageUrl': instance.imageUrl,
'original': instance.original,
'translation': instance.translation,
'originalBack': instance.originalBack,
'translationBack': instance.translationBack,
'imageBack': instance.imageBack,
'imageBackUrl': instance.imageBackUrl,
};

View file

@ -121,6 +121,9 @@ abstract class MatchPair with _$MatchPair {
/// The question is multi-step on a single screen: after each correct selection
/// the chosen card flips and a new target is shown from stages array until
/// all cards are processed.
///
/// Note: Each stage has its own targetWord. For backward compatibility,
/// use stages[0].targetWord if a single word is needed.
@freezed
abstract class MatrixQuestion with _$MatrixQuestion {
const factory MatrixQuestion({
@ -131,7 +134,6 @@ abstract class MatrixQuestion with _$MatrixQuestion {
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
@Deprecated('Use stages[0] instead') String? initialTargetCardId,
@Deprecated('Use stages[0] instead') String? initialTargetWord,
required String word,
String? text,
@Default('matrix') String type,
}) = _MatrixQuestion;
@ -157,8 +159,13 @@ abstract class MatrixCard with _$MatrixCard {
const factory MatrixCard({
required String id,
String? image,
String? imageUrl,
String? original,
String? translation,
String? originalBack,
String? translationBack,
String? imageBack,
String? imageBackUrl,
}) = _MatrixCard;
factory MatrixCard.fromJson(Map<String, dynamic> json) =>

View file

@ -2268,7 +2268,7 @@ as String,
mixin _$MatrixQuestion {
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;
@Deprecated('Use stages[0] instead') String? get initialTargetCardId;@Deprecated('Use stages[0] instead') String? get initialTargetWord; String? get text; String get type;
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2281,16 +2281,16 @@ $MatrixQuestionCopyWith<MatrixQuestion> get copyWith => _$MatrixQuestionCopyWith
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other.cards, cards)&&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));
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.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),const DeepCollectionEquality().hash(stages),initialTargetCardId,initialTargetWord,word,text,type);
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(cards),const DeepCollectionEquality().hash(stages),initialTargetCardId,initialTargetWord,text,type);
@override
String toString() {
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, text: $text, type: $type)';
}
@ -2301,7 +2301,7 @@ abstract mixin class $MatrixQuestionCopyWith<$Res> {
factory $MatrixQuestionCopyWith(MatrixQuestion value, $Res Function(MatrixQuestion) _then) = _$MatrixQuestionCopyWithImpl;
@useResult
$Res call({
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, 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? text, String type
});
@ -2318,7 +2318,7 @@ class _$MatrixQuestionCopyWithImpl<$Res>
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,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? text = freezed,Object? type = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
@ -2326,8 +2326,7 @@ as int,cards: null == cards ? _self.cards : cards // ignore: cast_nullable_to_no
as List<MatrixCard>,stages: null == stages ? _self.stages : stages // ignore: cast_nullable_to_non_nullable
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,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,
));
@ -2414,10 +2413,10 @@ return $default(_that);case _:
/// }
/// ```
@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;
@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? text, String type)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MatrixQuestion() when $default != null:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.text,_that.type);case _:
return orElse();
}
@ -2435,10 +2434,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initial
/// }
/// ```
@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;
@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? text, String type) $default,) {final _that = this;
switch (_that) {
case _MatrixQuestion():
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.text,_that.type);case _:
throw StateError('Unexpected subclass');
}
@ -2455,10 +2454,10 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initial
/// }
/// ```
@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;
@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? text, String type)? $default,) {final _that = this;
switch (_that) {
case _MatrixQuestion() when $default != null:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.word,_that.text,_that.type);case _:
return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initialTargetCardId,_that.initialTargetWord,_that.text,_that.type);case _:
return null;
}
@ -2470,7 +2469,7 @@ return $default(_that.id,_that.matrixSize,_that.cards,_that.stages,_that.initial
@JsonSerializable()
class _MatrixQuestion implements MatrixQuestion {
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;
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, this.text, this.type = 'matrix'}): _cards = cards,_stages = stages;
factory _MatrixQuestion.fromJson(Map<String, dynamic> json) => _$MatrixQuestionFromJson(json);
@override final String id;
@ -2492,7 +2491,6 @@ class _MatrixQuestion implements MatrixQuestion {
// DEPRECATED: Use stages[0] instead. Kept for backward compatibility.
@override@Deprecated('Use stages[0] instead') final String? initialTargetCardId;
@override@Deprecated('Use stages[0] instead') final String? initialTargetWord;
@override final String word;
@override final String? text;
@override@JsonKey() final String type;
@ -2509,16 +2507,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.matrixSize, matrixSize) || other.matrixSize == matrixSize)&&const DeepCollectionEquality().equals(other._cards, _cards)&&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));
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.text, text) || other.text == text)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),const DeepCollectionEquality().hash(_stages),initialTargetCardId,initialTargetWord,word,text,type);
int get hashCode => Object.hash(runtimeType,id,matrixSize,const DeepCollectionEquality().hash(_cards),const DeepCollectionEquality().hash(_stages),initialTargetCardId,initialTargetWord,text,type);
@override
String toString() {
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, word: $word, text: $text, type: $type)';
return 'MatrixQuestion(id: $id, matrixSize: $matrixSize, cards: $cards, stages: $stages, initialTargetCardId: $initialTargetCardId, initialTargetWord: $initialTargetWord, text: $text, type: $type)';
}
@ -2529,7 +2527,7 @@ abstract mixin class _$MatrixQuestionCopyWith<$Res> implements $MatrixQuestionCo
factory _$MatrixQuestionCopyWith(_MatrixQuestion value, $Res Function(_MatrixQuestion) _then) = __$MatrixQuestionCopyWithImpl;
@override @useResult
$Res call({
String id, int matrixSize, List<MatrixCard> cards, List<MatrixStage> stages,@Deprecated('Use stages[0] instead') String? initialTargetCardId,@Deprecated('Use stages[0] instead') String? initialTargetWord, String word, String? text, 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? text, String type
});
@ -2546,7 +2544,7 @@ class __$MatrixQuestionCopyWithImpl<$Res>
/// Create a copy of MatrixQuestion
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? matrixSize = null,Object? cards = null,Object? stages = null,Object? initialTargetCardId = freezed,Object? initialTargetWord = freezed,Object? word = null,Object? text = freezed,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? text = freezed,Object? type = null,}) {
return _then(_MatrixQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,matrixSize: null == matrixSize ? _self.matrixSize : matrixSize // ignore: cast_nullable_to_non_nullable
@ -2554,8 +2552,7 @@ as int,cards: null == cards ? _self._cards : cards // ignore: cast_nullable_to_n
as List<MatrixCard>,stages: null == stages ? _self._stages : stages // ignore: cast_nullable_to_non_nullable
as List<MatrixStage>,initialTargetCardId: freezed == initialTargetCardId ? _self.initialTargetCardId : initialTargetCardId // ignore: cast_nullable_to_non_nullable
as String?,initialTargetWord: freezed == initialTargetWord ? _self.initialTargetWord : initialTargetWord // ignore: cast_nullable_to_non_nullable
as String?,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,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,
));
@ -2837,7 +2834,7 @@ as String?,
/// @nodoc
mixin _$MatrixCard {
String get id; String? get image; String? get original; String? get translation;
String get id; String? get image; String? get imageUrl; String? get original; String? get translation; String? get originalBack; String? get translationBack; String? get imageBack; String? get imageBackUrl;
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2850,16 +2847,16 @@ $MatrixCardCopyWith<MatrixCard> get copyWith => _$MatrixCardCopyWithImpl<MatrixC
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixCard&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.original, original) || other.original == original)&&(identical(other.translation, translation) || other.translation == translation));
return identical(this, other) || (other.runtimeType == runtimeType&&other is MatrixCard&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.original, original) || other.original == original)&&(identical(other.translation, translation) || other.translation == translation)&&(identical(other.originalBack, originalBack) || other.originalBack == originalBack)&&(identical(other.translationBack, translationBack) || other.translationBack == translationBack)&&(identical(other.imageBack, imageBack) || other.imageBack == imageBack)&&(identical(other.imageBackUrl, imageBackUrl) || other.imageBackUrl == imageBackUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,image,original,translation);
int get hashCode => Object.hash(runtimeType,id,image,imageUrl,original,translation,originalBack,translationBack,imageBack,imageBackUrl);
@override
String toString() {
return 'MatrixCard(id: $id, image: $image, original: $original, translation: $translation)';
return 'MatrixCard(id: $id, image: $image, imageUrl: $imageUrl, original: $original, translation: $translation, originalBack: $originalBack, translationBack: $translationBack, imageBack: $imageBack, imageBackUrl: $imageBackUrl)';
}
@ -2870,7 +2867,7 @@ abstract mixin class $MatrixCardCopyWith<$Res> {
factory $MatrixCardCopyWith(MatrixCard value, $Res Function(MatrixCard) _then) = _$MatrixCardCopyWithImpl;
@useResult
$Res call({
String id, String? image, String? original, String? translation
String id, String? image, String? imageUrl, String? original, String? translation, String? originalBack, String? translationBack, String? imageBack, String? imageBackUrl
});
@ -2887,12 +2884,17 @@ class _$MatrixCardCopyWithImpl<$Res>
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? image = freezed,Object? imageUrl = freezed,Object? original = freezed,Object? translation = freezed,Object? originalBack = freezed,Object? translationBack = freezed,Object? imageBack = freezed,Object? imageBackUrl = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String?,originalBack: freezed == originalBack ? _self.originalBack : originalBack // ignore: cast_nullable_to_non_nullable
as String?,translationBack: freezed == translationBack ? _self.translationBack : translationBack // ignore: cast_nullable_to_non_nullable
as String?,imageBack: freezed == imageBack ? _self.imageBack : imageBack // ignore: cast_nullable_to_non_nullable
as String?,imageBackUrl: freezed == imageBackUrl ? _self.imageBackUrl : imageBackUrl // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@ -2978,10 +2980,10 @@ 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? imageUrl, String? original, String? translation, String? originalBack, String? translationBack, String? imageBack, String? imageBackUrl)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
return $default(_that.id,_that.image,_that.imageUrl,_that.original,_that.translation,_that.originalBack,_that.translationBack,_that.imageBack,_that.imageBackUrl);case _:
return orElse();
}
@ -2999,10 +3001,10 @@ 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? imageUrl, String? original, String? translation, String? originalBack, String? translationBack, String? imageBack, String? imageBackUrl) $default,) {final _that = this;
switch (_that) {
case _MatrixCard():
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
return $default(_that.id,_that.image,_that.imageUrl,_that.original,_that.translation,_that.originalBack,_that.translationBack,_that.imageBack,_that.imageBackUrl);case _:
throw StateError('Unexpected subclass');
}
@ -3019,10 +3021,10 @@ 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? imageUrl, String? original, String? translation, String? originalBack, String? translationBack, String? imageBack, String? imageBackUrl)? $default,) {final _that = this;
switch (_that) {
case _MatrixCard() when $default != null:
return $default(_that.id,_that.image,_that.original,_that.translation);case _:
return $default(_that.id,_that.image,_that.imageUrl,_that.original,_that.translation,_that.originalBack,_that.translationBack,_that.imageBack,_that.imageBackUrl);case _:
return null;
}
@ -3034,13 +3036,18 @@ return $default(_that.id,_that.image,_that.original,_that.translation);case _:
@JsonSerializable()
class _MatrixCard implements MatrixCard {
const _MatrixCard({required this.id, this.image, this.original, this.translation});
const _MatrixCard({required this.id, this.image, this.imageUrl, this.original, this.translation, this.originalBack, this.translationBack, this.imageBack, this.imageBackUrl});
factory _MatrixCard.fromJson(Map<String, dynamic> json) => _$MatrixCardFromJson(json);
@override final String id;
@override final String? image;
@override final String? imageUrl;
@override final String? original;
@override final String? translation;
@override final String? originalBack;
@override final String? translationBack;
@override final String? imageBack;
@override final String? imageBackUrl;
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@ -3055,16 +3062,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixCard&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.original, original) || other.original == original)&&(identical(other.translation, translation) || other.translation == translation));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MatrixCard&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.original, original) || other.original == original)&&(identical(other.translation, translation) || other.translation == translation)&&(identical(other.originalBack, originalBack) || other.originalBack == originalBack)&&(identical(other.translationBack, translationBack) || other.translationBack == translationBack)&&(identical(other.imageBack, imageBack) || other.imageBack == imageBack)&&(identical(other.imageBackUrl, imageBackUrl) || other.imageBackUrl == imageBackUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,image,original,translation);
int get hashCode => Object.hash(runtimeType,id,image,imageUrl,original,translation,originalBack,translationBack,imageBack,imageBackUrl);
@override
String toString() {
return 'MatrixCard(id: $id, image: $image, original: $original, translation: $translation)';
return 'MatrixCard(id: $id, image: $image, imageUrl: $imageUrl, original: $original, translation: $translation, originalBack: $originalBack, translationBack: $translationBack, imageBack: $imageBack, imageBackUrl: $imageBackUrl)';
}
@ -3075,7 +3082,7 @@ abstract mixin class _$MatrixCardCopyWith<$Res> implements $MatrixCardCopyWith<$
factory _$MatrixCardCopyWith(_MatrixCard value, $Res Function(_MatrixCard) _then) = __$MatrixCardCopyWithImpl;
@override @useResult
$Res call({
String id, String? image, String? original, String? translation
String id, String? image, String? imageUrl, String? original, String? translation, String? originalBack, String? translationBack, String? imageBack, String? imageBackUrl
});
@ -3092,12 +3099,17 @@ class __$MatrixCardCopyWithImpl<$Res>
/// Create a copy of MatrixCard
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = freezed,Object? original = freezed,Object? translation = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? image = freezed,Object? imageUrl = freezed,Object? original = freezed,Object? translation = freezed,Object? originalBack = freezed,Object? translationBack = freezed,Object? imageBack = freezed,Object? imageBackUrl = freezed,}) {
return _then(_MatrixCard(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,original: freezed == original ? _self.original : original // ignore: cast_nullable_to_non_nullable
as String?,translation: freezed == translation ? _self.translation : translation // ignore: cast_nullable_to_non_nullable
as String?,originalBack: freezed == originalBack ? _self.originalBack : originalBack // ignore: cast_nullable_to_non_nullable
as String?,translationBack: freezed == translationBack ? _self.translationBack : translationBack // ignore: cast_nullable_to_non_nullable
as String?,imageBack: freezed == imageBack ? _self.imageBack : imageBack // ignore: cast_nullable_to_non_nullable
as String?,imageBackUrl: freezed == imageBackUrl ? _self.imageBackUrl : imageBackUrl // ignore: cast_nullable_to_non_nullable
as String?,
));
}

View file

@ -205,7 +205,6 @@ _MatrixQuestion _$MatrixQuestionFromJson(Map<String, dynamic> json) =>
const [],
initialTargetCardId: json['initialTargetCardId'] as String?,
initialTargetWord: json['initialTargetWord'] as String?,
word: json['word'] as String,
text: json['text'] as String?,
type: json['type'] as String? ?? 'matrix',
);
@ -218,7 +217,6 @@ Map<String, dynamic> _$MatrixQuestionToJson(_MatrixQuestion instance) =>
'stages': instance.stages,
'initialTargetCardId': instance.initialTargetCardId,
'initialTargetWord': instance.initialTargetWord,
'word': instance.word,
'text': instance.text,
'type': instance.type,
};
@ -239,16 +237,26 @@ Map<String, dynamic> _$MatrixStageToJson(_MatrixStage instance) =>
_MatrixCard _$MatrixCardFromJson(Map<String, dynamic> json) => _MatrixCard(
id: json['id'] as String,
image: json['image'] as String?,
imageUrl: json['imageUrl'] as String?,
original: json['original'] as String?,
translation: json['translation'] as String?,
originalBack: json['originalBack'] as String?,
translationBack: json['translationBack'] as String?,
imageBack: json['imageBack'] as String?,
imageBackUrl: json['imageBackUrl'] as String?,
);
Map<String, dynamic> _$MatrixCardToJson(_MatrixCard instance) =>
<String, dynamic>{
'id': instance.id,
'image': instance.image,
'imageUrl': instance.imageUrl,
'original': instance.original,
'translation': instance.translation,
'originalBack': instance.originalBack,
'translationBack': instance.translationBack,
'imageBack': instance.imageBack,
'imageBackUrl': instance.imageBackUrl,
};
_QuestionResult _$QuestionResultFromJson(Map<String, dynamic> json) =>

View file

@ -11,7 +11,7 @@ class GameSessionManager {
GameSessionManager();
GameSessionResult? _currentResult;
final Map<String, QuestionResult> _questionResults = {};
final Map<String, List<QuestionResult>> _questionResults = {};
Timer? _questionTimer;
DateTime? _sessionStartTime;
DateTime? _currentQuestionStartTime;
@ -21,7 +21,7 @@ class GameSessionManager {
GameSessionResult? get currentResult => _currentResult;
/// All question results for current session
Map<String, QuestionResult> get questionResults =>
Map<String, List<QuestionResult>> get questionResults =>
Map.unmodifiable(_questionResults);
/// Elapsed time since session start
@ -50,11 +50,53 @@ class GameSessionManager {
// Pre-populate question results with placeholders
for (final question in questions) {
final questionId = _getQuestionId(question);
_questionResults[questionId] = QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: false,
timeSpent: Duration.zero,
question.when(
multipleChoice: (_) {
// Для обычных: список с одним элементом
_questionResults[questionId] = [
QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: false,
timeSpent: Duration.zero,
)
];
},
inputLetters: (_) {
// Для обычных: список с одним элементом
_questionResults[questionId] = [
QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: false,
timeSpent: Duration.zero,
)
];
},
match: (_) {
// Для обычных: список с одним элементом
_questionResults[questionId] = [
QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: false,
timeSpent: Duration.zero,
)
];
},
matrix: (q) {
// Для матричных: список placeholders для каждого stage
_questionResults[questionId] = q.stages.map((stage) =>
QuestionResult(
questionId: questionId,
word: stage.targetWord,
isCorrect: false,
timeSpent: Duration.zero,
selectedAnswer: stage.targetCardId, // Для идентификации
)
).toList();
},
);
}
@ -74,31 +116,69 @@ class GameSessionManager {
/// Submit answer for current question and move to next
void submitAnswer(String questionId, GameQuestion question, dynamic answer) {
final timeSpent = _calculateTimeSpent();
final isCorrect = _validateAnswer(question, answer);
log(
'Answer submitted for question $questionId: correct=$isCorrect, time=${timeSpent.inMilliseconds}ms',
'Answer submitted for question $questionId, time=${timeSpent.inMilliseconds}ms',
name: 'GameSessionManager',
);
final selectedAnswer = answer is String ? answer : null;
final selectedAnswers = switch (answer) {
List<String> v => v,
MatrixImageSelectAnswer v => v.correctCardIdsInOrder,
_ => null,
};
final result = QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: isCorrect,
timeSpent: timeSpent,
selectedAnswer: selectedAnswer,
selectedAnswers: selectedAnswers,
answeredAt: DateTime.now(),
// Для матричных вопросов создаем список результатов (по одному на stage)
var isMatrixProcessed = false;
question.when(
matrix: (q) {
if (answer is MatrixImageSelectAnswer) {
final results = <QuestionResult>[];
final timePerStageMs = timeSpent.inMilliseconds / q.stages.length;
final timePerStage = Duration(milliseconds: timePerStageMs.round());
for (int i = 0; i < q.stages.length; i++) {
final stage = q.stages[i];
final selectedCardId = answer.correctCardIdsInOrder[i];
final isStageCorrect = selectedCardId == stage.targetCardId;
results.add(QuestionResult(
questionId: questionId, // Оригинальный ID (без _stage_)
word: stage.targetWord,
isCorrect: isStageCorrect,
timeSpent: timePerStage,
selectedAnswer: selectedCardId,
selectedAnswers: answer.correctCardIdsInOrder,
answeredAt: DateTime.now(),
));
}
_questionResults[questionId] = results;
isMatrixProcessed = true;
}
},
multipleChoice: (_) {},
inputLetters: (_) {},
match: (_) {},
);
if (!isMatrixProcessed) {
// Обычные вопросы: список с одним элементом
final isCorrect = _validateAnswer(question, answer);
final selectedAnswer = answer is String ? answer : null;
final selectedAnswers = switch (answer) {
List<String> v => v,
MatrixImageSelectAnswer v => v.correctCardIdsInOrder,
_ => null,
};
final result = QuestionResult(
questionId: questionId,
word: _getQuestionWord(question),
isCorrect: isCorrect,
timeSpent: timeSpent,
selectedAnswer: selectedAnswer,
selectedAnswers: selectedAnswers,
answeredAt: DateTime.now(),
);
_questionResults[questionId] = [result];
}
_questionResults[questionId] = result;
_questionTimer?.cancel();
_currentQuestionStartTime = null;
}
@ -108,14 +188,20 @@ class GameSessionManager {
/// Complete the current game session
GameSessionResult completeSession(String testId) {
final totalTime = _calculateSessionTime();
final correctAnswers = _questionResults.values
// Все результаты из всех списков
final allResults = _questionResults.values
.expand((results) => results)
.toList();
final correctAnswers = allResults
.where((r) => r.isCorrect)
.length;
final totalQuestions = _questionResults.length;
final totalQuestions = allResults.length; // Количество stages
_currentResult = GameSessionResult(
testId: testId,
questionResults: _questionResults.values.toList(),
questionResults: allResults, // Плоский список
totalTime: totalTime,
correctAnswers: correctAnswers,
totalQuestions: totalQuestions,
@ -136,21 +222,51 @@ class GameSessionManager {
log('Question skipped: $questionId', name: 'GameSessionManager');
final timeSpent = _calculateTimeSpent();
final existingResult = _questionResults[questionId];
final results = _questionResults[questionId];
if (existingResult != null) {
_questionResults[questionId] = existingResult.copyWith(
timeSpent: existingResult.timeSpent + timeSpent,
);
if (results != null && results.isNotEmpty) {
final timePerResultMs = timeSpent.inMilliseconds / results.length;
final timePerResult = Duration(milliseconds: timePerResultMs.round());
_questionResults[questionId] = results
.map((r) => r.copyWith(timeSpent: r.timeSpent + timePerResult))
.toList();
}
_questionTimer?.cancel();
_currentQuestionStartTime = null;
}
/// Get result for specific question
/// Get result for specific question (returns first result for backward compatibility)
QuestionResult? getQuestionResult(String questionId) {
return _questionResults[questionId];
final results = _questionResults[questionId];
return results?.isNotEmpty == true ? results!.first : null;
}
/// Get result for specific stage of matrix question
QuestionResult? getQuestionResultByStage(
String questionId,
String word,
String? selectedAnswer,
) {
final results = _questionResults[questionId];
if (results == null || results.isEmpty) return null;
try {
return results.firstWhere(
(r) => r.word == word && r.selectedAnswer == selectedAnswer,
);
} catch (_) {
return null;
}
}
/// Check if all stages of matrix question are correct
bool isMatrixQuestionCorrect(String questionId) {
final results = _questionResults[questionId];
if (results == null || results.isEmpty) return false;
// Все stages должны быть правильными
return results.every((r) => r.isCorrect);
}
/// Check if session is active
@ -160,10 +276,14 @@ class GameSessionManager {
Map<String, dynamic> getSessionStats() {
if (!isSessionActive) return {};
final answeredQuestions = _questionResults.values
final allResults = _questionResults.values
.expand((results) => results)
.toList();
final answeredQuestions = allResults
.where((r) => r.answeredAt != null)
.length;
final correctAnswers = _questionResults.values
final correctAnswers = allResults
.where((r) => r.isCorrect)
.length;
final totalTime = _calculateSessionTime();
@ -171,7 +291,7 @@ class GameSessionManager {
return {
'answeredQuestions': answeredQuestions,
'correctAnswers': correctAnswers,
'totalQuestions': _questionResults.length,
'totalQuestions': allResults.length, // Количество stages
'accuracy': answeredQuestions > 0
? correctAnswers / answeredQuestions
: 0.0,
@ -206,7 +326,7 @@ class GameSessionManager {
multipleChoice: (q) => q.word,
inputLetters: (q) => q.word,
match: (q) => q.word,
matrix: (q) => q.word,
matrix: (q) => q.stages.isNotEmpty ? q.stages.first.targetWord : '',
);
}
@ -283,13 +403,25 @@ class GameSessionManager {
// attempts. Progression to the next question is handled by
// TestsStateManager (it should advance even if isCorrect == false).
if (answer is MatrixImageSelectAnswer) {
return answer.correctCardIdsInOrder.length == question.cards.length &&
answer.wrongAttempts == 0;
// Проверяем, что все stages пройдены
if (answer.correctCardIdsInOrder.length != question.stages.length) {
return false;
}
// Проверяем, что все stages правильные
for (int i = 0; i < question.stages.length; i++) {
if (answer.correctCardIdsInOrder[i] != question.stages[i].targetCardId) {
return false;
}
}
// Проверяем, что не было ошибок
return answer.wrongAttempts == 0;
}
// Backward/compat: if someone submits just the correct ids list.
if (answer is List<String>) {
return answer.length == question.cards.length;
return answer.length == question.stages.length;
}
return false;

View file

@ -36,7 +36,7 @@ class TestsState with _$TestsState {
required List<GameQuestion> questions,
required int currentQuestionIndex,
required GameSessionResult? currentResult,
required Map<String, QuestionResult> questionResults,
required Map<String, List<QuestionResult>> questionResults,
required bool isAnswerSubmitted,
required bool isCorrect,
Duration? answerFeedbackDelay,
@ -168,8 +168,13 @@ class TestsStateManager extends StateManager<TestsState> {
// Submit answer to session manager
_gameSessionManager.submitAnswer(questionId, currentQuestion, answer);
final isCorrect =
_gameSessionManager.getQuestionResult(questionId)?.isCorrect ?? false;
// Для матричных вопросов проверяем все stages, для остальных - первый результат
final isCorrect = currentQuestion.when(
multipleChoice: (_) => _gameSessionManager.getQuestionResult(questionId)?.isCorrect ?? false,
inputLetters: (_) => _gameSessionManager.getQuestionResult(questionId)?.isCorrect ?? false,
match: (_) => _gameSessionManager.getQuestionResult(questionId)?.isCorrect ?? false,
matrix: (_) => _gameSessionManager.isMatrixQuestionCorrect(questionId),
);
// Play sound/haptics based on settings
if (_settings.enableSounds) {
@ -246,8 +251,8 @@ class TestsStateManager extends StateManager<TestsState> {
final questionId = _getQuestionId(currentQuestion);
_gameSessionManager.submitAnswer(questionId, currentQuestion, answer);
final isCorrect =
_gameSessionManager.getQuestionResult(questionId)?.isCorrect ?? false;
// Матричный вопрос считается правильным, если все stages правильные
final isCorrect = _gameSessionManager.isMatrixQuestionCorrect(questionId);
// Completion feedback (always positive UX)
if (_settings.enableSounds) {
@ -540,9 +545,16 @@ class TestsStateManager extends StateManager<TestsState> {
.map(
(c) => MatrixCard(
id: c.id,
image: c.imageUrl ?? c.image, // Use presigned URL if available
// Front side fields
image: c.image,
imageUrl: c.imageUrl, // Presigned URL for display
original: c.original,
translation: c.translation,
// Back side fields
originalBack: c.originalBack,
translationBack: c.translationBack,
imageBack: c.imageBack,
imageBackUrl: c.imageBackUrl,
),
)
.toList();
@ -558,13 +570,12 @@ class TestsStateManager extends StateManager<TestsState> {
)
.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;
// Ensure stages are not empty
assert(stages.isNotEmpty, 'Matrix question must have at least one stage');
// Use stages for deprecated fields (for backward compatibility)
final initialTargetCardId = stages.first.targetCardId;
final initialTargetWord = stages.first.targetWord;
questions.add(
GameQuestion.matrix(
@ -575,7 +586,6 @@ class TestsStateManager extends StateManager<TestsState> {
stages: stages,
initialTargetCardId: initialTargetCardId,
initialTargetWord: initialTargetWord,
word: question.word,
text: question.text,
),
),

View file

@ -12,11 +12,17 @@ part of 'tests_state_manager.dart';
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TestsState {
mixin _$TestsState implements DiagnosticableTreeMixin {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState'))
;
}
@override
bool operator ==(Object other) {
@ -28,7 +34,7 @@ bool operator ==(Object other) {
int get hashCode => runtimeType.hashCode;
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState()';
}
@ -134,7 +140,7 @@ return gameSessionCompleted(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? loading,TResult Function( List<TestDto> tests, String packId)? loaded,TResult Function( String message)? error,TResult Function( TestDto test, List<GameQuestion> questions)? gameSessionPreparing,TResult Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, QuestionResult> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay)? gameSessionActive,TResult Function( TestDto test, GameSessionResult result)? gameSessionCompleted,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? loading,TResult Function( List<TestDto> tests, String packId)? loaded,TResult Function( String message)? error,TResult Function( TestDto test, List<GameQuestion> questions)? gameSessionPreparing,TResult Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, List<QuestionResult>> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay)? gameSessionActive,TResult Function( TestDto test, GameSessionResult result)? gameSessionCompleted,required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Loading() when loading != null:
return loading();case _Loaded() when loaded != null:
@ -160,7 +166,7 @@ return gameSessionCompleted(_that.test,_that.result);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() loading,required TResult Function( List<TestDto> tests, String packId) loaded,required TResult Function( String message) error,required TResult Function( TestDto test, List<GameQuestion> questions) gameSessionPreparing,required TResult Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, QuestionResult> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay) gameSessionActive,required TResult Function( TestDto test, GameSessionResult result) gameSessionCompleted,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() loading,required TResult Function( List<TestDto> tests, String packId) loaded,required TResult Function( String message) error,required TResult Function( TestDto test, List<GameQuestion> questions) gameSessionPreparing,required TResult Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, List<QuestionResult>> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay) gameSessionActive,required TResult Function( TestDto test, GameSessionResult result) gameSessionCompleted,}) {final _that = this;
switch (_that) {
case _Loading():
return loading();case _Loaded():
@ -185,7 +191,7 @@ return gameSessionCompleted(_that.test,_that.result);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? loading,TResult? Function( List<TestDto> tests, String packId)? loaded,TResult? Function( String message)? error,TResult? Function( TestDto test, List<GameQuestion> questions)? gameSessionPreparing,TResult? Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, QuestionResult> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay)? gameSessionActive,TResult? Function( TestDto test, GameSessionResult result)? gameSessionCompleted,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? loading,TResult? Function( List<TestDto> tests, String packId)? loaded,TResult? Function( String message)? error,TResult? Function( TestDto test, List<GameQuestion> questions)? gameSessionPreparing,TResult? Function( TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, List<QuestionResult>> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay)? gameSessionActive,TResult? Function( TestDto test, GameSessionResult result)? gameSessionCompleted,}) {final _that = this;
switch (_that) {
case _Loading() when loading != null:
return loading();case _Loaded() when loaded != null:
@ -204,7 +210,7 @@ return gameSessionCompleted(_that.test,_that.result);case _:
/// @nodoc
class _Loading implements TestsState {
class _Loading with DiagnosticableTreeMixin implements TestsState {
const _Loading();
@ -212,6 +218,12 @@ class _Loading implements TestsState {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.loading'))
;
}
@override
bool operator ==(Object other) {
@ -223,7 +235,7 @@ bool operator ==(Object other) {
int get hashCode => runtimeType.hashCode;
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.loading()';
}
@ -236,7 +248,7 @@ String toString() {
/// @nodoc
class _Loaded implements TestsState {
class _Loaded with DiagnosticableTreeMixin implements TestsState {
const _Loaded({required final List<TestDto> tests, required this.packId}): _tests = tests;
@ -256,6 +268,12 @@ class _Loaded implements TestsState {
_$LoadedCopyWith<_Loaded> get copyWith => __$LoadedCopyWithImpl<_Loaded>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.loaded'))
..add(DiagnosticsProperty('tests', tests))..add(DiagnosticsProperty('packId', packId));
}
@override
bool operator ==(Object other) {
@ -267,7 +285,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_tests),packId);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.loaded(tests: $tests, packId: $packId)';
}
@ -310,7 +328,7 @@ as String,
/// @nodoc
class _Error implements TestsState {
class _Error with DiagnosticableTreeMixin implements TestsState {
const _Error(this.message);
@ -323,6 +341,12 @@ class _Error implements TestsState {
_$ErrorCopyWith<_Error> get copyWith => __$ErrorCopyWithImpl<_Error>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.error'))
..add(DiagnosticsProperty('message', message));
}
@override
bool operator ==(Object other) {
@ -334,7 +358,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,message);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.error(message: $message)';
}
@ -376,7 +400,7 @@ as String,
/// @nodoc
class _GameSessionPreparing implements TestsState {
class _GameSessionPreparing with DiagnosticableTreeMixin implements TestsState {
const _GameSessionPreparing({required this.test, required final List<GameQuestion> questions}): _questions = questions;
@ -396,6 +420,12 @@ class _GameSessionPreparing implements TestsState {
_$GameSessionPreparingCopyWith<_GameSessionPreparing> get copyWith => __$GameSessionPreparingCopyWithImpl<_GameSessionPreparing>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.gameSessionPreparing'))
..add(DiagnosticsProperty('test', test))..add(DiagnosticsProperty('questions', questions));
}
@override
bool operator ==(Object other) {
@ -407,7 +437,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,test,const DeepCollectionEquality().hash(_questions));
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.gameSessionPreparing(test: $test, questions: $questions)';
}
@ -450,8 +480,8 @@ as List<GameQuestion>,
/// @nodoc
class _GameSessionActive implements TestsState {
const _GameSessionActive({required this.test, required final List<GameQuestion> questions, required this.currentQuestionIndex, required this.currentResult, required final Map<String, QuestionResult> questionResults, required this.isAnswerSubmitted, required this.isCorrect, this.answerFeedbackDelay}): _questions = questions,_questionResults = questionResults;
class _GameSessionActive with DiagnosticableTreeMixin implements TestsState {
const _GameSessionActive({required this.test, required final List<GameQuestion> questions, required this.currentQuestionIndex, required this.currentResult, required final Map<String, List<QuestionResult>> questionResults, required this.isAnswerSubmitted, required this.isCorrect, this.answerFeedbackDelay}): _questions = questions,_questionResults = questionResults;
final TestDto test;
@ -464,8 +494,8 @@ class _GameSessionActive implements TestsState {
final int currentQuestionIndex;
final GameSessionResult? currentResult;
final Map<String, QuestionResult> _questionResults;
Map<String, QuestionResult> get questionResults {
final Map<String, List<QuestionResult>> _questionResults;
Map<String, List<QuestionResult>> get questionResults {
if (_questionResults is EqualUnmodifiableMapView) return _questionResults;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_questionResults);
@ -482,6 +512,12 @@ class _GameSessionActive implements TestsState {
_$GameSessionActiveCopyWith<_GameSessionActive> get copyWith => __$GameSessionActiveCopyWithImpl<_GameSessionActive>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.gameSessionActive'))
..add(DiagnosticsProperty('test', test))..add(DiagnosticsProperty('questions', questions))..add(DiagnosticsProperty('currentQuestionIndex', currentQuestionIndex))..add(DiagnosticsProperty('currentResult', currentResult))..add(DiagnosticsProperty('questionResults', questionResults))..add(DiagnosticsProperty('isAnswerSubmitted', isAnswerSubmitted))..add(DiagnosticsProperty('isCorrect', isCorrect))..add(DiagnosticsProperty('answerFeedbackDelay', answerFeedbackDelay));
}
@override
bool operator ==(Object other) {
@ -493,7 +529,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,test,const DeepCollectionEquality().hash(_questions),currentQuestionIndex,currentResult,const DeepCollectionEquality().hash(_questionResults),isAnswerSubmitted,isCorrect,answerFeedbackDelay);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.gameSessionActive(test: $test, questions: $questions, currentQuestionIndex: $currentQuestionIndex, currentResult: $currentResult, questionResults: $questionResults, isAnswerSubmitted: $isAnswerSubmitted, isCorrect: $isCorrect, answerFeedbackDelay: $answerFeedbackDelay)';
}
@ -505,7 +541,7 @@ abstract mixin class _$GameSessionActiveCopyWith<$Res> implements $TestsStateCop
factory _$GameSessionActiveCopyWith(_GameSessionActive value, $Res Function(_GameSessionActive) _then) = __$GameSessionActiveCopyWithImpl;
@useResult
$Res call({
TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, QuestionResult> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay
TestDto test, List<GameQuestion> questions, int currentQuestionIndex, GameSessionResult? currentResult, Map<String, List<QuestionResult>> questionResults, bool isAnswerSubmitted, bool isCorrect, Duration? answerFeedbackDelay
});
@ -529,7 +565,7 @@ as TestDto,questions: null == questions ? _self._questions : questions // ignore
as List<GameQuestion>,currentQuestionIndex: null == currentQuestionIndex ? _self.currentQuestionIndex : currentQuestionIndex // ignore: cast_nullable_to_non_nullable
as int,currentResult: freezed == currentResult ? _self.currentResult : currentResult // ignore: cast_nullable_to_non_nullable
as GameSessionResult?,questionResults: null == questionResults ? _self._questionResults : questionResults // ignore: cast_nullable_to_non_nullable
as Map<String, QuestionResult>,isAnswerSubmitted: null == isAnswerSubmitted ? _self.isAnswerSubmitted : isAnswerSubmitted // ignore: cast_nullable_to_non_nullable
as Map<String, List<QuestionResult>>,isAnswerSubmitted: null == isAnswerSubmitted ? _self.isAnswerSubmitted : isAnswerSubmitted // ignore: cast_nullable_to_non_nullable
as bool,isCorrect: null == isCorrect ? _self.isCorrect : isCorrect // ignore: cast_nullable_to_non_nullable
as bool,answerFeedbackDelay: freezed == answerFeedbackDelay ? _self.answerFeedbackDelay : answerFeedbackDelay // ignore: cast_nullable_to_non_nullable
as Duration?,
@ -554,7 +590,7 @@ $GameSessionResultCopyWith<$Res>? get currentResult {
/// @nodoc
class _GameSessionCompleted implements TestsState {
class _GameSessionCompleted with DiagnosticableTreeMixin implements TestsState {
const _GameSessionCompleted({required this.test, required this.result});
@ -568,6 +604,12 @@ class _GameSessionCompleted implements TestsState {
_$GameSessionCompletedCopyWith<_GameSessionCompleted> get copyWith => __$GameSessionCompletedCopyWithImpl<_GameSessionCompleted>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'TestsState.gameSessionCompleted'))
..add(DiagnosticsProperty('test', test))..add(DiagnosticsProperty('result', result));
}
@override
bool operator ==(Object other) {
@ -579,7 +621,7 @@ bool operator ==(Object other) {
int get hashCode => Object.hash(runtimeType,test,result);
@override
String toString() {
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'TestsState.gameSessionCompleted(test: $test, result: $result)';
}

View file

@ -251,7 +251,7 @@ class _GamePageState extends State<GamePage> {
int currentQuestionIndex,
bool isAnswerSubmitted,
bool isCorrect,
Map<String, QuestionResult> questionResults,
Map<String, List<QuestionResult>> questionResults,
Duration sessionElapsed,
) {
final theme = Theme.of(context);
@ -296,6 +296,7 @@ class _GamePageState extends State<GamePage> {
currentQuestion: currentQuestionIndex,
totalQuestions: questions.length,
correctAnswers: questionResults.values
.expand((results) => results)
.where((r) => r.isCorrect)
.length,
timeElapsed: sessionElapsed,
@ -889,11 +890,11 @@ class _GamePageState extends State<GamePage> {
String? _getSelectedAnswerForMultipleChoice(
MultipleChoiceQuestion question,
Map<String, QuestionResult> questionResults,
Map<String, List<QuestionResult>> questionResults,
) {
final questionId = question.id;
final result = questionResults[questionId];
return result?.selectedAnswer;
final results = questionResults[questionId];
return results?.isNotEmpty == true ? results!.first.selectedAnswer : null;
}
Color _scoreColor(ColorScheme colorScheme, int score) {

View file

@ -127,54 +127,72 @@ final mockMatrixQuestion = MatrixQuestion(
image: 'https://via.placeholder.com/100x100?text=1',
original: 'Apple',
translation: 'Яблоко',
originalBack: 'Apple',
translationBack: 'Яблоко',
),
MatrixCard(
id: 'card2',
image: 'https://via.placeholder.com/100x100?text=2',
original: 'Orange',
translation: 'Апельсин',
originalBack: 'Orange',
translationBack: 'Апельсин',
),
MatrixCard(
id: 'card3',
image: 'https://via.placeholder.com/100x100?text=3',
original: 'Banana',
translation: 'Банан',
originalBack: 'Banana',
translationBack: 'Банан',
),
MatrixCard(
id: 'card4',
image: 'https://via.placeholder.com/100x100?text=4',
original: 'Grape',
translation: 'Виноград',
originalBack: 'Grape',
translationBack: 'Виноград',
),
MatrixCard(
id: 'card5',
image: 'https://via.placeholder.com/100x100?text=5',
original: 'Cherry',
translation: 'Вишня',
originalBack: 'Cherry',
translationBack: 'Вишня',
),
MatrixCard(
id: 'card6',
image: 'https://via.placeholder.com/100x100?text=6',
original: 'Strawberry',
translation: 'Клубника',
originalBack: 'Strawberry',
translationBack: 'Клубника',
),
MatrixCard(
id: 'card7',
image: 'https://via.placeholder.com/100x100?text=7',
original: 'Watermelon',
translation: 'Арбуз',
originalBack: 'Watermelon',
translationBack: 'Арбуз',
),
MatrixCard(
id: 'card8',
image: 'https://via.placeholder.com/100x100?text=8',
original: 'Pineapple',
translation: 'Ананас',
originalBack: 'Pineapple',
translationBack: 'Ананас',
),
MatrixCard(
id: 'card9',
image: 'https://via.placeholder.com/100x100?text=9',
original: 'Mango',
translation: 'Манго',
originalBack: 'Mango',
translationBack: 'Манго',
),
],
stages: [
@ -194,6 +212,6 @@ final mockMatrixQuestion = MatrixQuestion(
targetAudio: null,
),
],
word: 'apple',
initialTargetWord: 'Apple',
type: 'matrix',
);

View file

@ -547,49 +547,7 @@ class _MatrixFlipCard extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(21.r),
child: isBack
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi),
child: Center(
child: Padding(
padding: EdgeInsets.all(24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (card.original != null)
Text(
card.original!,
style: TextStyle(
fontSize: 28,
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,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(
0.5,
),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
)
? _buildCardBack(card, colorScheme)
: _buildCardFront(card, colorScheme),
),
),
@ -600,7 +558,9 @@ class _MatrixFlipCard extends StatelessWidget {
}
Widget _buildCardFront(MatrixCard card, ColorScheme colorScheme) {
final hasImage = card.image != null && card.image!.isNotEmpty;
// Front side: use only front fields
final frontImage = card.imageUrl ?? card.image;
final hasImage = frontImage != null && frontImage.isNotEmpty;
final hasOriginal = card.original != null && card.original!.isNotEmpty;
final hasTranslation =
card.translation != null && card.translation!.isNotEmpty;
@ -648,7 +608,7 @@ class _MatrixFlipCard extends StatelessWidget {
// If only image (no text), show image full size
if (hasImage && !hasText) {
return Image.network(
card.image!,
frontImage!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
@ -701,7 +661,7 @@ class _MatrixFlipCard extends StatelessWidget {
),
Expanded(
child: Image.network(
card.image!,
frontImage!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
@ -746,4 +706,68 @@ class _MatrixFlipCard extends StatelessWidget {
),
);
}
Widget _buildCardBack(MatrixCard card, ColorScheme colorScheme) {
// Back side: use only back fields
final backImage = card.imageBackUrl ?? card.imageBack;
final hasImage = backImage != null && backImage.isNotEmpty;
final hasOriginal = card.originalBack != null && card.originalBack!.isNotEmpty;
final hasTranslation =
card.translationBack != null && card.translationBack!.isNotEmpty;
final hasText = hasOriginal || hasTranslation;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi),
child: Center(
child: Padding(
padding: EdgeInsets.all(24.w),
child: hasImage && !hasText
? Image.network(
backImage!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
color: colorScheme.onSurfaceVariant,
),
);
},
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasOriginal)
Text(
card.originalBack!,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (hasOriginal && hasTranslation) SizedBox(height: 12.h),
if (hasTranslation)
Text(
card.translationBack!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}