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 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
|
||||||
|
|
|
||||||
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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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://'));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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}';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue