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
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:
parent
8b754bbd9f
commit
de06c74b72
21 changed files with 484 additions and 179 deletions
|
|
@ -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
|
||||
|
|
|
|||
8
TODO.md
8
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ class MinioService {
|
|||
Future<void> ensureBucketsExist() async {
|
||||
final buckets = [
|
||||
MinioConfig.cardImagesBucket,
|
||||
MinioConfig.testImagesBucket,
|
||||
MinioConfig.voiceAudioBucket,
|
||||
];
|
||||
print('Config endpoint: ${_config.endpoint}');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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://'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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}';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue