stuff
Some checks are pending
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 Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (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

This commit is contained in:
Dmitry 2025-12-17 04:42:58 +03:00
parent d869e5c17d
commit dfc5ba43c3
10 changed files with 1160 additions and 151 deletions

View file

@ -0,0 +1,407 @@
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { ImageUpload } from '@/components/ui/image-upload'
import { CardVoicesManager } from '@/components/CardVoicesManager'
interface CardEditorPreviewProps {
formData: {
packId: string
original: string
translation: string
mnemo: string
transcription: string
transcriptionMnemo: string
back: string
image: string | undefined
imageBack: string | undefined
}
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
cardId?: string
disabled?: boolean
packColor?: string
}
// Helper function to format text with mnemo syntax (similar to Flutter's MnemoText)
function formatMnemoText(text: string | null | undefined): React.ReactNode {
if (!text) return null
// Replace \n with actual line breaks
const formattedText = text.replaceAll('\\n', '\n')
// Find all matches of text in curly braces
const matches = Array.from(formattedText.matchAll(/\{(.*?)\}/g))
if (matches.length === 0) {
return <span>{formattedText}</span>
}
const elements: React.ReactNode[] = []
let lastIndex = 0
matches.forEach((match) => {
// Add text before the match
if (match.index !== undefined && match.index > lastIndex) {
elements.push(
<span key={`text-${lastIndex}`}>
{formattedText.substring(lastIndex, match.index)}
</span>
)
}
// Extract text inside braces
let mnemoText = match[1]
// Check if text starts with color code #RRGGBB
let color = '#ef4444' // Default red color (mnemoRed)
if (mnemoText.startsWith('#') && mnemoText.length > 7) {
const colorCode = mnemoText.substring(0, 7)
color = colorCode
mnemoText = mnemoText.substring(7)
}
// Add highlighted text
elements.push(
<span key={`mnemo-${match.index}`} style={{ color }}>
{mnemoText}
</span>
)
lastIndex = match.index! + match[0].length
})
// Add remaining text
if (lastIndex < formattedText.length) {
elements.push(
<span key={`text-${lastIndex}`}>
{formattedText.substring(lastIndex)}
</span>
)
}
return <>{elements}</>
}
// Helper to get image source
function getImageSrc(image?: string): string | undefined {
if (!image) return undefined
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
return image
}
return `data:image/png;base64,${image}`
}
export function CardEditorPreview({
formData,
onFormDataChange,
cardId,
disabled = false,
packColor = '#6b7280', // Default gray border color
}: CardEditorPreviewProps) {
return (
<div className="flex flex-col items-center space-y-4">
{/* Cards Preview - Front and Back side by side */}
<div className="w-full max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Front side */}
<div className="space-y-2">
<div className="text-sm font-medium text-center text-muted-foreground">Front Side</div>
<div
className="rounded-3xl shadow-lg"
style={{
borderColor: packColor,
backgroundColor: 'white',
borderWidth: '3px',
borderStyle: 'solid',
aspectRatio: '0.66',
minHeight: '400px',
maxHeight: '600px',
}}
>
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
{/* Top section: Original, Translation */}
<div className="w-full px-6 pt-6 pb-4 flex-shrink-0">
{/* Original - large, bold */}
<div className="mb-3">
<Input
value={formData.original}
onChange={(e) =>
onFormDataChange({ original: e.target.value })
}
placeholder="Original text"
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
disabled={disabled}
style={{
fontSize: '32px',
fontWeight: 700,
}}
/>
</div>
{/* Translation - smaller, gray */}
<div className="mb-3">
<Input
value={formData.translation}
onChange={(e) =>
onFormDataChange({ translation: e.target.value })
}
placeholder="Translation"
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
disabled={disabled}
style={{
fontSize: '22px',
fontWeight: 400,
color: formData.translation ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
}}
/>
</div>
</div>
{/* Image in the middle */}
<div className="flex-1 bg-gray-50 flex items-center justify-center relative min-h-0">
{getImageSrc(formData.image) ? (
<img
src={getImageSrc(formData.image)}
alt="Card"
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
) : (
<div className="text-gray-400">
<svg
className="w-24 h-24"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
)}
</div>
{/* Mnemo phrase at the bottom */}
<div className="w-full px-6 py-6 bg-white flex-shrink-0">
<div className="text-center min-h-[60px] flex items-center justify-center">
{formData.mnemo ? (
<div className="w-full">
<Input
value={formData.mnemo}
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
placeholder="Mnemo phrase"
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent w-full mb-2"
disabled={disabled}
style={{
fontSize: '24px',
fontWeight: 600,
}}
/>
{/* Preview of formatted mnemo text */}
<div
className="text-center"
style={{
fontSize: '24px',
fontWeight: 600,
}}
>
{formatMnemoText(formData.mnemo)}
</div>
</div>
) : (
<Input
value={formData.mnemo}
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
placeholder="Mnemo phrase"
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent w-full"
disabled={disabled}
style={{
fontSize: '24px',
fontWeight: 600,
}}
/>
)}
</div>
</div>
</div>
</div>
{/* Back side */}
<div
className="rounded-3xl shadow-lg"
style={{
borderColor: packColor,
backgroundColor: 'white',
borderWidth: '3px',
borderStyle: 'solid',
aspectRatio: '0.66',
minHeight: '400px',
maxHeight: '600px',
}}
>
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
{formData.imageBack ? (
<>
<div className="flex-1 flex items-center justify-center bg-gray-50 min-h-0">
<img
src={getImageSrc(formData.imageBack)}
alt="Back"
className="w-full h-full object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
<div className="p-6 bg-white flex-shrink-0">
<Textarea
value={formData.back || formData.original}
onChange={(e) =>
onFormDataChange({ back: e.target.value })
}
placeholder="Back side text"
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
disabled={disabled}
style={{
fontSize: '28px',
fontWeight: 700,
lineHeight: '1.2',
}}
/>
</div>
</>
) : (
<div className="h-full flex items-center justify-center p-6">
<Textarea
value={formData.back || formData.original}
onChange={(e) =>
onFormDataChange({ back: e.target.value })
}
placeholder="Back side text"
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
disabled={disabled}
style={{
fontSize: '28px',
fontWeight: 700,
lineHeight: '1.2',
}}
/>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Edit fields below the preview */}
<div className="w-full max-w-5xl space-y-4 p-4 border rounded-lg bg-muted/30">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Original *</label>
<Input
value={formData.original}
onChange={(e) =>
onFormDataChange({ original: e.target.value })
}
placeholder="e.g. cerdo"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Translation *</label>
<Input
value={formData.translation}
onChange={(e) =>
onFormDataChange({ translation: e.target.value })
}
placeholder="e.g. pig"
disabled={disabled}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Mnemo *</label>
<Input
value={formData.mnemo}
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
placeholder="e.g. [pig] with heart"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Back Side</label>
<Textarea
value={formData.back}
onChange={(e) => onFormDataChange({ back: e.target.value })}
placeholder="Additional information on the back of the card"
rows={3}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<ImageUpload
label="Front Image"
value={formData.image}
onChange={(value) => onFormDataChange({ image: value })}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Back Image"
value={formData.imageBack}
onChange={(value) => onFormDataChange({ imageBack: value })}
disabled={disabled}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Transcription</label>
<Input
value={formData.transcription}
onChange={(e) =>
onFormDataChange({ transcription: e.target.value })
}
placeholder="e.g. sɛrdo"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Transcription Mnemo</label>
<Input
value={formData.transcriptionMnemo}
onChange={(e) =>
onFormDataChange({ transcriptionMnemo: e.target.value })
}
placeholder="e.g. pig with heart"
disabled={disabled}
/>
</div>
</div>
{/* Voice Controls - only show if cardId exists */}
{cardId && (
<div className="space-y-2">
<CardVoicesManager cardId={cardId} disabled={disabled} />
</div>
)}
</div>
</div>
)
}

View file

@ -5,7 +5,7 @@ import { X, Image as ImageIcon } from 'lucide-react'
interface ImageUploadProps {
label?: string
value?: string // base64 string
value?: string // base64 string or URL
onChange: (value: string | undefined) => void
accept?: string
maxSizeMB?: number
@ -106,7 +106,13 @@ export function ImageUpload({
<div className="relative">
<div className="relative w-full border rounded-lg overflow-hidden bg-muted">
<img
src={`data:image/png;base64,${value}`}
src={
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('/api/')
? value
: `data:image/png;base64,${value}`
}
alt="Preview"
className="w-full h-48 object-contain cursor-pointer"
onClick={handleClick}

View file

@ -39,6 +39,7 @@ import { ImageUpload } from '@/components/ui/image-upload'
import { CardVoicesManager } from '@/components/CardVoicesManager'
import { BulkCardUpload } from '@/components/BulkCardUpload'
import { BulkCardEditor } from '@/components/BulkCardEditor'
import { CardEditorPreview } from '@/components/CardEditorPreview'
import { packsApi } from '@/api/packs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, List, Grid } from 'lucide-react'
@ -268,6 +269,15 @@ export default function CardsPage() {
return pack?.title || packId
}
// Get pack color by ID
const getPackColor = (packId: string): string | undefined => {
if (!packId || packId === '__no_pack__') {
return undefined
}
const pack = packsData?.items.find(p => p.id === packId)
return pack?.color
}
// Get image source - handles both base64 and URLs
const getImageSrc = (image?: string): string | undefined => {
if (!image) return undefined
@ -553,7 +563,7 @@ export default function CardsPage() {
{/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[900px] max-h-[95vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedCard ? 'Edit Card' : 'Create New Card'}
@ -590,100 +600,14 @@ export default function CardsPage() {
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="original">Original *</Label>
<Input
id="original"
value={formData.original}
onChange={(e) => setFormData(prev => ({ ...prev, original: e.target.value }))}
placeholder="e.g. cerdo"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="translation">Translation *</Label>
<Input
id="translation"
value={formData.translation}
onChange={(e) => setFormData(prev => ({ ...prev, translation: e.target.value }))}
placeholder="e.g. pig"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mnemo">Mnemo *</Label>
<Input
id="mnemo"
value={formData.mnemo}
onChange={(e) => setFormData(prev => ({ ...prev, mnemo: e.target.value }))}
placeholder="e.g. [pig] with heart"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="transcription">Transcription</Label>
<Input
id="transcription"
value={formData.transcription}
onChange={(e) => setFormData(prev => ({ ...prev, transcription: e.target.value }))}
placeholder="e.g. sɛrdo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="transcriptionMnemo">Transcription Mnemo</Label>
<Input
id="transcriptionMnemo"
value={formData.transcriptionMnemo}
onChange={(e) => setFormData(prev => ({ ...prev, transcriptionMnemo: e.target.value }))}
placeholder="e.g. pig with heart"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="back">Back Side</Label>
<Textarea
id="back"
value={formData.back}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, back: e.target.value }))}
placeholder="Additional information on the back of the card"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<ImageUpload
label="Front Image"
value={formData.image}
onChange={(value) => setFormData(prev => ({ ...prev, image: value }))}
<CardEditorPreview
formData={formData}
onFormDataChange={(updates) => setFormData(prev => ({ ...prev, ...updates }))}
cardId={selectedCard?.id ? String(selectedCard.id) : undefined}
disabled={createMutation.isPending || updateMutation.isPending}
packColor={getPackColor(formData.packId)}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Back Image"
value={formData.imageBack}
onChange={(value) => setFormData(prev => ({ ...prev, imageBack: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
</div>
{selectedCard && selectedCard.id && (
<div className="space-y-2">
<CardVoicesManager
cardId={selectedCard.id}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}>
Cancel

View file

@ -20,6 +20,41 @@ class AdminTestsApiV2 {
AdminTestsApiV2(this._db);
// Helper function to check if string is base64 encoded
bool _isBase64(String value) {
if (value.isEmpty) return false;
// Base64 strings are typically long and contain only base64 characters
// Check length (base64 images are usually > 100 chars) and character set
if (value.length < 50) return false;
final base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$');
return base64Regex.hasMatch(value) && value.length > 100;
}
// Helper function to convert base64 image to card and return card ID
Future<String?> _convertBase64ToCard(String base64Image, String? packId) async {
try {
// Create a temporary card with the base64 image
final companion = GameCardsCompanion.insert(
original: 'button_image',
translation: 'button_image',
image: base64Image,
mnemo: drift.Value('button_image'),
);
final cardId = await _db.packDao.createCard(companion);
// If packId is provided, link card to pack
if (packId != null) {
await _db.packDao.addCardToPack(cardId: cardId, packId: packId);
}
return cardId;
} catch (e) {
print('Error converting base64 to card: $e');
return null;
}
}
Response _json(
Object? data, {
int statusCode = 200,
@ -203,7 +238,7 @@ class AdminTestsApiV2 {
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
if (imageValue == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
@ -212,6 +247,17 @@ class AdminTestsApiV2 {
return imageValue;
}
// If packId is null, we can't convert to URL, return as is
if (packId == null) return imageValue;
// Check if it's base64 encoded image
if (_isBase64(imageValue)) {
// Base64 images should be converted to card IDs before saving
// If we see base64 here, it means it wasn't converted during save
// For now, return as is (will be handled during save)
return imageValue;
}
// If it's base64 data URL, extract card ID if possible
// Format: /api/v2/packs/{packId}/cards/{base64}/image or just base64
if (imageValue.contains('/cards/')) {
@ -368,6 +414,10 @@ class AdminTestsApiV2 {
for (final q in existingQuestions) {
await _db.testDao.softDeleteTestQuestion(q.id);
}
// Get packId for the test to convert base64 images to cards
final packId = await _db.testDao.getPackIdForTest(testId);
// Add new questions
int orderIndex = 0;
for (final q in questions) {
@ -377,11 +427,49 @@ class AdminTestsApiV2 {
// Извлекаем ключевые поля
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// Convert base64 images in buttons to card IDs
if (buttons.isNotEmpty) {
final convertedButtons = <Map<String, dynamic>>[];
for (final button in buttons) {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image'] as String;
// Check if it's base64
if (_isBase64(imageValue)) {
// Convert base64 to card
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
buttonMap['image'] = cardId;
}
}
}
convertedButtons.add(buttonMap);
} else {
convertedButtons.add(button as Map<String, dynamic>);
}
}
buttons = convertedButtons;
}
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
if (questionJson['image'] != null) {
final imageValue = questionJson['image'] as String;
// Convert base64 image to card if needed
if (_isBase64(imageValue)) {
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
uiData['image'] = cardId;
} else {
uiData['image'] = imageValue;
}
} else {
uiData['image'] = imageValue;
}
}
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
@ -436,6 +524,15 @@ class AdminTestsApiV2 {
// Add questions if provided
if (questions != null) {
// Get packId if test is linked to a pack
String? packId;
try {
packId = await _db.testDao.getPackIdForTest(newTest);
} catch (e) {
// Test might not be linked to a pack yet
packId = null;
}
int orderIndex = 0;
for (final q in questions) {
final questionJson = q as Map<String, dynamic>;
@ -444,11 +541,49 @@ class AdminTestsApiV2 {
// Извлекаем ключевые поля
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
var buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// Convert base64 images in buttons to card IDs
if (buttons.isNotEmpty) {
final convertedButtons = <Map<String, dynamic>>[];
for (final button in buttons) {
if (button is Map<String, dynamic>) {
final buttonMap = Map<String, dynamic>.from(button);
if (buttonMap['image'] != null) {
final imageValue = buttonMap['image'] as String;
// Check if it's base64
if (_isBase64(imageValue)) {
// Convert base64 to card
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
buttonMap['image'] = cardId;
}
}
}
convertedButtons.add(buttonMap);
} else {
convertedButtons.add(button as Map<String, dynamic>);
}
}
buttons = convertedButtons;
}
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
if (questionJson['image'] != null) {
final imageValue = questionJson['image'] as String;
// Convert base64 image to card if needed
if (_isBase64(imageValue)) {
final cardId = await _convertBase64ToCard(imageValue, packId);
if (cardId != null) {
uiData['image'] = cardId;
} else {
uiData['image'] = imageValue;
}
} else {
uiData['image'] = imageValue;
}
}
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];

View file

@ -52,6 +52,29 @@ class TestManager {
final test = await _db.testDao.getTestById(testId);
if (test == null) return null;
// Get packId for the test to convert image IDs to URLs
final packId = await _db.testDao.getPackIdForTest(testId);
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
return imageValue;
}
// If it looks like a UUID (card ID), convert to URL
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Otherwise, assume it's already a card ID and convert
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
final questions = await _db.testDao.getTestQuestions(testId);
final statistics = await _testStatisticsDto(user.id!, testId);
@ -65,19 +88,41 @@ class TestManager {
};
// Parse options (JSON array of buttons)
List<dynamic> buttons = [];
try {
final options = json.decode(q.options) as List<dynamic>;
questionJson['buttons'] = options;
buttons = json.decode(q.options) as List<dynamic>;
} catch (e) {
questionJson['buttons'] = [];
buttons = [];
}
// Convert button images to URLs
final buttonsWithUrls = buttons.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
);
return buttonMap;
}
return button;
}).toList();
questionJson['buttons'] = buttonsWithUrls;
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
// Convert question image to URL
if (uiData['image'] != null) {
uiData['image'] = _convertImageToUrl(
uiData['image'] as String?,
packId,
);
}
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore
@ -162,6 +207,26 @@ class TestManager {
final tests = await _db.testDao.getTestsByPackId(packId);
// Helper function to convert image ID to URL
String? _convertImageToUrl(String? imageValue, String? packId) {
if (imageValue == null || packId == null) return imageValue;
// If it's already a proper URL, return as is
if (imageValue.startsWith('http://') ||
imageValue.startsWith('https://') ||
(imageValue.startsWith('/api/') && imageValue.contains('/cards/') && imageValue.endsWith('/image'))) {
return imageValue;
}
// If it looks like a UUID (card ID), convert to URL
if (RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false).hasMatch(imageValue)) {
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Otherwise, assume it's already a card ID and convert
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
@ -176,19 +241,41 @@ class TestManager {
};
// Parse options (JSON array of buttons)
List<dynamic> buttons = [];
try {
final options = json.decode(q.options) as List<dynamic>;
questionJson['buttons'] = options;
buttons = json.decode(q.options) as List<dynamic>;
} catch (e) {
questionJson['buttons'] = [];
buttons = [];
}
// Convert button images to URLs
final buttonsWithUrls = buttons.map((button) {
if (button is Map<String, dynamic> && button['image'] != null) {
final buttonMap = Map<String, dynamic>.from(button);
buttonMap['image'] = _convertImageToUrl(
buttonMap['image'] as String?,
packId,
);
return buttonMap;
}
return button;
}).toList();
questionJson['buttons'] = buttonsWithUrls;
// Add answer
questionJson['answer'] = q.answer;
// Parse uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
// Convert question image to URL
if (uiData['image'] != null) {
uiData['image'] = _convertImageToUrl(
uiData['image'] as String?,
packId,
);
}
questionJson.addAll(uiData);
} catch (e) {
// If uiData is empty or invalid, ignore

View file

@ -22,6 +22,19 @@ abstract class GameQuestion with _$GameQuestion {
_$GameQuestionFromJson(json);
}
/// Choice option for multiple choice questions - can have text or image
@freezed
abstract class ChoiceOption with _$ChoiceOption {
const factory ChoiceOption({
required String id,
String? text,
String? image,
}) = _ChoiceOption;
factory ChoiceOption.fromJson(Map<String, dynamic> json) =>
_$ChoiceOptionFromJson(json);
}
/// Multiple choice question - user selects one correct answer from options
@freezed
abstract class MultipleChoiceQuestion with _$MultipleChoiceQuestion {
@ -30,7 +43,8 @@ abstract class MultipleChoiceQuestion with _$MultipleChoiceQuestion {
required String question,
String? image,
String? audio,
required List<String> options,
@Default([]) List<String> options, // Deprecated: use optionItems instead
@Default([]) List<ChoiceOption> optionItems, // New: supports both text and images
required String correctAnswer,
required String word, // associated word for statistics
@Default('multipleChoice') String type,

View file

@ -552,10 +552,281 @@ $MatrixQuestionCopyWith<$Res> get question {
}
/// @nodoc
mixin _$ChoiceOption {
String get id; String? get text; String? get image;
/// Create a copy of ChoiceOption
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChoiceOptionCopyWith<ChoiceOption> get copyWith => _$ChoiceOptionCopyWithImpl<ChoiceOption>(this as ChoiceOption, _$identity);
/// Serializes this ChoiceOption to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChoiceOption&&(identical(other.id, id) || other.id == id)&&(identical(other.text, text) || other.text == text)&&(identical(other.image, image) || other.image == image));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,text,image);
@override
String toString() {
return 'ChoiceOption(id: $id, text: $text, image: $image)';
}
}
/// @nodoc
abstract mixin class $ChoiceOptionCopyWith<$Res> {
factory $ChoiceOptionCopyWith(ChoiceOption value, $Res Function(ChoiceOption) _then) = _$ChoiceOptionCopyWithImpl;
@useResult
$Res call({
String id, String? text, String? image
});
}
/// @nodoc
class _$ChoiceOptionCopyWithImpl<$Res>
implements $ChoiceOptionCopyWith<$Res> {
_$ChoiceOptionCopyWithImpl(this._self, this._then);
final ChoiceOption _self;
final $Res Function(ChoiceOption) _then;
/// Create a copy of ChoiceOption
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? text = freezed,Object? image = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [ChoiceOption].
extension ChoiceOptionPatterns on ChoiceOption {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChoiceOption value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChoiceOption() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChoiceOption value) $default,){
final _that = this;
switch (_that) {
case _ChoiceOption():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChoiceOption value)? $default,){
final _that = this;
switch (_that) {
case _ChoiceOption() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? text, String? image)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChoiceOption() when $default != null:
return $default(_that.id,_that.text,_that.image);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? text, String? image) $default,) {final _that = this;
switch (_that) {
case _ChoiceOption():
return $default(_that.id,_that.text,_that.image);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? text, String? image)? $default,) {final _that = this;
switch (_that) {
case _ChoiceOption() when $default != null:
return $default(_that.id,_that.text,_that.image);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ChoiceOption implements ChoiceOption {
const _ChoiceOption({required this.id, this.text, this.image});
factory _ChoiceOption.fromJson(Map<String, dynamic> json) => _$ChoiceOptionFromJson(json);
@override final String id;
@override final String? text;
@override final String? image;
/// Create a copy of ChoiceOption
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChoiceOptionCopyWith<_ChoiceOption> get copyWith => __$ChoiceOptionCopyWithImpl<_ChoiceOption>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ChoiceOptionToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChoiceOption&&(identical(other.id, id) || other.id == id)&&(identical(other.text, text) || other.text == text)&&(identical(other.image, image) || other.image == image));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,text,image);
@override
String toString() {
return 'ChoiceOption(id: $id, text: $text, image: $image)';
}
}
/// @nodoc
abstract mixin class _$ChoiceOptionCopyWith<$Res> implements $ChoiceOptionCopyWith<$Res> {
factory _$ChoiceOptionCopyWith(_ChoiceOption value, $Res Function(_ChoiceOption) _then) = __$ChoiceOptionCopyWithImpl;
@override @useResult
$Res call({
String id, String? text, String? image
});
}
/// @nodoc
class __$ChoiceOptionCopyWithImpl<$Res>
implements _$ChoiceOptionCopyWith<$Res> {
__$ChoiceOptionCopyWithImpl(this._self, this._then);
final _ChoiceOption _self;
final $Res Function(_ChoiceOption) _then;
/// Create a copy of ChoiceOption
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? text = freezed,Object? image = freezed,}) {
return _then(_ChoiceOption(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String?,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
mixin _$MultipleChoiceQuestion {
String get id; String get question; String? get image; String? get audio; List<String> get options; String get correctAnswer; String get word;// associated word for statistics
String get id; String get question; String? get image; String? get audio; List<String> get options;// Deprecated: use optionItems instead
List<ChoiceOption> get optionItems;// New: supports both text and images
String get correctAnswer; String get word;// associated word for statistics
String get type;
/// Create a copy of MultipleChoiceQuestion
/// with the given fields replaced by the non-null parameter values.
@ -569,16 +840,16 @@ $MultipleChoiceQuestionCopyWith<MultipleChoiceQuestion> get copyWith => _$Multip
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MultipleChoiceQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.question, question) || other.question == question)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&const DeepCollectionEquality().equals(other.options, options)&&(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 MultipleChoiceQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.question, question) || other.question == question)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&const DeepCollectionEquality().equals(other.options, options)&&const DeepCollectionEquality().equals(other.optionItems, optionItems)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,question,image,audio,const DeepCollectionEquality().hash(options),correctAnswer,word,type);
int get hashCode => Object.hash(runtimeType,id,question,image,audio,const DeepCollectionEquality().hash(options),const DeepCollectionEquality().hash(optionItems),correctAnswer,word,type);
@override
String toString() {
return 'MultipleChoiceQuestion(id: $id, question: $question, image: $image, audio: $audio, options: $options, correctAnswer: $correctAnswer, word: $word, type: $type)';
return 'MultipleChoiceQuestion(id: $id, question: $question, image: $image, audio: $audio, options: $options, optionItems: $optionItems, correctAnswer: $correctAnswer, word: $word, type: $type)';
}
@ -589,7 +860,7 @@ abstract mixin class $MultipleChoiceQuestionCopyWith<$Res> {
factory $MultipleChoiceQuestionCopyWith(MultipleChoiceQuestion value, $Res Function(MultipleChoiceQuestion) _then) = _$MultipleChoiceQuestionCopyWithImpl;
@useResult
$Res call({
String id, String question, String? image, String? audio, List<String> options, String correctAnswer, String word, String type
String id, String question, String? image, String? audio, List<String> options, List<ChoiceOption> optionItems, String correctAnswer, String word, String type
});
@ -606,14 +877,15 @@ class _$MultipleChoiceQuestionCopyWithImpl<$Res>
/// Create a copy of MultipleChoiceQuestion
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? question = null,Object? image = freezed,Object? audio = freezed,Object? options = null,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? question = null,Object? image = freezed,Object? audio = freezed,Object? options = null,Object? optionItems = null,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,question: null == question ? _self.question : question // ignore: cast_nullable_to_non_nullable
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,audio: freezed == audio ? _self.audio : audio // ignore: cast_nullable_to_non_nullable
as String?,options: null == options ? _self.options : options // ignore: cast_nullable_to_non_nullable
as List<String>,correctAnswer: null == correctAnswer ? _self.correctAnswer : correctAnswer // ignore: cast_nullable_to_non_nullable
as List<String>,optionItems: null == optionItems ? _self.optionItems : optionItems // ignore: cast_nullable_to_non_nullable
as List<ChoiceOption>,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,
@ -701,10 +973,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String question, String? image, String? audio, List<String> options, String correctAnswer, String word, String type)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String question, String? image, String? audio, List<String> options, List<ChoiceOption> optionItems, String correctAnswer, String word, String type)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MultipleChoiceQuestion() when $default != null:
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.optionItems,_that.correctAnswer,_that.word,_that.type);case _:
return orElse();
}
@ -722,10 +994,10 @@ return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_t
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String question, String? image, String? audio, List<String> options, String correctAnswer, String word, String type) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String question, String? image, String? audio, List<String> options, List<ChoiceOption> optionItems, String correctAnswer, String word, String type) $default,) {final _that = this;
switch (_that) {
case _MultipleChoiceQuestion():
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.optionItems,_that.correctAnswer,_that.word,_that.type);case _:
throw StateError('Unexpected subclass');
}
@ -742,10 +1014,10 @@ return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_t
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String question, String? image, String? audio, List<String> options, String correctAnswer, String word, String type)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String question, String? image, String? audio, List<String> options, List<ChoiceOption> optionItems, String correctAnswer, String word, String type)? $default,) {final _that = this;
switch (_that) {
case _MultipleChoiceQuestion() when $default != null:
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.correctAnswer,_that.word,_that.type);case _:
return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_that.optionItems,_that.correctAnswer,_that.word,_that.type);case _:
return null;
}
@ -757,7 +1029,7 @@ return $default(_that.id,_that.question,_that.image,_that.audio,_that.options,_t
@JsonSerializable()
class _MultipleChoiceQuestion implements MultipleChoiceQuestion {
const _MultipleChoiceQuestion({required this.id, required this.question, this.image, this.audio, required final List<String> options, required this.correctAnswer, required this.word, this.type = 'multipleChoice'}): _options = options;
const _MultipleChoiceQuestion({required this.id, required this.question, this.image, this.audio, final List<String> options = const [], final List<ChoiceOption> optionItems = const [], required this.correctAnswer, required this.word, this.type = 'multipleChoice'}): _options = options,_optionItems = optionItems;
factory _MultipleChoiceQuestion.fromJson(Map<String, dynamic> json) => _$MultipleChoiceQuestionFromJson(json);
@override final String id;
@ -765,12 +1037,22 @@ class _MultipleChoiceQuestion implements MultipleChoiceQuestion {
@override final String? image;
@override final String? audio;
final List<String> _options;
@override List<String> get options {
@override@JsonKey() List<String> get options {
if (_options is EqualUnmodifiableListView) return _options;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_options);
}
// Deprecated: use optionItems instead
final List<ChoiceOption> _optionItems;
// Deprecated: use optionItems instead
@override@JsonKey() List<ChoiceOption> get optionItems {
if (_optionItems is EqualUnmodifiableListView) return _optionItems;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_optionItems);
}
// New: supports both text and images
@override final String correctAnswer;
@override final String word;
// associated word for statistics
@ -789,16 +1071,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MultipleChoiceQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.question, question) || other.question == question)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&const DeepCollectionEquality().equals(other._options, _options)&&(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 _MultipleChoiceQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.question, question) || other.question == question)&&(identical(other.image, image) || other.image == image)&&(identical(other.audio, audio) || other.audio == audio)&&const DeepCollectionEquality().equals(other._options, _options)&&const DeepCollectionEquality().equals(other._optionItems, _optionItems)&&(identical(other.correctAnswer, correctAnswer) || other.correctAnswer == correctAnswer)&&(identical(other.word, word) || other.word == word)&&(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,question,image,audio,const DeepCollectionEquality().hash(_options),correctAnswer,word,type);
int get hashCode => Object.hash(runtimeType,id,question,image,audio,const DeepCollectionEquality().hash(_options),const DeepCollectionEquality().hash(_optionItems),correctAnswer,word,type);
@override
String toString() {
return 'MultipleChoiceQuestion(id: $id, question: $question, image: $image, audio: $audio, options: $options, correctAnswer: $correctAnswer, word: $word, type: $type)';
return 'MultipleChoiceQuestion(id: $id, question: $question, image: $image, audio: $audio, options: $options, optionItems: $optionItems, correctAnswer: $correctAnswer, word: $word, type: $type)';
}
@ -809,7 +1091,7 @@ abstract mixin class _$MultipleChoiceQuestionCopyWith<$Res> implements $Multiple
factory _$MultipleChoiceQuestionCopyWith(_MultipleChoiceQuestion value, $Res Function(_MultipleChoiceQuestion) _then) = __$MultipleChoiceQuestionCopyWithImpl;
@override @useResult
$Res call({
String id, String question, String? image, String? audio, List<String> options, String correctAnswer, String word, String type
String id, String question, String? image, String? audio, List<String> options, List<ChoiceOption> optionItems, String correctAnswer, String word, String type
});
@ -826,14 +1108,15 @@ class __$MultipleChoiceQuestionCopyWithImpl<$Res>
/// Create a copy of MultipleChoiceQuestion
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? question = null,Object? image = freezed,Object? audio = freezed,Object? options = null,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? question = null,Object? image = freezed,Object? audio = freezed,Object? options = null,Object? optionItems = null,Object? correctAnswer = null,Object? word = null,Object? type = null,}) {
return _then(_MultipleChoiceQuestion(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,question: null == question ? _self.question : question // ignore: cast_nullable_to_non_nullable
as String,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,audio: freezed == audio ? _self.audio : audio // ignore: cast_nullable_to_non_nullable
as String?,options: null == options ? _self._options : options // ignore: cast_nullable_to_non_nullable
as List<String>,correctAnswer: null == correctAnswer ? _self.correctAnswer : correctAnswer // ignore: cast_nullable_to_non_nullable
as List<String>,optionItems: null == optionItems ? _self._optionItems : optionItems // ignore: cast_nullable_to_non_nullable
as List<ChoiceOption>,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,

View file

@ -58,6 +58,20 @@ Map<String, dynamic> _$GameQuestionMatrixToJson(GameQuestionMatrix instance) =>
'runtimeType': instance.$type,
};
_ChoiceOption _$ChoiceOptionFromJson(Map<String, dynamic> json) =>
_ChoiceOption(
id: json['id'] as String,
text: json['text'] as String?,
image: json['image'] as String?,
);
Map<String, dynamic> _$ChoiceOptionToJson(_ChoiceOption instance) =>
<String, dynamic>{
'id': instance.id,
'text': instance.text,
'image': instance.image,
};
_MultipleChoiceQuestion _$MultipleChoiceQuestionFromJson(
Map<String, dynamic> json,
) => _MultipleChoiceQuestion(
@ -65,7 +79,14 @@ _MultipleChoiceQuestion _$MultipleChoiceQuestionFromJson(
question: json['question'] as String,
image: json['image'] as String?,
audio: json['audio'] as String?,
options: (json['options'] as List<dynamic>).map((e) => e as String).toList(),
options:
(json['options'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
optionItems:
(json['optionItems'] as List<dynamic>?)
?.map((e) => ChoiceOption.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
correctAnswer: json['correctAnswer'] as String,
word: json['word'] as String,
type: json['type'] as String? ?? 'multipleChoice',
@ -79,6 +100,7 @@ Map<String, dynamic> _$MultipleChoiceQuestionToJson(
'image': instance.image,
'audio': instance.audio,
'options': instance.options,
'optionItems': instance.optionItems,
'correctAnswer': instance.correctAnswer,
'word': instance.word,
'type': instance.type,

View file

@ -333,7 +333,21 @@ class TestsStateManager extends StateManager<TestsState> {
for (final question in test.questions) {
if (question is SimpleTestQuestionBody) {
// Convert to MultipleChoiceQuestion
final options = question.buttons.map((b) => b.text ?? '').where((t) => t.isNotEmpty).toList();
// 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.image, // Now supports URL from backend
);
}).toList();
// For backward compatibility, also create text-only options
final options = question.buttons
.map((b) => b.text ?? '')
.where((t) => t.isNotEmpty)
.toList();
final correctAnswer = question.answer;
questions.add(GameQuestion.multipleChoice(
@ -342,7 +356,8 @@ class TestsStateManager extends StateManager<TestsState> {
question: question.text ?? '',
image: question.image,
audio: question.audio,
options: options,
options: options, // Keep for backward compatibility
optionItems: optionItems, // New: supports images
correctAnswer: correctAnswer,
word: question.word,
),

View file

@ -35,6 +35,17 @@ class AnswerOptions extends StatelessWidget {
// Determine layout: single column for mobile, 2 columns for wider screens
final crossAxisCount = constraints.maxWidth > 600 ? 2 : 1;
// Use optionItems if available (supports images), otherwise fall back to options
final hasOptionItems = question.optionItems.isNotEmpty;
final itemCount = hasOptionItems
? question.optionItems.length
: question.options.length;
// Adjust aspect ratio for image buttons (they need more space)
final hasImages = hasOptionItems &&
question.optionItems.any((item) => item.image != null);
final childAspectRatio = hasImages ? 2.5 : 4.0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -42,22 +53,60 @@ class AnswerOptions extends StatelessWidget {
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 4.0, // Wider buttons
childAspectRatio: childAspectRatio,
),
itemCount: question.options.length,
itemCount: itemCount,
itemBuilder: (context, index) {
if (hasOptionItems) {
final optionItem = question.optionItems[index];
return _buildAnswerOptionFromItem(context, optionItem);
} else {
final option = question.options[index];
return _buildAnswerOption(context, option);
}
},
);
},
);
}
Widget _buildAnswerOptionFromItem(BuildContext context, ChoiceOption optionItem) {
final isSelected = selectedAnswer == optionItem.id;
final isCorrectOption = optionItem.id == question.correctAnswer;
return _buildAnswerOptionWidget(
context: context,
optionId: optionItem.id,
text: optionItem.text,
image: optionItem.image,
isSelected: isSelected,
isCorrectOption: isCorrectOption,
);
}
Widget _buildAnswerOption(BuildContext context, String option) {
final isSelected = selectedAnswer == option;
final isCorrectOption = option == question.correctAnswer;
return _buildAnswerOptionWidget(
context: context,
optionId: option,
text: option,
image: null,
isSelected: isSelected,
isCorrectOption: isCorrectOption,
);
}
Widget _buildAnswerOptionWidget({
required BuildContext context,
required String optionId,
String? text,
String? image,
required bool isSelected,
required bool isCorrectOption,
}) {
// Determine button color based on state
Color? backgroundColor;
Color? borderColor;
@ -111,7 +160,7 @@ class AnswerOptions extends StatelessWidget {
shadowColor: borderColor?.withOpacity(0.3),
child: InkWell(
onTap: enabled && !isAnswerSubmitted
? () => _onAnswerSelected(context, option)
? () => _onAnswerSelected(context, optionId)
: null,
borderRadius: BorderRadius.circular(12),
splashColor: borderColor?.withOpacity(0.1),
@ -189,21 +238,15 @@ class AnswerOptions extends StatelessWidget {
SizedBox(width: 12),
// Option text
// Option content (text or image)
Expanded(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodyLarge!
.copyWith(
color: textColor,
fontWeight:
isSelected ||
(isAnswerSubmitted && isCorrectOption)
? FontWeight.w600
: FontWeight.normal,
fontSize: isSelected ? 17 : 16,
),
child: Text(option),
child: _buildOptionContent(
context,
text: text,
image: image,
textColor: textColor,
isSelected: isSelected,
isCorrectOption: isCorrectOption,
),
),
],
@ -216,4 +259,77 @@ class AnswerOptions extends StatelessWidget {
},
);
}
Widget _buildOptionContent(
BuildContext context, {
String? text,
String? image,
Color? textColor,
required bool isSelected,
required bool isCorrectOption,
}) {
// If we have an image, show it (with optional text below)
if (image != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
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 (text != null && text.isNotEmpty) ...[
const SizedBox(height: 4),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: textColor,
fontWeight: isSelected || isCorrectOption
? FontWeight.w600
: FontWeight.normal,
),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
],
],
);
}
// Otherwise, show text only
return AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: textColor,
fontWeight: isSelected || isCorrectOption
? FontWeight.w600
: FontWeight.normal,
fontSize: isSelected ? 17 : 16,
),
child: Text(text ?? ''),
);
}
}