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
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:
parent
d869e5c17d
commit
dfc5ba43c3
10 changed files with 1160 additions and 151 deletions
407
mnemo_cards_admin/src/components/CardEditorPreview.tsx
Normal file
407
mnemo_cards_admin/src/components/CardEditorPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue