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 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 - Updated tests_state_manager: converts TestButtonDto and MatrixCardDto to use imageUrl when available
- All widgets maintain backward compatibility with fallback to ApiConfigV2 URL building - 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 ### Common Libraries
- **mnemo_cards_common**: Shared models and utilities - **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 - 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 - Created theme-aware loading screen components using StateBuilder
- Added assets/images/ to pubspec.yaml and updated app.dart with new loading UI - 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 - [ ] **Integration Tests**: Implement comprehensive integration testing
- API endpoint testing - API endpoint testing

View file

@ -21,9 +21,14 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
const [newLanguage, setNewLanguage] = useState('en') const [newLanguage, setNewLanguage] = useState('en')
// Load voices for the card // Load voices for the card
const { data: voicesData, isLoading } = useQuery({ const { data: voicesData, isLoading, refetch } = useQuery({
queryKey: ['cardVoices', cardId], 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) // Voices should still load even if parent form is disabled (e.g. during save)
enabled: !!cardId, enabled: !!cardId,
}) })
@ -38,10 +43,19 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
}) })
return voicesApi.addCardVoice(cardId, voiceUrl, language) return voicesApi.addCardVoice(cardId, voiceUrl, language)
}, },
onSuccess: () => { onSuccess: async () => {
console.log('✅ CardVoicesManager: addVoiceMutation.onSuccess') 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') toast.success('Voice added successfully')
// Auto-close form and reset after successful addition
setShowAddForm(false) setShowAddForm(false)
setNewAudio(undefined) setNewAudio(undefined)
setNewLanguage('en') setNewLanguage('en')
@ -66,30 +80,7 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
}, },
}) })
const handleAddVoice = () => { // handleAddVoice is no longer needed - voice is added automatically after upload
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,
})
}
const handleRemoveVoice = (voiceId: string) => { const handleRemoveVoice = (voiceId: string) => {
if (confirm('Are you sure you want to remove this voice?')) { if (confirm('Are you sure you want to remove this voice?')) {
@ -124,23 +115,34 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
<AudioUpload <AudioUpload
label="Audio File" label="Audio File"
value={newAudio} value={newAudio}
onChange={(value) => { onChange={async (value) => {
console.log('🔍 CardVoicesManager: AudioUpload onChange', { value, cardId }) console.log('🔍 CardVoicesManager: AudioUpload onChange', { value, cardId })
setNewAudio(value) 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} language={newLanguage}
onLanguageChange={setNewLanguage} onLanguageChange={setNewLanguage}
disabled={disabled || addVoiceMutation.isPending} disabled={disabled || addVoiceMutation.isPending}
/> />
{addVoiceMutation.isPending && (
<div className="text-sm text-muted-foreground">
Adding voice...
</div>
)}
<div className="flex items-center space-x-2"> <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 <Button
type="button" type="button"
variant="outline" variant="outline"

View file

@ -136,11 +136,11 @@ class AdminTestsApiV2 {
final parsed = CardImageStorage.tryParseBase64Image(v); final parsed = CardImageStorage.tryParseBase64Image(v);
if (parsed != null) { if (parsed != null) {
try { try {
final objectId = await _minioService.uploadFile( final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
bytes: parsed.bytes, bytes: parsed.bytes,
contentType: parsed.contentType, contentType: parsed.contentType,
); );
return objectId; return objectId;
} catch (e) { } catch (e) {
print('Error uploading test image to MinIO: $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 it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) { if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
objectId: v, objectId: v,
); );
return presignedUrl; return presignedUrl;
@ -184,7 +184,7 @@ class AdminTestsApiV2 {
// If cardId is UUID, generate presigned URL // If cardId is UUID, generate presigned URL
if (_isUuid(cardId)) { if (_isUuid(cardId)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
objectId: cardId, objectId: cardId,
); );
return presignedUrl; 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( final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
bytes: fileData.bytes, bytes: fileData.bytes,
contentType: fileData.contentType, contentType: fileData.contentType,
); );
// Generate presigned URL for preview // Generate presigned URL for preview
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
objectId: objectId, objectId: objectId,
); );
return _ok({ return _ok({
'objectId': objectId, 'objectId': objectId,
@ -325,7 +325,6 @@ class MediaApiV2 {
// Validate bucket // Validate bucket
final validBuckets = [ final validBuckets = [
MinioConfig.cardImagesBucket, MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket, MinioConfig.voiceAudioBucket,
]; ];
if (!validBuckets.contains(bucket)) { if (!validBuckets.contains(bucket)) {
@ -389,7 +388,6 @@ class MediaApiV2 {
// Validate bucket // Validate bucket
final validBuckets = [ final validBuckets = [
MinioConfig.cardImagesBucket, MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket, MinioConfig.voiceAudioBucket,
]; ];
if (!validBuckets.contains(bucket)) { if (!validBuckets.contains(bucket)) {

View file

@ -10,8 +10,9 @@ class MinioConfig {
final String region; final String region;
// Bucket names // 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 cardImagesBucket = 'card-images';
static const String testImagesBucket = 'test-images';
static const String voiceAudioBucket = 'voice-audio'; static const String voiceAudioBucket = 'voice-audio';
// Presigned URL expiration (4 hours) // Presigned URL expiration (4 hours)

View file

@ -28,7 +28,6 @@ class MinioService {
Future<void> ensureBucketsExist() async { Future<void> ensureBucketsExist() async {
final buckets = [ final buckets = [
MinioConfig.cardImagesBucket, MinioConfig.cardImagesBucket,
MinioConfig.testImagesBucket,
MinioConfig.voiceAudioBucket, MinioConfig.voiceAudioBucket,
]; ];
print('Config endpoint: ${_config.endpoint}'); 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:drift_postgres/drift_postgres.dart';
import 'package:mnemo_cards_backend/database/database.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/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_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart'; import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@ -119,7 +120,7 @@ class TestManager {
if (parsed != null) { if (parsed != null) {
try { try {
final objectId = await _minioService.uploadFile( final objectId = await _minioService.uploadFile(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
bytes: parsed.bytes, bytes: parsed.bytes,
contentType: parsed.contentType, contentType: parsed.contentType,
); );
@ -153,7 +154,7 @@ class TestManager {
// If it's a valid UUID (object ID in MinIO), generate presigned URL // If it's a valid UUID (object ID in MinIO), generate presigned URL
if (_isUuid(v)) { if (_isUuid(v)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
objectId: v, objectId: v,
); );
return presignedUrl; return presignedUrl;
@ -165,7 +166,7 @@ class TestManager {
// If cardId is UUID, generate presigned URL // If cardId is UUID, generate presigned URL
if (_isUuid(cardId)) { if (_isUuid(cardId)) {
final presignedUrl = await _minioService.getPresignedUrl( final presignedUrl = await _minioService.getPresignedUrl(
bucket: MinioConfig.testImagesBucket, bucket: MinioConfig.cardImagesBucket,
objectId: cardId, objectId: cardId,
); );
return presignedUrl; return presignedUrl;
@ -179,6 +180,38 @@ class TestManager {
return v; 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( Future<TestStatisticsDto?> _testStatisticsDto(
String userId, String testId) async { String userId, String testId) async {
final statistics = await _db.testDao.getTestStatistics(userId, testId); 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); final uiDataForResponse = Map<String, dynamic>.from(uiData);
if (uiDataForResponse['image'] != null) { if (uiDataForResponse['audio'] != null) {
uiDataForResponse['image'] = await _imageValueToApiUrl( final audioUrl = await _audioValueToApiUrl(
uiDataForResponse['image']?.toString(), uiDataForResponse['audio']?.toString(),
packId: packId,
); );
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( // Buttons already have image (objectId) and imageUrl (presigned URL)
normalizedButtons.map((b) async { // from normalization above, no need to convert again
if (b is Map<String, dynamic> && b['image'] != null) { questionJson['buttons'] = normalizedButtons;
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;
questionJson.addAll(uiDataForResponse); questionJson.addAll(uiDataForResponse);
// Matrix question: allow storing only config (matrixSize) and generate // Matrix question: allow storing only config (matrixSize) and generate
@ -392,8 +407,8 @@ class TestManager {
.map( .map(
(c) => <String, dynamic>{ (c) => <String, dynamic>{
'id': c.id, 'id': c.id,
// store as cardId; we will convert to URL below // store image as objectId (MinIO) or filename
'image': c.id, 'image': c.image,
'original': c.original, 'original': c.original,
'translation': c.translation, 'translation': c.translation,
}, },
@ -428,16 +443,19 @@ class TestManager {
// Convert button images to URLs (works for both TestButtonDto and matrix cards) // Convert button images to URLs (works for both TestButtonDto and matrix cards)
// Add imageUrl while keeping image (objectId) for admin // Add imageUrl while keeping image (objectId) for admin
// All images now use the same cardImagesBucket
final updatedButtons = await Future.wait( final updatedButtons = await Future.wait(
(questionJson['buttons'] as List<dynamic>? ?? []).map((button) async { (questionJson['buttons'] as List<dynamic>? ?? []).map((button) async {
if (button is Map<String, dynamic> && button['image'] != null) { if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button); final buttonMap = Map<String, dynamic>.from(button);
final imageValue = buttonMap['image']?.toString(); final imageValue = buttonMap['image']?.toString();
// Keep image as objectId, add imageUrl as presigned URL
// Convert image to presigned URL
final imageUrl = await _imageValueToApiUrl( final imageUrl = await _imageValueToApiUrl(
imageValue, imageValue,
packId: packId, packId: packId,
); );
if (imageUrl != null) { if (imageUrl != null) {
buttonMap['imageUrl'] = imageUrl; buttonMap['imageUrl'] = imageUrl;
} }

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:drift/drift.dart' hide isNull; import 'package:drift/drift.dart' hide isNull;
import 'package:mnemo_cards_backend/database/database.dart'; 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_backend/tests/test_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -9,6 +10,7 @@ import 'package:test/test.dart';
void main() { void main() {
late AppDatabase db; late AppDatabase db;
late TestManager testManager; late TestManager testManager;
late MinioService minioService;
setUpAll(() async { setUpAll(() async {
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost'; final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
@ -37,7 +39,22 @@ void main() {
// Create tables if needed (idempotent in drift for Postgres). // Create tables if needed (idempotent in drift for Postgres).
await Migrator(db).createAll(); 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 { tearDownAll(() async {

View file

@ -8,10 +8,6 @@ void main() {
MinioConfig.cardImagesBucket, MinioConfig.cardImagesBucket,
equals('card-images'), equals('card-images'),
); );
expect(
MinioConfig.testImagesBucket,
equals('test-images'),
);
expect( expect(
MinioConfig.voiceAudioBucket, MinioConfig.voiceAudioBucket,
equals('voice-audio'), equals('voice-audio'),
@ -37,20 +33,11 @@ void main() {
test('should validate bucket constants', () { test('should validate bucket constants', () {
// Test that bucket names are correctly defined // Test that bucket names are correctly defined
expect(MinioConfig.cardImagesBucket, isNotEmpty); expect(MinioConfig.cardImagesBucket, isNotEmpty);
expect(MinioConfig.testImagesBucket, isNotEmpty);
expect(MinioConfig.voiceAudioBucket, isNotEmpty); expect(MinioConfig.voiceAudioBucket, isNotEmpty);
// Test that bucket names are different // Test that bucket names are different
expect( expect(
MinioConfig.cardImagesBucket, MinioConfig.cardImagesBucket,
isNot(equals(MinioConfig.testImagesBucket)),
);
expect(
MinioConfig.cardImagesBucket,
isNot(equals(MinioConfig.voiceAudioBucket)),
);
expect(
MinioConfig.testImagesBucket,
isNot(equals(MinioConfig.voiceAudioBucket)), 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 /// 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 @freezed
abstract class InputLettersQuestion with _$InputLettersQuestion { abstract class InputLettersQuestion with _$InputLettersQuestion {
const factory InputLettersQuestion({ const factory InputLettersQuestion({
@ -65,6 +66,7 @@ abstract class InputLettersQuestion with _$InputLettersQuestion {
required String correctAnswer, required String correctAnswer,
required String word, required String word,
@Default('inputLetters') String type, @Default('inputLetters') String type,
@Default([]) List<ChoiceOption> buttons, // Buttons with text or images for user to tap
}) = _InputLettersQuestion; }) = _InputLettersQuestion;
factory InputLettersQuestion.fromJson(Map<String, dynamic> json) => factory InputLettersQuestion.fromJson(Map<String, dynamic> json) =>

View file

@ -1131,7 +1131,7 @@ as String,
mixin _$InputLettersQuestion { mixin _$InputLettersQuestion {
String get id; String get template;// e.g., "H _ _ L _" 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 /// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -1144,16 +1144,16 @@ $InputLettersQuestionCopyWith<InputLettersQuestion> get copyWith => _$InputLette
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory $InputLettersQuestionCopyWith(InputLettersQuestion value, $Res Function(InputLettersQuestion) _then) = _$InputLettersQuestionCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,template: null == template ? _self.template : template // 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?,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,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,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) { switch (_that) {
case _InputLettersQuestion() when $default != null: 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(); 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) { switch (_that) {
case _InputLettersQuestion(): 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'); 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) { switch (_that) {
case _InputLettersQuestion() when $default != null: 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; return null;
} }
@ -1331,7 +1332,7 @@ return $default(_that.id,_that.template,_that.image,_that.audio,_that.correctAns
@JsonSerializable() @JsonSerializable()
class _InputLettersQuestion implements InputLettersQuestion { 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); factory _InputLettersQuestion.fromJson(Map<String, dynamic> json) => _$InputLettersQuestionFromJson(json);
@override final String id; @override final String id;
@ -1342,6 +1343,13 @@ class _InputLettersQuestion implements InputLettersQuestion {
@override final String correctAnswer; @override final String correctAnswer;
@override final String word; @override final String word;
@override@JsonKey() final String type; @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 /// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -1356,16 +1364,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory _$InputLettersQuestionCopyWith(_InputLettersQuestion value, $Res Function(_InputLettersQuestion) _then) = __$InputLettersQuestionCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of InputLettersQuestion
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? 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( return _then(_InputLettersQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,template: null == template ? _self.template : template // 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?,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,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,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, correctAnswer: json['correctAnswer'] as String,
word: json['word'] as String, word: json['word'] as String,
type: json['type'] as String? ?? 'inputLetters', 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( Map<String, dynamic> _$InputLettersQuestionToJson(
@ -128,6 +133,7 @@ Map<String, dynamic> _$InputLettersQuestionToJson(
'correctAnswer': instance.correctAnswer, 'correctAnswer': instance.correctAnswer,
'word': instance.word, 'word': instance.word,
'type': instance.type, 'type': instance.type,
'buttons': instance.buttons,
}; };
_MatchQuestion _$MatchQuestionFromJson(Map<String, dynamic> json) => _MatchQuestion _$MatchQuestionFromJson(Map<String, dynamic> json) =>

View file

@ -430,10 +430,27 @@ class TestsStateManager extends StateManager<TestsState> {
), ),
)); ));
} else if (question is InputButtonsTestQuestionBody) { } else if (question is InputButtonsTestQuestionBody) {
// Match question - two columns of items to connect // Convert InputButtonsTestQuestionBody to InputLettersQuestion
// Note: Using simplified approach since correctPairs structure may vary // Convert buttons to ChoiceOptions (supporting both text and images)
// This is a placeholder for future implementation when proper structure is available final optionItems = question.buttons.map((b) {
continue; // Skip for now, will be implemented when backend supports it 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) { } else if (question is MatrixTestQuestionBody) {
final id = question.id?.toString() ?? 'q_${questions.length}'; 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 appScope = ScopeProvider.of<AppScopeContainer>(context, listen: false);
final userScope = appScope?.userScopeHolder.scope; final userScope = appScope?.userScopeHolder.scope;
if (userScope != null) { if (userScope != null) {
userScope.testsModule.testsStateManager.completeGameSession(); await userScope.testsModule.testsStateManager.completeGameSession();
} }
} }

View file

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

View file

@ -440,11 +440,9 @@ class _CardSide extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Padding( 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), padding: const EdgeInsets.symmetric(horizontal: 48),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Original текст - нормальный цвет для хорошей читаемости // Original текст - нормальный цвет для хорошей читаемости
if (card.original != null && card.original!.isNotEmpty) if (card.original != null && card.original!.isNotEmpty)

View file

@ -22,6 +22,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
String _currentAnswer = ''; String _currentAnswer = '';
List<String> _selectedButtonIds = [];
@override @override
void initState() { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
@ -80,38 +108,43 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
), ),
), ),
// Input field // Buttons with images or text (if available)
Container( if (widget.question.buttons.isNotEmpty) ...[
constraints: BoxConstraints( _buildButtonsGrid(constraints),
maxWidth: constraints.maxWidth * 0.8, SizedBox(height: 24.h),
), ] else ...[
child: TextField( // Input field (only if no buttons)
controller: _controller, Container(
focusNode: _focusNode, constraints: BoxConstraints(
decoration: InputDecoration( maxWidth: constraints.maxWidth * 0.8,
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( child: TextField(
fontSize: 18.sp, controller: _controller,
letterSpacing: 2, focusNode: _focusNode,
fontWeight: FontWeight.w500, 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 // Submit button
ElevatedButton.icon( ElevatedButton.icon(
@ -201,6 +234,119 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
return parts; 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]) { void _submitAnswer([String? value]) {
final answer = value ?? _currentAnswer; final answer = value ?? _currentAnswer;
if (answer.isEmpty) return; if (answer.isEmpty) return;
@ -215,6 +361,7 @@ class _InputLettersWidgetState extends State<InputLettersWidget> {
setState(() { setState(() {
_controller.clear(); _controller.clear();
_currentAnswer = ''; _currentAnswer = '';
_selectedButtonIds.clear();
}); });
} }
} }

View file

@ -56,6 +56,7 @@ class _SpyTestsStateManager extends TestsStateManager {
int startCalls = 0; int startCalls = 0;
int resumeCalls = 0; int resumeCalls = 0;
int completeCalls = 0;
Future<void> setStateForTest(TestsState newState) { Future<void> setStateForTest(TestsState newState) {
return handle((emit) async { return handle((emit) async {
@ -73,6 +74,12 @@ class _SpyTestsStateManager extends TestsStateManager {
void resumeGameSession() { void resumeGameSession() {
resumeCalls++; resumeCalls++;
} }
@override
Future<void> completeGameSession() {
completeCalls++;
return super.completeGameSession();
}
} }
void main() { void main() {
@ -195,12 +202,14 @@ void main() {
testWidgets('passes question audio url to playback callback', (tester) async { testWidgets('passes question audio url to playback callback', (tester) async {
Uri? playedUri; 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 = [ final questions = [
GameQuestion.multipleChoice( GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
id: 'q1', id: 'q1',
question: 'Listen and choose', question: 'Listen and choose',
audio: 'https://example.com/sound.mp3', audio: audioUrl,
options: const ['A', 'B'], options: const ['A', 'B'],
correctAnswer: 'A', correctAnswer: 'A',
word: 'sound', word: 'sound',
@ -234,7 +243,7 @@ void main() {
await tester.tap(find.byIcon(Icons.volume_up)); await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump(); 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 { testWidgets('should display active game state', (tester) async {
@ -444,5 +453,88 @@ void main() {
expect(find.text('Game Error'), findsOneWidget); expect(find.text('Game Error'), findsOneWidget);
expect(find.text('Test 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 { 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( final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
id: 'q1', id: 'q1',
question: 'Listen and choose', question: 'Listen and choose',
audio: 'https://example.com/sound.mp3', audio: audioUrl,
options: ['A', 'B'], options: ['A', 'B'],
correctAnswer: 'A', correctAnswer: 'A',
word: 'sound', word: 'sound',
@ -153,11 +155,13 @@ void main() {
testWidgets('should call audio playback with passed url', (tester) async { testWidgets('should call audio playback with passed url', (tester) async {
Uri? playedUri; 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( final question = GameQuestion.multipleChoice(
MultipleChoiceQuestion( MultipleChoiceQuestion(
id: 'q1', id: 'q1',
question: 'Listen and choose', question: 'Listen and choose',
audio: 'https://example.com/sound.mp3', audio: audioUrl,
options: ['A', 'B'], options: ['A', 'B'],
correctAnswer: 'A', correctAnswer: 'A',
word: 'sound', word: 'sound',
@ -178,7 +182,7 @@ void main() {
await tester.tap(find.byIcon(Icons.volume_up)); await tester.tap(find.byIcon(Icons.volume_up));
await tester.pump(); 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 { testWidgets('should handle image loading error gracefully', (tester) async {