voices
Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (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 Admin Panel / Deploy Admin Panel (push) Has been cancelled
Deploy Admin Panel / Admin Panel Verification (push) Has been cancelled

This commit is contained in:
Dmitry 2025-12-19 07:18:46 +03:00
parent 8b754bbd9f
commit de06c74b72
21 changed files with 484 additions and 179 deletions

View file

@ -191,6 +191,13 @@
- Updated test widgets: matrix_widget, answer_options, question_display to use presigned URLs
- Updated tests_state_manager: converts TestButtonDto and MatrixCardDto to use imageUrl when available
- All widgets maintain backward compatibility with fallback to ApiConfigV2 URL building
- **Test Images Fix**: Fixed missing images in game tests
- Fixed matrix question generation: now uses card.image instead of card.id for images
- Unified MinIO bucket structure: all images (cards and tests) now use cardImagesBucket
- Fixed image conversion logic: keeps image as objectId and adds imageUrl as presigned URL
- Removed testImagesBucket - simplified to single bucket for all images
- Updated all API endpoints and tests to use unified bucket structure
- Added comprehensive unit tests for image conversion in test_manager_image_conversion_test.dart
### Common Libraries
- **mnemo_cards_common**: Shared models and utilities

View file

@ -105,6 +105,14 @@
- Added sun image (el_sol.png) for light theme and moon image (la_luna.png) for dark theme
- Created theme-aware loading screen components using StateBuilder
- Added assets/images/ to pubspec.yaml and updated app.dart with new loading UI
- ✅ **Test Images Fix**: Fixed missing images in game tests (matrix questions)
- Fixed matrix question generation: now uses card.image instead of card.id for button images
- Unified MinIO bucket structure: all images (cards and tests) now use single cardImagesBucket
- Removed testImagesBucket bucket - simplified architecture to single bucket for all images
- Fixed image conversion to keep image as objectId and add imageUrl as presigned URL (no overwrite)
- Updated all API endpoints (MediaApiV2, AdminTestsApiV2, TestManager) to use unified bucket
- Updated all tests (media_api_v2_test, minio_service_test) to reflect single bucket structure
- Added comprehensive unit tests in test_manager_image_conversion_test.dart with 3 test cases
- [ ] **Integration Tests**: Implement comprehensive integration testing
- API endpoint testing

View file

@ -21,9 +21,14 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
const [newLanguage, setNewLanguage] = useState('en')
// Load voices for the card
const { data: voicesData, isLoading } = useQuery({
const { data: voicesData, isLoading, refetch } = useQuery({
queryKey: ['cardVoices', cardId],
queryFn: () => voicesApi.getCardVoices(cardId),
queryFn: async () => {
console.log('🔍 CardVoicesManager: Fetching voices for card', cardId)
const result = await voicesApi.getCardVoices(cardId)
console.log('✅ CardVoicesManager: Fetched voices', result)
return result
},
// Voices should still load even if parent form is disabled (e.g. during save)
enabled: !!cardId,
})
@ -38,10 +43,19 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
})
return voicesApi.addCardVoice(cardId, voiceUrl, language)
},
onSuccess: () => {
onSuccess: async () => {
console.log('✅ CardVoicesManager: addVoiceMutation.onSuccess')
queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
// Invalidate and refetch to ensure we get the latest data
await queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
// Force refetch to ensure the list is updated
const refetchResult = await refetch()
console.log('✅ CardVoicesManager: Refetched voices after add', refetchResult.data)
toast.success('Voice added successfully')
// Auto-close form and reset after successful addition
setShowAddForm(false)
setNewAudio(undefined)
setNewLanguage('en')
@ -66,30 +80,7 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
},
})
const handleAddVoice = () => {
console.log('🔍 CardVoicesManager: handleAddVoice called', {
newAudio,
newLanguage,
cardId,
})
if (!newAudio) {
console.warn('⚠️ CardVoicesManager: newAudio is empty')
toast.error('Please upload an audio file')
return
}
console.log('✅ CardVoicesManager: Calling addVoiceMutation.mutate', {
cardId,
voiceUrl: newAudio,
language: newLanguage,
})
addVoiceMutation.mutate({
voiceUrl: newAudio,
language: newLanguage,
})
}
// handleAddVoice is no longer needed - voice is added automatically after upload
const handleRemoveVoice = (voiceId: string) => {
if (confirm('Are you sure you want to remove this voice?')) {
@ -124,23 +115,34 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
<AudioUpload
label="Audio File"
value={newAudio}
onChange={(value) => {
onChange={async (value) => {
console.log('🔍 CardVoicesManager: AudioUpload onChange', { value, cardId })
setNewAudio(value)
// Automatically add voice after upload
if (value && cardId) {
console.log('✅ CardVoicesManager: Auto-adding voice after upload', {
cardId,
voiceUrl: value,
language: newLanguage,
})
addVoiceMutation.mutate({
voiceUrl: value,
language: newLanguage,
})
}
}}
language={newLanguage}
onLanguageChange={setNewLanguage}
disabled={disabled || addVoiceMutation.isPending}
/>
{addVoiceMutation.isPending && (
<div className="text-sm text-muted-foreground">
Adding voice...
</div>
)}
<div className="flex items-center space-x-2">
<Button
type="button"
size="sm"
onClick={handleAddVoice}
disabled={!newAudio || disabled || addVoiceMutation.isPending}
>
{addVoiceMutation.isPending ? 'Adding...' : 'Add Voice'}
</Button>
<Button
type="button"
variant="outline"

View file

@ -136,11 +136,11 @@ class AdminTestsApiV2 {
final parsed = CardImageStorage.tryParseBase64Image(v);
if (parsed != null) {
try {
final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket,
bytes: parsed.bytes,
contentType: parsed.contentType,
);
final objectId = await _minioService.uploadFile(
bucket: MinioConfig.cardImagesBucket,
bytes: parsed.bytes,
contentType: parsed.contentType,
);
return objectId;
} catch (e) {
print('Error uploading test image to MinIO: $e');
@ -172,7 +172,7 @@ class AdminTestsApiV2 {
// If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
objectId: v,
);
return presignedUrl;
@ -184,7 +184,7 @@ class AdminTestsApiV2 {
// If cardId is UUID, generate presigned URL
if (_isUuid(cardId)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
objectId: cardId,
);
return presignedUrl;

View file

@ -226,18 +226,18 @@ class MediaApiV2 {
);
}
// Upload to MinIO
// Upload to MinIO (test images use the same bucket as card images)
final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
bytes: fileData.bytes,
contentType: fileData.contentType,
);
// Generate presigned URL for preview
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
objectId: objectId,
);
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.cardImagesBucket,
objectId: objectId,
);
return _ok({
'objectId': objectId,
@ -325,7 +325,6 @@ class MediaApiV2 {
// Validate bucket
final validBuckets = [
MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket,
];
if (!validBuckets.contains(bucket)) {
@ -389,7 +388,6 @@ class MediaApiV2 {
// Validate bucket
final validBuckets = [
MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket,
];
if (!validBuckets.contains(bucket)) {

View file

@ -10,8 +10,9 @@ class MinioConfig {
final String region;
// Bucket names
// Note: cardImagesBucket is used for all images (both card images and test images)
// to simplify bucket management and reduce complexity
static const String cardImagesBucket = 'card-images';
static const String testImagesBucket = 'test-images';
static const String voiceAudioBucket = 'voice-audio';
// Presigned URL expiration (4 hours)

View file

@ -28,7 +28,6 @@ class MinioService {
Future<void> ensureBucketsExist() async {
final buckets = [
MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket,
];
print('Config endpoint: ${_config.endpoint}');

View file

@ -5,6 +5,7 @@ import 'package:injectable/injectable.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@ -119,7 +120,7 @@ class TestManager {
if (parsed != null) {
try {
final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
bytes: parsed.bytes,
contentType: parsed.contentType,
);
@ -153,7 +154,7 @@ class TestManager {
// If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
objectId: v,
);
return presignedUrl;
@ -165,7 +166,7 @@ class TestManager {
// If cardId is UUID, generate presigned URL
if (_isUuid(cardId)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
objectId: cardId,
);
return presignedUrl;
@ -179,6 +180,38 @@ class TestManager {
return v;
}
/// Converts an audio value to a presigned URL if it's a UUID (objectId in MinIO),
/// or returns it as-is if it's already a remote URL.
///
/// Similar to _imageValueToApiUrl but for audio files stored in voice-audio bucket.
Future<String?> _audioValueToApiUrl(String? value) async {
if (value == null) return null;
final v = value.trim();
if (v.isEmpty) return null;
// Already a remote URL: return as is
if (VoiceStorage.isRemoteUrl(v)) {
return v;
}
// If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.voiceAudioBucket,
objectId: v,
);
return presignedUrl;
}
// Legacy formats (base64, filenames) - don't leak through API
// Return null to indicate invalid/unsupported format
if (VoiceStorage.tryParseBase64Audio(v) != null) return null;
// For other formats (like legacy filenames), return null
// They should be migrated to UUID format
return null;
}
Future<TestStatisticsDto?> _testStatisticsDto(
String userId, String testId) async {
final statistics = await _db.testDao.getTestStatistics(userId, testId);
@ -324,42 +357,24 @@ class TestManager {
);
}
// Convert to URLs for response (async)
// Convert audio to presigned URL if it's a UUID (objectId in MinIO)
// Note: image and imageUrl were already set above during normalization
final uiDataForResponse = Map<String, dynamic>.from(uiData);
if (uiDataForResponse['image'] != null) {
uiDataForResponse['image'] = await _imageValueToApiUrl(
uiDataForResponse['image']?.toString(),
packId: packId,
if (uiDataForResponse['audio'] != null) {
final audioUrl = await _audioValueToApiUrl(
uiDataForResponse['audio']?.toString(),
);
if (audioUrl != null) {
uiDataForResponse['audio'] = audioUrl;
} else {
// If conversion failed (e.g., base64 or invalid format), remove audio
uiDataForResponse.remove('audio');
}
}
final buttonsForResponse = await Future.wait(
normalizedButtons.map((b) async {
if (b is Map<String, dynamic> && b['image'] != null) {
final updated = Map<String, dynamic>.from(b);
updated['image'] = await _imageValueToApiUrl(
updated['image']?.toString(),
packId: packId,
);
return updated;
}
if (b is Map) {
final updated = Map<String, dynamic>.from(
b.map((k, v) => MapEntry(k.toString(), v)),
);
if (updated['image'] != null) {
updated['image'] = await _imageValueToApiUrl(
updated['image']?.toString(),
packId: packId,
);
}
return updated;
}
return b;
}),
);
questionJson['buttons'] = buttonsForResponse;
// Buttons already have image (objectId) and imageUrl (presigned URL)
// from normalization above, no need to convert again
questionJson['buttons'] = normalizedButtons;
questionJson.addAll(uiDataForResponse);
// Matrix question: allow storing only config (matrixSize) and generate
@ -392,8 +407,8 @@ class TestManager {
.map(
(c) => <String, dynamic>{
'id': c.id,
// store as cardId; we will convert to URL below
'image': c.id,
// store image as objectId (MinIO) or filename
'image': c.image,
'original': c.original,
'translation': c.translation,
},
@ -428,16 +443,19 @@ class TestManager {
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin
// 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) {
final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString();
// Keep image as objectId, add imageUrl as presigned URL
// Convert image to presigned URL
final imageUrl = await _imageValueToApiUrl(
imageValue,
packId: packId,
);
if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl;
}

View file

@ -143,17 +143,17 @@ void main() {
const testPresignedUrl = 'https://minio.example.com/presigned-url-2';
when(mockMinioService.uploadFile(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
bytes: anyNamed('bytes'),
contentType: 'image/jpeg',
)).thenAnswer((_) async => testObjectId);
when(mockMinioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket,
bucket: MinioConfig.cardImagesBucket,
objectId: testObjectId,
)).thenAnswer((_) async => testPresignedUrl);
// Similar to uploadCardImage test
// Test images now use the same bucket as card images
expect(testObjectId, isNotEmpty);
expect(testPresignedUrl, startsWith('https://'));
});

View file

@ -2,6 +2,7 @@ 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_backend/mnemo_cards_common_backend.dart';
import 'package:test/test.dart';
@ -9,6 +10,7 @@ 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';
@ -37,7 +39,22 @@ void main() {
// Create tables if needed (idempotent in drift for Postgres).
await Migrator(db).createAll();
testManager = TestManager(db);
// 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 {

View file

@ -8,10 +8,6 @@ void main() {
MinioConfig.cardImagesBucket,
equals('card-images'),
);
expect(
MinioConfig.testImagesBucket,
equals('test-images'),
);
expect(
MinioConfig.voiceAudioBucket,
equals('voice-audio'),
@ -37,20 +33,11 @@ void main() {
test('should validate bucket constants', () {
// Test that bucket names are correctly defined
expect(MinioConfig.cardImagesBucket, isNotEmpty);
expect(MinioConfig.testImagesBucket, isNotEmpty);
expect(MinioConfig.voiceAudioBucket, isNotEmpty);
// Test that bucket names are different
expect(
MinioConfig.cardImagesBucket,
isNot(equals(MinioConfig.testImagesBucket)),
);
expect(
MinioConfig.cardImagesBucket,
isNot(equals(MinioConfig.voiceAudioBucket)),
);
expect(
MinioConfig.testImagesBucket,
isNot(equals(MinioConfig.voiceAudioBucket)),
);
});

View file

@ -55,6 +55,7 @@ abstract class MultipleChoiceQuestion with _$MultipleChoiceQuestion {
}
/// Input letters question - user fills in letters to form a word
/// Can have buttons with text or images that user can tap to fill in the template
@freezed
abstract class InputLettersQuestion with _$InputLettersQuestion {
const factory InputLettersQuestion({
@ -65,6 +66,7 @@ abstract class InputLettersQuestion with _$InputLettersQuestion {
required String correctAnswer,
required String word,
@Default('inputLetters') String type,
@Default([]) List<ChoiceOption> buttons, // Buttons with text or images for user to tap
}) = _InputLettersQuestion;
factory InputLettersQuestion.fromJson(Map<String, dynamic> json) =>

View file

@ -1131,7 +1131,7 @@ as String,
mixin _$InputLettersQuestion {
String get id; String get template;// e.g., "H _ _ L _"
String? get image; String? get audio; String get correctAnswer; String get word; String get type;
String? get image; String? get audio; String get correctAnswer; String get word; String get type; List<ChoiceOption> get buttons;
/// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -1144,16 +1144,16 @@ $InputLettersQuestionCopyWith<InputLettersQuestion> get copyWith => _$InputLette
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is InputLettersQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.template, template) || other.template == template)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is InputLettersQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.template, template) || other.template == template)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.buttons, buttons));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,template,image,audio,correctAnswer,word,type);
int get hashCode => Object.hash(runtimeType,id,template,image,audio,correctAnswer,word,type,const DeepCollectionEquality().hash(buttons));
@override
String toString() {
return 'InputLettersQuestion(id: $id, template: $template, image: $image, audio: $audio, correctAnswer: $correctAnswer, word: $word, type: $type)';
return 'InputLettersQuestion(id: $id, template: $template, image: $image, audio: $audio, correctAnswer: $correctAnswer, word: $word, type: $type, buttons: $buttons)';
}
@ -1164,7 +1164,7 @@ abstract mixin class $InputLettersQuestionCopyWith<$Res> {
factory $InputLettersQuestionCopyWith(InputLettersQuestion value, $Res Function(InputLettersQuestion) _then) = _$InputLettersQuestionCopyWithImpl;
@useResult
$Res call({
String id, String template, String? image, String? audio, String correctAnswer, String word, String type
String id, String template, String? image, String? audio, String correctAnswer, String word, String type, List<ChoiceOption> buttons
});
@ -1181,7 +1181,7 @@ class _$InputLettersQuestionCopyWithImpl<$Res>
/// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? template = null,Object? image = freezed,Object? audio = freezed,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? template = null,Object? image = freezed,Object? audio = freezed,Object? correctAnswer = null,Object? word = null,Object? type = null,Object? buttons = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,template: null == template ? _self.template : template // ignore: cast_nullable_to_non_nullable
@ -1190,7 +1190,8 @@ as String?,audio: freezed == audio ? _self.audio : audio // ignore: cast_nullabl
as String?,correctAnswer: null == correctAnswer ? _self.correctAnswer : correctAnswer // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,
as String,buttons: null == buttons ? _self.buttons : buttons // ignore: cast_nullable_to_non_nullable
as List<ChoiceOption>,
));
}
@ -1275,10 +1276,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type, List<ChoiceOption> buttons)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _InputLettersQuestion() when $default != null:
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type,_that.buttons);case _:
return orElse();
}
@ -1296,10 +1297,10 @@ return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAns
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type, List<ChoiceOption> buttons) $default,) {final _that = this;
switch (_that) {
case _InputLettersQuestion():
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type,_that.buttons);case _:
throw StateError('Unexpected subclass');
}
@ -1316,10 +1317,10 @@ return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAns
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String template, String? image, String? audio, String correctAnswer, String word, String type, List<ChoiceOption> buttons)? $default,) {final _that = this;
switch (_that) {
case _InputLettersQuestion() when $default != null:
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAnswer,_that.word,_that.type,_that.buttons);case _:
return null;
}
@ -1331,7 +1332,7 @@ return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAns
@JsonSerializable()
class _InputLettersQuestion implements InputLettersQuestion {
const _InputLettersQuestion({required this.id, required this.template, this.image, this.audio, required this.correctAnswer, required this.word, this.type = 'inputLetters'});
const _InputLettersQuestion({required this.id, required this.template, this.image, this.audio, required this.correctAnswer, required this.word, this.type = 'inputLetters', final List<ChoiceOption> buttons = const []}): _buttons = buttons;
factory _InputLettersQuestion.fromJson(Map<String, dynamic> json) => _$InputLettersQuestionFromJson(json);
@override final String id;
@ -1342,6 +1343,13 @@ class _InputLettersQuestion implements InputLettersQuestion {
@override final String correctAnswer;
@override final String word;
@override@JsonKey() final String type;
final List<ChoiceOption> _buttons;
@override@JsonKey() List<ChoiceOption> get buttons {
if (_buttons is EqualUnmodifiableListView) return _buttons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_buttons);
}
/// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values.
@ -1356,16 +1364,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _InputLettersQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.template, template) || other.template == template)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _InputLettersQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.template, template) || other.template == template)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._buttons, _buttons));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,template,image,audio,correctAnswer,word,type);
int get hashCode => Object.hash(runtimeType,id,template,image,audio,correctAnswer,word,type,const DeepCollectionEquality().hash(_buttons));
@override
String toString() {
return 'InputLettersQuestion(id: $id, template: $template, image: $image, audio: $audio, correctAnswer: $correctAnswer, word: $word, type: $type)';
return 'InputLettersQuestion(id: $id, template: $template, image: $image, audio: $audio, correctAnswer: $correctAnswer, word: $word, type: $type, buttons: $buttons)';
}
@ -1376,7 +1384,7 @@ abstract mixin class _$InputLettersQuestionCopyWith<$Res> implements $InputLette
factory _$InputLettersQuestionCopyWith(_InputLettersQuestion value, $Res Function(_InputLettersQuestion) _then) = __$InputLettersQuestionCopyWithImpl;
@override @useResult
$Res call({
String id, String template, String? image, String? audio, String correctAnswer, String word, String type
String id, String template, String? image, String? audio, String correctAnswer, String word, String type, List<ChoiceOption> buttons
});
@ -1393,7 +1401,7 @@ class __$InputLettersQuestionCopyWithImpl<$Res>
/// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? template = null,Object? image = freezed,Object? audio = freezed,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? template = null,Object? image = freezed,Object? audio = freezed,Object? correctAnswer = null,Object? word = null,Object? type = null,Object? buttons = null,}) {
return _then(_InputLettersQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,template: null == template ? _self.template : template // ignore: cast_nullable_to_non_nullable
@ -1402,7 +1410,8 @@ as String?,audio: freezed == audio ? _self.audio : audio // ignore: cast_nullabl
as String?,correctAnswer: null == correctAnswer ? _self.correctAnswer : correctAnswer // ignore: cast_nullable_to_non_nullable
as String,word: null == word ? _self.word : word // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,
as String,buttons: null == buttons ? _self._buttons : buttons // ignore: cast_nullable_to_non_nullable
as List<ChoiceOption>,
));
}

View file

@ -116,6 +116,11 @@ _InputLettersQuestion _$InputLettersQuestionFromJson(
correctAnswer: json['correctAnswer'] as String,
word: json['word'] as String,
type: json['type'] as String? ?? 'inputLetters',
buttons:
(json['buttons'] as List<dynamic>?)
?.map((e) => ChoiceOption.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$InputLettersQuestionToJson(
@ -128,6 +133,7 @@ Map<String, dynamic> _$InputLettersQuestionToJson(
'correctAnswer': instance.correctAnswer,
'word': instance.word,
'type': instance.type,
'buttons': instance.buttons,
};
_MatchQuestion _$MatchQuestionFromJson(Map<String, dynamic> json) =>

View file

@ -430,10 +430,27 @@ class TestsStateManager extends StateManager<TestsState> {
),
));
} else if (question is InputButtonsTestQuestionBody) {
// Match question - two columns of items to connect
// Note: Using simplified approach since correctPairs structure may vary
// This is a placeholder for future implementation when proper structure is available
continue; // Skip for now, will be implemented when backend supports it
// Convert InputButtonsTestQuestionBody to InputLettersQuestion
// Convert buttons to ChoiceOptions (supporting both text and images)
final optionItems = question.buttons.map((b) {
return ChoiceOption(
id: b.id,
text: b.text,
image: b.imageUrl ?? b.image, // Use presigned URL if available
);
}).toList();
questions.add(GameQuestion.inputLetters(
InputLettersQuestion(
id: question.id?.toString() ?? 'q_${questions.length}',
template: question.template,
image: question.imageUrl ?? question.image, // Use presigned URL if available
audio: question.audio,
correctAnswer: question.answer,
word: question.word,
buttons: optionItems,
),
));
} else if (question is MatrixTestQuestionBody) {
final id = question.id?.toString() ?? 'q_${questions.length}';

View file

@ -543,11 +543,11 @@ class _GamePageState extends State<GamePage> {
}
void _finishGame() {
Future<void> _finishGame() async {
final appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope;
if (userScope != null) {
userScope.testsModule.testsStateManager.completeGameSession();
await userScope.testsModule.testsStateManager.completeGameSession();
}
}

View file

@ -51,13 +51,6 @@ class CardFavoriteButton extends StatelessWidget {
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,

View file

@ -440,11 +440,9 @@ class _CardSide extends StatelessWidget {
child: Stack(
children: [
Padding(
// Reserve minimal space for overlay controls
// (voice on the left, favorite on the right) while keeping
// the text centered.
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Original текст - нормальный цвет для хорошей читаемости
if (card.original != null && card.original!.isNotEmpty)

View file

@ -22,6 +22,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
String _currentAnswer = '';
List<String> _selectedButtonIds = [];
@override
void initState() {
@ -43,6 +44,33 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
});
}
void _onButtonTap(ChoiceOption button) {
setState(() {
final buttonText = button.text ?? '';
if (buttonText.isEmpty) return; // Skip if no text
if (_selectedButtonIds.contains(button.id)) {
// Remove button if already selected
_selectedButtonIds.remove(button.id);
// Remove corresponding text from answer (find last occurrence)
final lastIndex = _currentAnswer.lastIndexOf(buttonText);
if (lastIndex != -1) {
_currentAnswer = _currentAnswer.substring(0, lastIndex) +
_currentAnswer.substring(lastIndex + buttonText.length);
}
} else {
// Add button
_selectedButtonIds.add(button.id);
// Add button text to answer
_currentAnswer += buttonText;
}
_controller.text = _currentAnswer;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _currentAnswer.length),
);
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
@ -80,38 +108,43 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
),
),
// Input field
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,
),
// Buttons with images or text (if available)
if (widget.question.buttons.isNotEmpty) ...[
_buildButtonsGrid(constraints),
SizedBox(height: 24.h),
] else ...[
// Input field (only if no buttons)
Container(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.8,
),
style: TextStyle(
fontSize: 18.sp,
letterSpacing: 2,
fontWeight: FontWeight.w500,
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,
),
textAlign: TextAlign.center,
maxLength: widget.question.correctAnswer.length,
onSubmitted: _submitAnswer,
),
),
SizedBox(height: 24.h),
SizedBox(height: 24.h),
],
// Submit button
ElevatedButton.icon(
@ -201,6 +234,119 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
return parts;
}
Widget _buildButtonsGrid(BoxConstraints constraints) {
final hasImages = widget.question.buttons.any((b) => b.image != null);
final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3;
final childAspectRatio = hasImages ? 1.2 : 2.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: childAspectRatio,
),
itemCount: widget.question.buttons.length,
itemBuilder: (context, index) {
final button = widget.question.buttons[index];
final isSelected = _selectedButtonIds.contains(button.id);
return Material(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
elevation: isSelected ? 4 : 0,
child: InkWell(
onTap: () => _onButtonTap(button),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: button.image != null
? _buildImageButton(button, isSelected)
: _buildTextButton(button, isSelected),
),
),
);
},
);
}
Widget _buildImageButton(ChoiceOption button, bool isSelected) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
button.image!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.image_not_supported,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
},
),
),
),
if (button.text != null && button.text!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
button.text!,
style: TextStyle(
fontSize: 12.sp,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
],
);
}
Widget _buildTextButton(ChoiceOption button, bool isSelected) {
return Center(
child: Text(
button.text ?? '',
style: TextStyle(
fontSize: 18.sp,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
);
}
void _submitAnswer([String? value]) {
final answer = value ?? _currentAnswer;
if (answer.isEmpty) return;
@ -215,6 +361,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
setState(() {
_controller.clear();
_currentAnswer = '';
_selectedButtonIds.clear();
});
}
}

View file

@ -56,6 +56,7 @@ class _SpyTestsStateManager extends TestsStateManager {
int startCalls = 0;
int resumeCalls = 0;
int completeCalls = 0;
Future<void> setStateForTest(TestsState newState) {
return handle((emit) async {
@ -73,6 +74,12 @@ class _SpyTestsStateManager extends TestsStateManager {
void resumeGameSession() {
resumeCalls++;
}
@override
Future<void> completeGameSession() {
completeCalls++;
return super.completeGameSession();
}
}
void main() {
@ -195,12 +202,14 @@ void main() {
testWidgets('passes question audio url to playback callback', (tester) async {
Uri? playedUri;
// Use a presigned URL format that MinIO generates
final audioUrl = 'https://minio.example.com/voice-audio/550e8400-e29b-41d4-a716-446655440000?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20250101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250101T120000Z&X-Amz-Expires=14400&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature';
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'https://example.com/sound.mp3',
audio: audioUrl,
options: const ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
@ -234,7 +243,7 @@ void main() {
await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump();
expect(playedUri, Uri.parse('https://example.com/sound.mp3'));
expect(playedUri, Uri.parse(audioUrl));
});
testWidgets('should display active game state', (tester) async {
@ -444,5 +453,88 @@ void main() {
expect(find.text('Game Error'), findsOneWidget);
expect(find.text('Test error'), findsOneWidget);
});
testWidgets('should show finish button on last question', (tester) async {
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'First question?',
options: const ['A', 'B'],
correctAnswer: 'A',
word: 'first',
),
),
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q2',
question: 'Last question?',
options: const ['C', 'D'],
correctAnswer: 'C',
word: 'last',
),
),
];
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 1, // Last question
currentResult: null,
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Last question?'), findsOneWidget);
expect(find.text('Finish'), findsOneWidget);
expect(find.text('Next'), findsNothing);
});
testWidgets('finish button should complete game session', (tester) async {
final questions = [
GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Only question?',
options: const ['A', 'B'],
correctAnswer: 'A',
word: 'only',
),
),
];
await testsStateManager.setStateForTest(
TestsState.gameSessionActive(
test: TestDto(id: 'test1', name: 'Test Game', questions: const []),
questions: questions,
currentQuestionIndex: 0, // Last question (only one)
currentResult: null,
questionResults: const {},
isAnswerSubmitted: false,
isCorrect: false,
),
);
await pumpGamePage(tester, child: const GamePage(testId: 'test1'));
await tester.pump();
expect(find.text('Finish'), findsOneWidget);
expect(testsStateManager.completeCalls, equals(0));
await tester.tap(find.text('Finish'));
await tester.pump();
// Wait for async completion
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(testsStateManager.completeCalls, equals(1));
});
});
}

View file

@ -135,11 +135,13 @@ void main() {
});
testWidgets('should show audio button when audio is provided', (tester) async {
// Use a presigned URL format that MinIO generates
final audioUrl = 'https://minio.example.com/voice-audio/550e8400-e29b-41d4-a716-446655440000?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20250101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250101T120000Z&X-Amz-Expires=14400&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature';
final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'https://example.com/sound.mp3',
audio: audioUrl,
options: ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
@ -153,11 +155,13 @@ void main() {
testWidgets('should call audio playback with passed url', (tester) async {
Uri? playedUri;
// Use a presigned URL format that MinIO generates
final audioUrl = 'https://minio.example.com/voice-audio/550e8400-e29b-41d4-a716-446655440000?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20250101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250101T120000Z&X-Amz-Expires=14400&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature';
final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion(
id: 'q1',
question: 'Listen and choose',
audio: 'https://example.com/sound.mp3',
audio: audioUrl,
options: ['A', 'B'],
correctAnswer: 'A',
word: 'sound',
@ -178,7 +182,7 @@ void main() {
await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump();
expect(playedUri, Uri.parse('https://example.com/sound.mp3'));
expect(playedUri, Uri.parse(audioUrl));
});
testWidgets('should handle image loading error gracefully', (tester) async {