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 03:41:47 +03:00
parent 79318528df
commit 3d179c95ef
36 changed files with 4705 additions and 317 deletions

View file

@ -7,37 +7,40 @@ import PacksPage from '@/pages/PacksPage'
import UsersPage from '@/pages/UsersPage'
import TestsPage from '@/pages/TestsPage'
import Layout from '@/components/layout/Layout'
import { TokenRefreshProvider } from '@/components/TokenRefreshProvider'
function App() {
const { isAuthenticated } = useAuthStore()
return (
<div className="min-h-screen bg-background">
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route
path="/*"
element={
isAuthenticated ? (
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/cards" element={<CardsPage />} />
<Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/tests" element={<TestsPage />} />
</Routes>
</Layout>
) : (
<Navigate to="/login" replace />
)
}
/>
</Routes>
</div>
<TokenRefreshProvider>
<div className="min-h-screen bg-background">
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route
path="/*"
element={
isAuthenticated ? (
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/cards" element={<CardsPage />} />
<Route path="/packs" element={<PacksPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/tests" element={<TestsPage />} />
</Routes>
</Layout>
) : (
<Navigate to="/login" replace />
)
}
/>
</Routes>
</div>
</TokenRefreshProvider>
)
}

View file

@ -1,5 +1,5 @@
import { adminApiClient } from './client'
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse } from '@/types/models'
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse, RefreshTokenResponse } from '@/types/models'
import type { AxiosError } from 'axios'
export const authApi = {
@ -46,4 +46,12 @@ export const authApi = {
const response = await adminApiClient.get('/api/v2/admin/auth/me')
return response.data
},
// Refresh access token using refresh token
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
const response = await adminApiClient.post('/api/v2/admin/auth/refresh', {
refreshToken,
})
return response.data
},
}

View file

@ -1,5 +1,6 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { authApi } from './auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
@ -59,22 +60,122 @@ const addAuthToken = (config: InternalAxiosRequestConfig) => {
// Flag to prevent multiple logout calls
let isLoggingOut = false
// Flag to prevent multiple refresh calls
let isRefreshing = false
let refreshPromise: Promise<string> | null = null
// Function to refresh token
const refreshAccessToken = async (): Promise<string> => {
if (refreshPromise) {
return refreshPromise
}
refreshPromise = (async () => {
try {
const refreshToken = localStorage.getItem('admin_refresh_token')
if (!refreshToken) {
throw new Error('No refresh token available')
}
const response = await authApi.refreshToken(refreshToken)
if (!response.success || !response.token) {
throw new Error('Failed to refresh token')
}
// Update store with new tokens
useAuthStore.getState().updateToken(
response.token,
response.refreshToken,
response.expiresIn
)
return response.token
} catch (error) {
console.error('[API Client] Token refresh failed:', error)
// If refresh fails, logout
useAuthStore.getState().logout()
throw error
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
// Request interceptor to check and refresh token if needed
const checkAndRefreshToken = async (config: InternalAxiosRequestConfig) => {
const isPublicAuthEndpoint =
config.url?.includes('/admin/auth/request-code') ||
config.url?.includes('/admin/auth/verify-code') ||
config.url?.includes('/admin/auth/code-status') ||
config.url?.includes('/admin/auth/refresh')
// Skip token refresh for public endpoints
if (isPublicAuthEndpoint) {
return config
}
// Check if token needs refresh
if (useAuthStore.getState().shouldRefreshToken() && !isRefreshing) {
isRefreshing = true
try {
const newToken = await refreshAccessToken()
// Update token in config
if (config.headers) {
config.headers['Authorization'] = `Bearer ${newToken}`
}
} catch (error) {
console.error('[API Client] Failed to refresh token before request:', error)
// Will be handled by response interceptor
} finally {
isRefreshing = false
}
}
return config
}
// Response interceptor for error handling
const handleAuthError = (error: unknown) => {
const handleAuthError = async (error: unknown) => {
const axiosError = error as AxiosError
const originalRequest = axiosError.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (axiosError.response?.status === 401) {
// Check if we're already on login page
const currentPath = window.location.pathname
const isPublicAuthEndpoint =
axiosError.config?.url?.includes('/admin/auth/request-code') ||
axiosError.config?.url?.includes('/admin/auth/verify-code') ||
axiosError.config?.url?.includes('/admin/auth/code-status')
originalRequest?.url?.includes('/admin/auth/request-code') ||
originalRequest?.url?.includes('/admin/auth/verify-code') ||
originalRequest?.url?.includes('/admin/auth/code-status') ||
originalRequest?.url?.includes('/admin/auth/refresh')
// Don't logout for public auth endpoints or if already on login page
if (currentPath === '/login' || isPublicAuthEndpoint) {
return Promise.reject(error)
}
// Try to refresh token if we haven't retried yet
if (!originalRequest._retry) {
originalRequest._retry = true
const refreshToken = localStorage.getItem('admin_refresh_token')
if (refreshToken) {
try {
const newToken = await refreshAccessToken()
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${newToken}`
}
return adminApiClient(originalRequest)
} catch (refreshError) {
console.error('[API Client] Token refresh failed, logging out:', refreshError)
// Refresh failed, proceed to logout
}
}
}
// Prevent multiple logout calls
if (isLoggingOut) {
@ -86,6 +187,7 @@ const handleAuthError = (error: unknown) => {
// Remove token and update auth store
// React Router will automatically redirect to /login when isAuthenticated becomes false
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_refresh_token')
useAuthStore.getState().logout()
// Reset flag after a short delay to allow React Router to handle redirect
@ -97,8 +199,10 @@ const handleAuthError = (error: unknown) => {
}
// Apply interceptors to both clients
apiClient.interceptors.request.use(checkAndRefreshToken)
apiClient.interceptors.request.use(addAuthToken)
apiClient.interceptors.response.use((response) => response, handleAuthError)
adminApiClient.interceptors.request.use(checkAndRefreshToken)
adminApiClient.interceptors.request.use(addAuthToken)
adminApiClient.interceptors.response.use((response) => response, handleAuthError)

View file

@ -0,0 +1,160 @@
import { useState, useEffect } from 'react'
import type { Question } from '@/types/questions'
import { questionToJson, questionFromJson } from '@/types/questions'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
interface JSONQuestionEditorProps {
question: Partial<Question> | null
onChange: (question: Partial<Question> | null, isValid: boolean) => void
}
export function JSONQuestionEditor({
question,
onChange,
}: JSONQuestionEditorProps) {
const [jsonText, setJsonText] = useState('')
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
// Инициализация JSON из вопроса
useEffect(() => {
if (question) {
try {
const json = questionToJson(question as Question)
setJsonText(JSON.stringify(json, null, 2))
setError(null)
setIsValid(true)
} catch (e) {
setError(`Failed to serialize question: ${e}`)
setIsValid(false)
}
} else {
setJsonText('')
setError(null)
setIsValid(false)
}
}, [question])
// Валидация и парсинг JSON
const handleJsonChange = (value: string) => {
setJsonText(value)
if (!value.trim()) {
setError(null)
setIsValid(false)
onChange(null, false)
return
}
try {
const parsed = JSON.parse(value)
// Базовая валидация структуры
if (!parsed.questionType) {
throw new Error('Missing required field: questionType')
}
if (!parsed.word) {
throw new Error('Missing required field: word')
}
if (!parsed.answer) {
throw new Error('Missing required field: answer')
}
if (!parsed.buttons || !Array.isArray(parsed.buttons)) {
throw new Error('Missing or invalid field: buttons (must be an array)')
}
// Проверка что answer существует в buttons
const buttonIds = parsed.buttons.map((b: any) => b.id)
if (!buttonIds.includes(parsed.answer)) {
throw new Error(
`Answer "${parsed.answer}" not found in buttons. Available IDs: ${buttonIds.join(', ')}`,
)
}
// Проверка для input_buttons
if (parsed.questionType === 'input_buttons' && !parsed.template) {
throw new Error('Missing required field for input_buttons: template')
}
// Парсинг в Question объект
const questionObj = questionFromJson(parsed)
setError(null)
setIsValid(true)
onChange(questionObj, true)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON'
setError(errorMessage)
setIsValid(false)
onChange(null, false)
}
}
// Форматирование JSON
const formatJson = () => {
try {
const parsed = JSON.parse(jsonText)
setJsonText(JSON.stringify(parsed, null, 2))
handleJsonChange(JSON.stringify(parsed, null, 2))
} catch (e) {
// Если невалидный JSON, просто показываем ошибку
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>JSON Editor</Label>
<Button type="button" variant="outline" size="sm" onClick={formatJson}>
Format JSON
</Button>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder='{"questionType": "simple", "word": "...", "answer": "...", "buttons": [...]}'
rows={20}
className="font-mono text-sm"
/>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isValid && !error && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>JSON is valid</AlertDescription>
</Alert>
)}
<div className="text-xs text-muted-foreground">
<p className="font-semibold mb-1">Required fields:</p>
<ul className="list-disc list-inside space-y-1">
<li>
<code>questionType</code>: "simple" or "input_buttons"
</li>
<li>
<code>word</code>: Word or phrase to learn
</li>
<li>
<code>answer</code>: ID of the correct button
</li>
<li>
<code>buttons</code>: Array of button objects with id, text/image
</li>
<li>
<code>template</code>: Required for input_buttons type
</li>
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,243 @@
import { useState, useEffect } from 'react'
import type { Question } from '@/types/questions'
import { QuestionType, isSimpleQuestion, isInputButtonsQuestion } from '@/types/questions'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SimpleQuestionForm } from './forms/SimpleQuestionForm'
import { InputButtonsQuestionForm } from './forms/InputButtonsQuestionForm'
import { JSONQuestionEditor } from './JSONQuestionEditor'
import { Code, FileText } from 'lucide-react'
type EditorMode = 'visual' | 'json'
interface QuestionEditorDialogProps {
open: boolean
question: Question | null
onSave: (question: Question) => void
onClose: () => void
}
export function QuestionEditorDialog({
open,
question,
onSave,
onClose,
}: QuestionEditorDialogProps) {
const [mode, setMode] = useState<EditorMode>('visual')
const [questionType, setQuestionType] = useState<QuestionType>(
question?.questionType || QuestionType.SIMPLE,
)
const [currentQuestion, setCurrentQuestion] = useState<Partial<Question> | null>(
question || null,
)
const [isValid, setIsValid] = useState(false)
// Инициализация при открытии
useEffect(() => {
if (open) {
if (question) {
setCurrentQuestion(question)
setQuestionType(question.questionType)
setMode('visual')
} else {
// Новый вопрос
setCurrentQuestion({
questionType: QuestionType.SIMPLE,
word: '',
answer: '',
options: [],
})
setQuestionType(QuestionType.SIMPLE)
setMode('visual')
}
setIsValid(false)
}
}, [open, question])
const handleQuestionChange = (
updated: Partial<Question> | null,
valid: boolean,
) => {
setCurrentQuestion(updated)
setIsValid(valid)
}
const handleSave = () => {
if (!currentQuestion || !isValid) {
return
}
// Валидация обязательных полей
if (!currentQuestion.word || !currentQuestion.answer) {
return
}
if (!currentQuestion.options || currentQuestion.options.length === 0) {
return
}
if (
isInputButtonsQuestion(currentQuestion as Question) &&
!(currentQuestion as any).template
) {
return
}
onSave(currentQuestion as Question)
}
const handleTypeChange = (newType: QuestionType) => {
setQuestionType(newType)
if (currentQuestion) {
const updated: Partial<Question> = {
...currentQuestion,
questionType: newType,
}
// Если переключаемся на input_buttons и нет template, добавляем пустой
if (newType === QuestionType.INPUT_BUTTONS && !(updated as any).template) {
;(updated as any).template = ''
}
setCurrentQuestion(updated)
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{question ? 'Edit Question' : 'Create New Question'}
</DialogTitle>
<DialogDescription>
{question
? 'Update the question information'
: 'Add a new question to the test'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Режим редактирования */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Label>Editor Mode:</Label>
<div className="flex gap-2">
<Button
type="button"
variant={mode === 'visual' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('visual')}
>
<FileText className="h-4 w-4 mr-2" />
Visual
</Button>
<Button
type="button"
variant={mode === 'json' ? 'default' : 'outline'}
size="sm"
onClick={() => setMode('json')}
>
<Code className="h-4 w-4 mr-2" />
JSON
</Button>
</div>
</div>
{/* Тип вопроса (только в visual режиме) */}
{mode === 'visual' && (
<div className="flex items-center gap-2">
<Label htmlFor="question-type">Type:</Label>
<Select
value={questionType}
onValueChange={(value) => handleTypeChange(value as QuestionType)}
>
<SelectTrigger id="question-type" className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={QuestionType.SIMPLE}>Simple</SelectItem>
<SelectItem value={QuestionType.INPUT_BUTTONS}>
Input Buttons
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Контент редактора */}
{mode === 'visual' ? (
<div>
{questionType === QuestionType.SIMPLE ? (
<SimpleQuestionForm
question={currentQuestion || {}}
onChange={(updated) => {
setCurrentQuestion({
...updated,
questionType: QuestionType.SIMPLE,
})
setIsValid(
!!(updated.word && updated.answer && updated.options?.length),
)
}}
/>
) : (
<InputButtonsQuestionForm
question={currentQuestion || {}}
onChange={(updated) => {
setCurrentQuestion({
...updated,
questionType: QuestionType.INPUT_BUTTONS,
})
setIsValid(
!!(
updated.word &&
updated.answer &&
updated.options?.length &&
(updated as any).template
),
)
}}
/>
)}
</div>
) : (
<JSONQuestionEditor
question={currentQuestion}
onChange={handleQuestionChange}
/>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!isValid || !currentQuestion}
>
{question ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,183 @@
import { useState } from 'react'
import type { Question } from '@/types/questions'
import { QuestionType, isSimpleQuestion, isInputButtonsQuestion } from '@/types/questions'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Edit, Trash2, Plus, FileText, Type } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
interface TestQuestionsManagerProps {
questions: Question[]
onChange: (questions: Question[]) => void
onEdit: (question: Question, index: number) => void
disabled?: boolean
}
export function TestQuestionsManager({
questions,
onChange,
onEdit,
disabled = false,
}: TestQuestionsManagerProps) {
const [questionToDelete, setQuestionToDelete] = useState<{
question: Question
index: number
} | null>(null)
const handleDelete = (question: Question, index: number) => {
setQuestionToDelete({ question, index })
}
const confirmDelete = () => {
if (questionToDelete) {
const newQuestions = questions.filter((_, i) => i !== questionToDelete.index)
onChange(newQuestions)
setQuestionToDelete(null)
}
}
const getQuestionPreview = (question: Question): string => {
if (question.text) return question.text
if (question.image) return '📷 Image question'
return `Word: ${question.word}`
}
const getQuestionTypeLabel = (type: QuestionType): string => {
switch (type) {
case QuestionType.SIMPLE:
return 'Simple'
case QuestionType.INPUT_BUTTONS:
return 'Input Buttons'
default:
return type
}
}
const getQuestionTypeColor = (type: QuestionType): string => {
switch (type) {
case QuestionType.SIMPLE:
return 'bg-blue-500'
case QuestionType.INPUT_BUTTONS:
return 'bg-green-500'
default:
return 'bg-gray-500'
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Questions</h3>
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this test
</p>
</div>
</div>
{questions.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<FileText className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground">
No questions yet. Add your first question to get started.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{questions.map((question, index) => (
<Card key={question.id || index}>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge
className={`${getQuestionTypeColor(
question.questionType,
)} text-white border-transparent`}
>
<Type className="h-3 w-3 mr-1" />
{getQuestionTypeLabel(question.questionType)}
</Badge>
<span className="text-sm text-muted-foreground">
#{index + 1}
</span>
</div>
<div>
<p className="font-medium">{question.word}</p>
<p className="text-sm text-muted-foreground">
{getQuestionPreview(question)}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{question.options.length} option(s)</span>
{isInputButtonsQuestion(question) && (
<span> Template: {question.template}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(question, index)}
disabled={disabled}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(question, index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={questionToDelete !== null}
onOpenChange={(open) => !open && setQuestionToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Question</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this question? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '@/stores/authStore'
import { authApi } from '@/api/auth'
/**
* Component that periodically checks and refreshes the access token
* before it expires. This ensures seamless user experience without
* unexpected logouts.
*/
export function TokenRefreshProvider({ children }: { children: React.ReactNode }) {
const { shouldRefreshToken, refreshToken, updateToken, logout } = useAuthStore()
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Check every minute if token needs refresh
const checkAndRefresh = async () => {
if (!shouldRefreshToken() || !refreshToken) {
return
}
try {
console.log('[TokenRefreshProvider] Refreshing token...')
const response = await authApi.refreshToken(refreshToken)
if (response.success && response.token) {
updateToken(response.token, response.refreshToken, response.expiresIn)
console.log('[TokenRefreshProvider] Token refreshed successfully')
} else {
throw new Error('Failed to refresh token')
}
} catch (error) {
console.error('[TokenRefreshProvider] Token refresh failed:', error)
// If refresh token is expired, logout
logout()
}
}
// Check immediately on mount
checkAndRefresh()
// Set up interval to check every minute
intervalRef.current = setInterval(checkAndRefresh, 60000) // 1 minute
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [shouldRefreshToken, refreshToken, updateToken, logout])
return <>{children}</>
}

View file

@ -0,0 +1,243 @@
import { useState, useEffect } from 'react'
import type { InputButtonsQuestion, TestButton } from '@/types/questions'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { ImageUpload } from '@/components/ui/image-upload'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
interface InputButtonsQuestionFormProps {
question: Partial<InputButtonsQuestion>
onChange: (question: Partial<InputButtonsQuestion>) => void
}
export function InputButtonsQuestionForm({
question,
onChange,
}: InputButtonsQuestionFormProps) {
const [word, setWord] = useState(question.word || '')
const [text, setText] = useState(question.text || '')
const [image, setImage] = useState(question.image || '')
const [audio, setAudio] = useState(question.audio || '')
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
const [answer, setAnswer] = useState(question.answer || '')
const [template, setTemplate] = useState(question.template || '')
useEffect(() => {
onChange({
questionType: 'input_buttons',
word,
text: text || undefined,
image: image || undefined,
audio: audio || undefined,
options: buttons,
answer,
template,
})
}, [word, text, image, audio, buttons, answer, template, onChange])
const addButton = () => {
const newButton: TestButton = {
id: `btn_${Date.now()}`,
text: '',
}
setButtons([...buttons, newButton])
}
const removeButton = (index: number) => {
setButtons(buttons.filter((_, i) => i !== index))
if (answer === buttons[index]?.id) {
setAnswer('')
}
}
const updateButton = (index: number, updates: Partial<TestButton>) => {
const newButtons = [...buttons]
newButtons[index] = { ...newButtons[index], ...updates }
setButtons(newButtons)
}
// Генерация template из answer
const generateTemplate = () => {
if (answer) {
setTemplate(answer.replace(/[^ ]/g, '_'))
}
}
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="word">
Word <span className="text-red-500">*</span>
</Label>
<Input
id="word"
value={word}
onChange={(e) => setWord(e.target.value)}
placeholder="Word or phrase to learn"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="text">Question Text</Label>
<Textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Optional question text"
rows={3}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Question Image"
value={image}
onChange={setImage}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audio">Audio URL</Label>
<Input
id="audio"
value={audio}
onChange={(e) => setAudio(e.target.value)}
placeholder="https://..."
type="url"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Answer Options <span className="text-red-500">*</span>
</Label>
<Button type="button" variant="outline" size="sm" onClick={addButton}>
<Plus className="h-4 w-4 mr-2" />
Add Button
</Button>
</div>
{buttons.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center py-4">
No buttons yet. Add at least one button.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{buttons.map((button, index) => (
<Card key={button.id}>
<CardContent className="pt-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Button {index + 1} (ID: {button.id})
</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeButton(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor={`button-text-${index}`}>Text</Label>
<Input
id={`button-text-${index}`}
value={button.text || ''}
onChange={(e) =>
updateButton(index, { text: e.target.value })
}
placeholder="Button text"
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Image"
value={button.image || ''}
onChange={(value) =>
updateButton(index, { image: value || undefined })
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="answer">
Correct Answer <span className="text-red-500">*</span>
</Label>
<Select value={answer} onValueChange={setAnswer} required>
<SelectTrigger id="answer">
<SelectValue placeholder="Select correct answer" />
</SelectTrigger>
<SelectContent>
{buttons.map((button) => (
<SelectItem key={button.id} value={button.id}>
{button.text || button.image || button.id}
</SelectItem>
))}
</SelectContent>
</Select>
{buttons.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one button to select an answer
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="template">
Template <span className="text-red-500">*</span>
</Label>
{answer && (
<Button
type="button"
variant="outline"
size="sm"
onClick={generateTemplate}
>
Generate from answer
</Button>
)}
</div>
<Input
id="template"
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="e.g., a___e (underscores for letters to input)"
required
/>
<p className="text-xs text-muted-foreground">
Template defines which letters the user needs to input. Use underscores
(_) for letters to input, spaces for word separators.
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,205 @@
import { useState, useEffect } from 'react'
import type { SimpleQuestion, TestButton } from '@/types/questions'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { ImageUpload } from '@/components/ui/image-upload'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
interface SimpleQuestionFormProps {
question: Partial<SimpleQuestion>
onChange: (question: Partial<SimpleQuestion>) => void
}
export function SimpleQuestionForm({
question,
onChange,
}: SimpleQuestionFormProps) {
const [word, setWord] = useState(question.word || '')
const [text, setText] = useState(question.text || '')
const [image, setImage] = useState(question.image || '')
const [audio, setAudio] = useState(question.audio || '')
const [buttons, setButtons] = useState<TestButton[]>(question.options || [])
const [answer, setAnswer] = useState(question.answer || '')
useEffect(() => {
onChange({
questionType: 'simple',
word,
text: text || undefined,
image: image || undefined,
audio: audio || undefined,
options: buttons,
answer,
})
}, [word, text, image, audio, buttons, answer, onChange])
const addButton = () => {
const newButton: TestButton = {
id: `btn_${Date.now()}`,
text: '',
}
setButtons([...buttons, newButton])
}
const removeButton = (index: number) => {
setButtons(buttons.filter((_, i) => i !== index))
if (answer === buttons[index]?.id) {
setAnswer('')
}
}
const updateButton = (index: number, updates: Partial<TestButton>) => {
const newButtons = [...buttons]
newButtons[index] = { ...newButtons[index], ...updates }
setButtons(newButtons)
}
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="word">
Word <span className="text-red-500">*</span>
</Label>
<Input
id="word"
value={word}
onChange={(e) => setWord(e.target.value)}
placeholder="Word or phrase to learn"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="text">Question Text</Label>
<Textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Optional question text"
rows={3}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Question Image"
value={image}
onChange={setImage}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audio">Audio URL</Label>
<Input
id="audio"
value={audio}
onChange={(e) => setAudio(e.target.value)}
placeholder="https://..."
type="url"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Answer Options <span className="text-red-500">*</span>
</Label>
<Button type="button" variant="outline" size="sm" onClick={addButton}>
<Plus className="h-4 w-4 mr-2" />
Add Button
</Button>
</div>
{buttons.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center py-4">
No buttons yet. Add at least one button.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{buttons.map((button, index) => (
<Card key={button.id}>
<CardContent className="pt-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Button {index + 1} (ID: {button.id})
</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeButton(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor={`button-text-${index}`}>Text</Label>
<Input
id={`button-text-${index}`}
value={button.text || ''}
onChange={(e) =>
updateButton(index, { text: e.target.value })
}
placeholder="Button text"
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Image"
value={button.image || ''}
onChange={(value) =>
updateButton(index, { image: value || undefined })
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="answer">
Correct Answer <span className="text-red-500">*</span>
</Label>
<Select value={answer} onValueChange={setAnswer} required>
<SelectTrigger id="answer">
<SelectValue placeholder="Select correct answer" />
</SelectTrigger>
<SelectContent>
{buttons.map((button) => (
<SelectItem key={button.id} value={button.id}>
{button.text || button.image || button.id}
</SelectItem>
))}
</SelectContent>
</Select>
{buttons.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one button to select an answer
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -95,14 +95,18 @@ export default function LoginPage() {
console.log('Login successful, setting token and user')
console.log('Token received:', data.token ? `Token exists, length: ${data.token.length}` : 'NO TOKEN!')
console.log('RefreshToken received:', data.refreshToken ? `Token exists, length: ${data.refreshToken.length}` : 'NO REFRESH TOKEN!')
console.log('ExpiresIn:', data.expiresIn)
console.log('User received:', data.user)
// Save token first
login(data.token, data.user)
// Save token with refresh token and expiration
login(data.token, data.refreshToken ?? null, data.expiresIn ?? null, data.user)
// Verify token was saved
const savedToken = localStorage.getItem('admin_token')
const savedRefreshToken = localStorage.getItem('admin_refresh_token')
console.log('Token saved verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT saved!')
console.log('RefreshToken saved verification:', savedRefreshToken ? `Token exists, length: ${savedRefreshToken.length}` : 'RefreshToken NOT saved!')
if (!savedToken) {
console.error('CRITICAL: Token was not saved to localStorage!')

View file

@ -4,6 +4,8 @@ import { toast } from 'sonner'
import { testsApi, isTestsApiError } from '@/api/tests'
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
import type { TestDto, PaginatedResponse } from '@/types/models'
import type { Question } from '@/types/questions'
import { questionFromJson, questionToJson } from '@/types/questions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -36,6 +38,8 @@ import {
import { Label } from '@/components/ui/label'
import { ImageUpload } from '@/components/ui/image-upload'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
export default function TestsPage() {
const queryClient = useQueryClient()
@ -45,6 +49,11 @@ export default function TestsPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
const [isQuestionDialogOpen, setIsQuestionDialogOpen] = useState(false)
const [editingQuestion, setEditingQuestion] = useState<{
question: Question | null
index: number
} | null>(null)
// Form state
const [formData, setFormData] = useState({
@ -54,7 +63,7 @@ export default function TestsPage() {
version: '',
time: '',
timeSubtitle: '',
questions: [] as TestDto['questions'],
questions: [] as Question[],
})
const limit = 20
@ -141,6 +150,17 @@ export default function TestsPage() {
// Load full test data if we only have preview
const fullTest = test.id ? await testsApi.getTest(test.id) : test
setSelectedTest(fullTest)
// Преобразуем вопросы из JSON в Question объекты
const questions: Question[] = (fullTest.questions || []).map((q: any) => {
try {
return questionFromJson(q)
} catch (e) {
console.error('Error parsing question:', e, q)
return null
}
}).filter((q): q is Question => q !== null)
setFormData({
name: fullTest.name || '',
color: fullTest.color || '',
@ -148,7 +168,7 @@ export default function TestsPage() {
version: fullTest.version || '',
time: fullTest.time || '',
timeSubtitle: fullTest.timeSubtitle || '',
questions: fullTest.questions || [],
questions,
})
setIsDialogOpen(true)
} catch (error) {
@ -173,6 +193,9 @@ export default function TestsPage() {
return
}
// Преобразуем вопросы в JSON формат для отправки на бэкенд
const questionsJson = formData.questions.map((q) => questionToJson(q))
const testData: TestDto = {
id: selectedTest?.id,
name: formData.name.trim(),
@ -181,7 +204,7 @@ export default function TestsPage() {
version: formData.version.trim() || undefined,
time: formData.time.trim() || undefined,
timeSubtitle: formData.timeSubtitle.trim() || undefined,
questions: formData.questions,
questions: questionsJson as any,
}
if (selectedTest) {
@ -300,7 +323,11 @@ export default function TestsPage() {
<TableRow key={test.id || Math.random()}>
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
<TableCell className="font-medium">{test.name}</TableCell>
<TableCell>{test.questions?.length || 0}</TableCell>
<TableCell>
{typeof test.questions === 'number'
? test.questions
: test.questions?.length || 0}
</TableCell>
<TableCell>{test.version || 'N/A'}</TableCell>
<TableCell>{test.time || 'N/A'}</TableCell>
<TableCell>
@ -365,7 +392,7 @@ export default function TestsPage() {
{/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[95vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedTest ? 'Edit Test' : 'Create New Test'}
@ -439,17 +466,29 @@ export default function TestsPage() {
</div>
<div className="space-y-2">
<Label>Questions</Label>
<div className="border rounded-lg p-4 bg-muted/50">
<p className="text-sm text-muted-foreground">
{formData.questions.length} question(s) in this test
</p>
{formData.questions.length === 0 && (
<p className="text-xs text-muted-foreground mt-2">
Questions are managed separately. After creating the test, you can add questions through the backend API.
</p>
)}
</div>
<TestQuestionsManager
questions={formData.questions}
onChange={(questions) =>
setFormData((prev) => ({ ...prev, questions }))
}
onEdit={(question, index) => {
setEditingQuestion({ question, index })
setIsQuestionDialogOpen(true)
}}
disabled={createMutation.isPending || updateMutation.isPending}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setEditingQuestion({ question: null, index: -1 })
setIsQuestionDialogOpen(true)
}}
disabled={createMutation.isPending || updateMutation.isPending}
>
<Plus className="h-4 w-4 mr-2" />
Add Question
</Button>
</div>
</div>
<DialogFooter>
@ -485,6 +524,31 @@ export default function TestsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Question Editor Dialog */}
<QuestionEditorDialog
open={isQuestionDialogOpen}
question={editingQuestion?.question || null}
onSave={(question) => {
if (editingQuestion) {
const newQuestions = [...formData.questions]
if (editingQuestion.index >= 0) {
// Редактирование существующего вопроса
newQuestions[editingQuestion.index] = question
} else {
// Добавление нового вопроса
newQuestions.push(question)
}
setFormData((prev) => ({ ...prev, questions: newQuestions }))
}
setIsQuestionDialogOpen(false)
setEditingQuestion(null)
}}
onClose={() => {
setIsQuestionDialogOpen(false)
setEditingQuestion(null)
}}
/>
</div>
)
}

View file

@ -5,51 +5,90 @@ import type { UserDto } from '@/types/models'
interface AuthState {
user: UserDto | null
token: string | null
refreshToken: string | null
expiresIn: number | null
tokenExpiresAt: number | null // timestamp when token expires
isAuthenticated: boolean
isLoading: boolean
error: string | null
}
interface AuthActions {
login: (token: string, user: UserDto) => void
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => void
updateToken: (token: string, refreshToken: string, expiresIn: number) => void
logout: () => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
shouldRefreshToken: () => boolean
}
type AuthStore = AuthState & AuthActions
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
(set, get) => ({
// State
user: null,
token: null,
refreshToken: null,
expiresIn: null,
tokenExpiresAt: null,
isAuthenticated: false,
isLoading: false,
error: null,
// Actions
login: (token: string, user: UserDto) => {
login: (token: string, refreshToken: string | null, expiresIn: number | null, user: UserDto) => {
console.log('[AuthStore] Saving token to localStorage, length:', token.length)
localStorage.setItem('admin_token', token)
if (refreshToken) {
localStorage.setItem('admin_refresh_token', refreshToken)
}
// Calculate expiration time (refresh 5 minutes before expiration)
const tokenExpiresAt = expiresIn
? Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
: null
// Verify it was saved
const savedToken = localStorage.getItem('admin_token')
console.log('[AuthStore] Token saved, verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT found!')
set({
user,
token,
refreshToken: refreshToken ?? null,
expiresIn: expiresIn ?? null,
tokenExpiresAt,
isAuthenticated: true,
isLoading: false,
error: null,
})
},
updateToken: (token: string, refreshToken: string, expiresIn: number) => {
console.log('[AuthStore] Updating token')
localStorage.setItem('admin_token', token)
localStorage.setItem('admin_refresh_token', refreshToken)
const tokenExpiresAt = Date.now() + (expiresIn - 300) * 1000 // 5 minutes buffer
set({
token,
refreshToken,
expiresIn,
tokenExpiresAt,
})
},
logout: () => {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_refresh_token')
set({
user: null,
token: null,
refreshToken: null,
expiresIn: null,
tokenExpiresAt: null,
isAuthenticated: false,
isLoading: false,
error: null,
@ -63,14 +102,45 @@ export const useAuthStore = create<AuthStore>()(
setError: (error: string | null) => {
set({ error, isLoading: false })
},
shouldRefreshToken: () => {
const state = get()
if (!state.refreshToken || !state.token) {
return false
}
// If we have expiresIn but no tokenExpiresAt, calculate it
if (state.expiresIn && !state.tokenExpiresAt) {
const calculatedExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
set({ tokenExpiresAt: calculatedExpiresAt })
return Date.now() >= calculatedExpiresAt
}
if (!state.tokenExpiresAt) {
return false
}
// Refresh if token expires in less than 5 minutes
return Date.now() >= state.tokenExpiresAt
},
}),
{
name: 'admin-auth',
partialize: (state) => ({
user: state.user,
token: state.token,
refreshToken: state.refreshToken,
expiresIn: state.expiresIn,
tokenExpiresAt: state.tokenExpiresAt,
isAuthenticated: state.isAuthenticated,
}),
onRehydrateStorage: () => (state) => {
// After rehydration, recalculate tokenExpiresAt if needed
if (state && state.expiresIn && !state.tokenExpiresAt) {
const tokenExpiresAt = Date.now() + (state.expiresIn - 300) * 1000
state.tokenExpiresAt = tokenExpiresAt
}
},
}
)
)

View file

@ -129,10 +129,20 @@ export interface AuthRequest {
export interface AuthResponse {
success?: boolean
token: string
refreshToken?: string
expiresIn?: number
user: UserDto
message?: string
}
export interface RefreshTokenResponse {
success: boolean
token: string
refreshToken: string
expiresIn: number
message?: string
}
export interface CodeRequest {
code: string
}
@ -159,11 +169,9 @@ export interface CodeStatusResponse {
}
// Test types
export interface TestQuestion {
id?: string
questionType: string
body: unknown // JSON structure varies by question type
}
import type { Question } from './questions'
export type TestQuestion = Question
export interface TestDto {
id?: string
@ -173,6 +181,6 @@ export interface TestDto {
version?: string
time?: string
timeSubtitle?: string
questions: TestQuestion[]
questions: Question[]
statistics?: unknown
}

View file

@ -0,0 +1,116 @@
export enum QuestionType {
SIMPLE = 'simple',
INPUT_BUTTONS = 'input_buttons',
}
// Соответствует полю options в БД
export interface TestButton {
id: string
text?: string
image?: string
}
// Базовый интерфейс вопроса
export interface BaseQuestion {
id?: string
questionType: QuestionType
orderIndex?: number
// Ключевые поля (хранятся отдельно в БД)
word: string // TestQuestions.word
answer: string // TestQuestions.answer
options: TestButton[] // TestQuestions.options (JSON)
// UI данные (хранятся в TestQuestions.uiData как JSON)
image?: string
text?: string
audio?: string
}
export interface SimpleQuestion extends BaseQuestion {
questionType: QuestionType.SIMPLE
}
export interface InputButtonsQuestion extends BaseQuestion {
questionType: QuestionType.INPUT_BUTTONS
template: string // Хранится в uiData
}
export type Question = SimpleQuestion | InputButtonsQuestion
// Хелпер для преобразования в/из БД формата
export interface QuestionDbFormat {
id?: string
testId: string
orderIndex: number
questionType: string
word: string
answer: string
options: string // JSON string
uiData: string // JSON string
}
// Хелперы для работы с вопросами
export function isSimpleQuestion(question: Question): question is SimpleQuestion {
return question.questionType === QuestionType.SIMPLE
}
export function isInputButtonsQuestion(
question: Question,
): question is InputButtonsQuestion {
return question.questionType === QuestionType.INPUT_BUTTONS
}
// Создать вопрос из JSON (как приходит с бэкенда)
export function questionFromJson(json: any): Question {
const base: BaseQuestion = {
id: json.id,
questionType: json.questionType as QuestionType,
orderIndex: json.orderIndex,
word: json.word || '',
answer: json.answer || '',
options: json.buttons || json.options || [],
image: json.image,
text: json.text,
audio: json.audio,
}
if (json.questionType === QuestionType.INPUT_BUTTONS) {
return {
...base,
questionType: QuestionType.INPUT_BUTTONS,
template: json.template || '',
}
}
return {
...base,
questionType: QuestionType.SIMPLE,
}
}
// Преобразовать вопрос в JSON для отправки на бэкенд
export function questionToJson(question: Question): any {
const base: any = {
questionType: question.questionType,
word: question.word,
answer: question.answer,
buttons: question.options,
}
if (question.id) {
base.id = question.id
}
// UI данные
if (question.image) base.image = question.image
if (question.text) base.text = question.text
if (question.audio) base.audio = question.audio
// Специфичные для типа поля
if (isInputButtonsQuestion(question)) {
base.template = question.template
}
return base
}

View file

@ -164,6 +164,8 @@ class AdminAuthApiV2 {
return _json({
'success': true,
'token': tokens.accessToken,
'refreshToken': tokens.refreshToken,
'expiresIn': tokens.expiresIn,
'user': {
'id': user.id,
'name': user.name,
@ -266,6 +268,91 @@ class AdminAuthApiV2 {
}
}
/// POST /api/v2/admin/auth/refresh
/// Refresh access token using refresh token
/// Requires admin privileges
@Route.post('/admin/auth/refresh')
Future<Response> refreshToken(Request request) async {
try {
final body = await request.readAsString();
if (body.isEmpty) {
return _json(
{
'success': false,
'message': 'Request body is required',
},
statusCode: 400,
);
}
final jsonData = jsonDecode(body) as Map<String, dynamic>;
final refreshToken = jsonData['refreshToken'] as String?;
if (refreshToken == null || refreshToken.isEmpty) {
return _json(
{
'success': false,
'message': 'refreshToken is required',
},
statusCode: 400,
);
}
// Verify and extract user from refresh token
final userIdStr = await _jwtService.verifyRefreshToken(refreshToken);
if (userIdStr == null || userIdStr.isEmpty) {
return _json(
{
'success': false,
'message': 'Invalid or expired refresh token',
},
statusCode: 401,
);
}
// Get user
final user = await _userManager.fetchUser(userIdStr);
if (user == null) {
return _json(
{
'success': false,
'message': 'User not found',
},
statusCode: 401,
);
}
// Verify admin status
if (!user.admin) {
return _json(
{
'success': false,
'message': 'Access denied: not an admin',
},
statusCode: 403,
);
}
// Generate new tokens
final tokens = await _jwtService.generateTokens(user);
return _json({
'success': true,
'token': tokens.accessToken,
'refreshToken': tokens.refreshToken,
'expiresIn': tokens.expiresIn,
});
} catch (e) {
return _json(
{
'success': false,
'message': 'Internal server error: $e',
},
statusCode: 500,
);
}
}
/// Get list of admin Telegram IDs from environment variable or file
Future<List<String>> _getAdminIds() async {
return AdminIdsService.getAdminIds();

View file

@ -12,5 +12,6 @@ Router _$AdminAuthApiV2Router(AdminAuthApiV2 service) {
router.add('POST', r'/admin/auth/verify-code', service.verifyCode);
router.add('GET', r'/admin/auth/code-status/<code>', service.getCodeStatus);
router.add('GET', r'/admin/auth/me', service.getCurrentUser);
router.add('POST', r'/admin/auth/refresh', service.refreshToken);
return router;
}

View file

@ -434,6 +434,11 @@ class AdminCardsApiV2 {
@Route.delete('/admin/cards/<cardId>')
Future<Response> deleteCard(Request request, String cardId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (cardId.isEmpty) {
return Response.badRequest(
body: json.encode({

View file

@ -166,14 +166,102 @@ class AdminTestsApiV2 {
);
}
// Get packId for the test to build image URLs
final packId = await _db.testDao.getPackIdForTest(testId);
// Get questions
final questions = await _db.testDao.getTestQuestions(testId);
final questionsList = questions.map((q) {
final body = json.decode(q.body) as Map<String, dynamic>;
return AbstractTestQuestion.fromJson({
// Новая структура: собираем вопрос из отдельных полей
Map<String, dynamic> questionJson = {
'questionType': q.questionType,
...body,
});
'id': q.id,
'word': q.word,
};
// Парсим options (JSON array кнопок)
try {
final options = json.decode(q.options) as List<dynamic>;
questionJson['buttons'] = options;
} catch (e) {
questionJson['buttons'] = [];
}
// Добавляем answer
questionJson['answer'] = q.answer;
// Парсим uiData (image, text, audio, template)
try {
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
questionJson.addAll(uiData);
} catch (e) {
// Если uiData пустой или невалидный, игнорируем
}
return AbstractTestQuestion.fromJson(questionJson);
}).toList();
// 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's base64 data URL, extract card ID if possible
// Format: /api/v2/packs/{packId}/cards/{base64}/image or just base64
if (imageValue.contains('/cards/')) {
// Extract card ID from path like /api/v2/packs/{packId}/cards/{cardId}/image
final parts = imageValue.split('/cards/');
if (parts.length == 2) {
final cardIdPart = parts[1].split('/')[0];
// If it's a valid UUID format, use it; otherwise it might be base64
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(cardIdPart)) {
return '/api/v2/packs/$packId/cards/$cardIdPart/image';
}
}
}
// 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
// This handles cases where the value is stored as card ID
return '/api/v2/packs/$packId/cards/$imageValue/image';
}
// Convert image IDs to URLs in questions
final questionsWithUrls = questionsList.map((q) {
final questionJson = q.toJson();
// Convert image in question itself
if (questionJson['image'] != null) {
questionJson['image'] = _convertImageToUrl(
questionJson['image'] as String?,
packId,
);
}
// Convert images in buttons
if (questionJson['buttons'] != null) {
final buttons = questionJson['buttons'] as List<dynamic>;
for (final button in buttons) {
if (button is Map<String, dynamic> && button['image'] != null) {
button['image'] = _convertImageToUrl(
button['image'] as String?,
packId,
);
}
}
}
return questionJson;
}).toList();
return _json({
@ -184,7 +272,7 @@ class AdminTestsApiV2 {
'version': test.version ?? '1.0',
'time': test.time,
'timeSubtitle': test.timeSubtitle,
'questions': questionsList.map((q) => q.toJson()).toList(),
'questions': questionsWithUrls,
});
} catch (e, s) {
print('Error in getTest: $e\n$s');
@ -281,16 +369,32 @@ class AdminTestsApiV2 {
await _db.testDao.softDeleteTestQuestion(q.id);
}
// Add new questions
int orderIndex = 0;
for (final q in questions) {
final questionJson = q as Map<String, dynamic>;
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
final questionBody = Map<String, dynamic>.from(questionJson);
questionBody.remove('questionType');
final questionType = questionJson['questionType'] as String? ?? 'simple';
// Извлекаем ключевые поля
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: testId,
orderIndex: orderIndex++,
questionType: questionType,
body: jsonEncode(questionBody),
word: word,
answer: answer,
options: jsonEncode(buttons),
uiData: jsonEncode(uiData),
),
);
}
@ -332,16 +436,32 @@ class AdminTestsApiV2 {
// Add questions if provided
if (questions != null) {
int orderIndex = 0;
for (final q in questions) {
final questionJson = q as Map<String, dynamic>;
final questionType = questionJson['questionType'] as String? ?? 'multiple_choice';
final questionBody = Map<String, dynamic>.from(questionJson);
questionBody.remove('questionType');
final questionType = questionJson['questionType'] as String? ?? 'simple';
// Извлекаем ключевые поля
final word = questionJson['word'] as String? ?? '';
final answer = questionJson['answer'] as String? ?? '';
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
// UI данные (image, text, audio, template)
final uiData = <String, dynamic>{};
if (questionJson['image'] != null) uiData['image'] = questionJson['image'];
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
if (questionJson['audio'] != null) uiData['audio'] = questionJson['audio'];
if (questionJson['template'] != null) uiData['template'] = questionJson['template'];
await _db.testDao.createTestQuestion(
TestQuestionsCompanion.insert(
testId: newTest,
orderIndex: orderIndex++,
questionType: questionType,
body: jsonEncode(questionBody),
word: word,
answer: answer,
options: jsonEncode(buttons),
uiData: jsonEncode(uiData),
),
);
}

View file

@ -430,6 +430,73 @@ class PacksApiV2 {
}
}
/// GET /api/v2/packs/{packId}/cards/{cardId}/imageBack
/// Get card back image
/// Returns PNG image file
///
/// Images are accessible for enabled packs even without authentication
/// to allow image preview in public pack listings
@Route.get('/packs/<packId>/cards/<cardId>/imageBack')
@OpenApiRouteHttp()
Future<Response> getCardImageBack(
Request request,
String packId,
String cardId,
) async {
try {
if (cardId.isEmpty) {
return _badRequest('Invalid card ID');
}
if (packId.isEmpty) {
return _badRequest('Invalid pack ID');
}
// Check if pack exists and is enabled
// We allow access to images for enabled packs even without auth
// to support image previews in public listings
final pack = await _db.packDao.getPackById(packId);
if (pack == null || !pack.enabled) {
return _notFound('Pack not found or not enabled');
}
// Get card and verify it belongs to the pack
final card = await _db.packDao.getCardById(cardId);
if (card == null || card.imageBack == null || card.imageBack!.isEmpty) {
return _notFound('Card or back image not found');
}
// Verify card belongs to this pack
final packCards = await _db.packDao.getPackCards(packId);
final belongsToPack = packCards.any((c) => c.id == cardId);
if (!belongsToPack) {
return _notFound('Card does not belong to this pack');
}
// Get image bytes - card.imageBack is already base64 or path
// For now, assume it's base64 encoded or needs to be loaded
final imageBase64 = card.imageBack!;
if (imageBase64.isEmpty) {
return _notFound('Back image not found');
}
// Decode base64 and return as image
final imageBytes = base64Decode(imageBase64);
return Response.ok(
imageBytes,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
);
} catch (e, s) {
print('Error fetching card back image: $e\n$s');
return _internalServerError('Error loading back image');
}
}
/// GET /api/v2/packs/{packId}/cards/{cardId}/voices
/// Get card voices metadata
/// Returns JSON list of VoiceDto

View file

@ -17,6 +17,11 @@ Router _$PacksApiV2Router(PacksApiV2 service) {
r'/packs/<packId>/cards/<cardId>/image',
service.getCardImage,
);
router.add(
'GET',
r'/packs/<packId>/cards/<cardId>/imageBack',
service.getCardImageBack,
);
router.add(
'GET',
r'/packs/<packId>/cards/<cardId>/voices',

View file

@ -88,9 +88,11 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
// ==================== GameCards ====================
/// Получить карточку по ID
/// Получить карточку по ID (только активные)
Future<GameCard?> getCardById(String id) {
return (select(gameCards)..where((c) => c.id.equals(id))).getSingleOrNull();
return (select(gameCards)
..where((c) => c.id.equals(id) & c.isDeleted.equals(false))
).getSingleOrNull();
}
/// Получить паки для карточки

View file

@ -2,7 +2,6 @@ import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import '../database.dart';
import '../tables/tests.dart';
import '../tables/packs.dart';
part 'test_dao.g.dart';
@ -56,6 +55,7 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
return (update(tests)..where((t) => t.id.equals(testId)))
.write(TestsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
updatedAt: Value(PgDateTime(DateTime.now())),
));
}
@ -70,13 +70,23 @@ class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
mode: InsertMode.insertOrIgnore,
);
}
/// Получить packId для теста
Future<String?> getPackIdForTest(String testId) async {
final relation = await (select(testPackRelations)
..where((tpr) => tpr.testId.equals(testId))
..limit(1)
).getSingleOrNull();
return relation?.packId;
}
// ==================== TestQuestions ====================
/// Получить вопросы теста (только активные)
/// Получить вопросы теста (только активные, отсортированные по orderIndex)
Future<List<TestQuestion>> getTestQuestions(String testId) {
return (select(testQuestions)
..where((tq) => tq.testId.equals(testId) & tq.isDeleted.equals(false))
..orderBy([(tq) => OrderingTerm(expression: tq.orderIndex)])
).get();
}

View file

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:postgres/postgres.dart' as pg;
import 'dart:io';
import 'dart:convert';
// Импорт конвертеров (нужен для генерации кода)
import 'converters.dart';
@ -122,7 +123,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
/// Factory для подключения к PostgreSQL
static AppDatabase connect({
@ -177,10 +178,10 @@ class AppDatabase extends _$AppDatabase {
onUpgrade: (Migrator m, int from, int to) async {
print('Migrating database from version $from to $to');
// Миграции при обновлении схемы
// if (from < 2) {
// await m.addColumn(users, users.phoneNumber);
// }
// Миграция с версии 1 на 2: обновление структуры тестов
if (from < 2) {
await _migrateToV2(m);
}
},
beforeOpen: (details) async {
print('Opening database connection...');
@ -251,4 +252,63 @@ class AppDatabase extends _$AppDatabase {
print('Indexes created successfully');
}
/// Миграция с версии 1 на версию 2
/// Обновление структуры тестов: добавление новых колонок и очистка старых данных
Future<void> _migrateToV2(Migrator m) async {
print('Starting migration to v2: updating test questions and statistics...');
try {
// 1. Добавляем новые колонки в test_questions
print('Adding new columns to test_questions...');
await customStatement(
'ALTER TABLE test_questions '
'ADD COLUMN IF NOT EXISTS order_index INTEGER DEFAULT 0, '
'ADD COLUMN IF NOT EXISTS word TEXT DEFAULT \'\', '
'ADD COLUMN IF NOT EXISTS answer TEXT DEFAULT \'\', '
'ADD COLUMN IF NOT EXISTS options TEXT DEFAULT \'[]\', '
'ADD COLUMN IF NOT EXISTS ui_data TEXT DEFAULT \'{}\'',
);
// 2. Удаляем все старые вопросы (soft delete)
print('Soft-deleting all existing test questions...');
await customStatement(
'UPDATE test_questions SET is_deleted = TRUE, deleted_at = NOW() WHERE is_deleted = FALSE',
);
print('All old questions marked as deleted. Create new questions via admin panel.');
// 3. Удаляем старую колонку body
print('Dropping old body column...');
await customStatement('ALTER TABLE test_questions DROP COLUMN IF EXISTS body');
// 4. Добавляем новые колонки в test_statistics
print('Adding new columns to test_statistics...');
await customStatement(
'ALTER TABLE test_statistics '
'ADD COLUMN IF NOT EXISTS total_questions INTEGER DEFAULT 0, '
'ADD COLUMN IF NOT EXISTS correct_answers INTEGER DEFAULT 0, '
'ADD COLUMN IF NOT EXISTS incorrect_answers INTEGER DEFAULT 0, '
'ADD COLUMN IF NOT EXISTS skipped_answers INTEGER DEFAULT 0, '
'ADD COLUMN IF NOT EXISTS score_percentage REAL DEFAULT 0.0, '
'ADD COLUMN IF NOT EXISTS time_spent_seconds INTEGER, '
'ADD COLUMN IF NOT EXISTS question_results TEXT DEFAULT \'[]\', '
'ADD COLUMN IF NOT EXISTS metadata TEXT DEFAULT \'{}\'',
);
// 5. Удаляем всю старую статистику (она будет создаваться заново)
print('Deleting old test statistics...');
await customStatement('DELETE FROM test_statistics');
print('All old statistics deleted. New statistics will be collected automatically.');
// 6. Удаляем старую колонку results
print('Dropping old results column...');
await customStatement('ALTER TABLE test_statistics DROP COLUMN IF EXISTS results');
print('Migration to v2 completed successfully!');
} catch (e, stackTrace) {
print('Error during migration to v2: $e');
print('Stack trace: $stackTrace');
rethrow;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -39,9 +39,26 @@ class TestQuestions extends Table {
TextColumn get testId => text()
.references(Tests, #id, onDelete: KeyAction.cascade)();
// Порядок вопроса в тесте
IntColumn get orderIndex => integer()
.withDefault(const Constant(0))();
// Тип вопроса (enum as string)
TextColumn get questionType => text()();
TextColumn get body => text()();
// Ключевые поля вынесены из JSON
TextColumn get word => text()(); // Слово/фраза для изучения
TextColumn get answer => text()(); // Правильный ответ (ID кнопки)
// Варианты ответов (JSON array кнопок)
// [{"id": "btn1", "text": "apple", "image": null}, ...]
TextColumn get options => text()
.withDefault(const Constant('[]'))();
// UI данные (image, text, audio, template для input_buttons)
// {"image": "url", "text": "Question?", "audio": "url", "template": "___"}
TextColumn get uiData => text()
.withDefault(const Constant('{}'))();
// Audit
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
@ -78,11 +95,34 @@ class TestStatistics extends Table {
TextColumn get testId => text()
.references(Tests, #id, onDelete: KeyAction.cascade)();
// Результаты (JSON)
TextColumn get results => text()
.withDefault(const Constant('{}'))
.map(const JsonMapConverter())();
// Сводные метрики (вынесены для быстрого доступа и аналитики)
IntColumn get totalQuestions => integer()
.withDefault(const Constant(0))();
IntColumn get correctAnswers => integer()
.withDefault(const Constant(0))();
IntColumn get incorrectAnswers => integer()
.withDefault(const Constant(0))();
IntColumn get skippedAnswers => integer()
.withDefault(const Constant(0))();
// Процент правильных ответов (0-100)
RealColumn get scorePercentage => real()
.withDefault(const Constant(0.0))();
// Время прохождения в секундах
IntColumn get timeSpentSeconds => integer()
.nullable()();
// Детальные результаты по каждому вопросу (JSON)
// [{"questionId": "123", "correct": true, "timeSpent": 5, "answer": "a1"}]
TextColumn get questionResults => text()
.withDefault(const Constant('[]'))();
// Дополнительные данные (по типам вопросов, streak и т.д.)
TextColumn get metadata => text()
.withDefault(const Constant('{}'))();
// Когда завершен тест
Column<PgDateTime> get completedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();

View file

@ -588,6 +588,137 @@ void main() {
});
});
group('PacksApiV2 - Get Card Image Back', () {
test('should return 404 for card without imageBack', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(request, '10', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return 404 for non-existent card', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/999/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(request, '10', '999');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return 404 for non-existent pack', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/999/cards/1/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(request, '999', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should return 400 for invalid card ID', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/invalid/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(
request,
'10',
'invalid',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
});
test('should return 400 for invalid pack ID', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/invalid/cards/1/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(
request,
'invalid',
'1',
);
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(400));
expect(responseBody['error'], equals('Bad Request'));
});
test('should return 404 for disabled pack', () async {
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/99/cards/1/imageBack',
user: testUser,
);
final response = await packsApiV2.getCardImageBack(request, '99', '1');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
test('should allow access without authentication for enabled packs', () async {
// Request without user context (public access)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/1/imageBack',
);
// This should work even without auth because pack 10 is enabled
// and images are now accessible for enabled packs
final response = await packsApiV2.getCardImageBack(request, '10', '1');
// Note: This might return 404 if image file doesn't exist,
// but it should not return 401 Unauthorized
expect(response.statusCode, isNot(equals(401)));
});
test('should return 404 if card does not belong to pack', () async {
// Try to get card 3 from pack 10 (card 3 belongs to pack 20)
final request = buildRequest(
'GET',
'http://localhost/api/v2/packs/10/cards/3/imageBack',
);
final response = await packsApiV2.getCardImageBack(request, '10', '3');
final responseBody = jsonDecode(await response.readAsString())
as Map<String, dynamic>;
expect(response.statusCode, equals(404));
expect(responseBody['error'], equals('Not Found'));
});
});
group('PacksApiV2 - Get Pack Tests', () {
test('should return 401 for unauthenticated request', () async {
final request = buildRequest(

View file

@ -0,0 +1,290 @@
import 'dart:io';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
/// Unit тесты для SoftDeleteMixin
///
/// Тестирует функциональность soft delete на примере WordStatisticsDao
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
/// - Тестовая БД: mnemo_cards_test
void main() {
late AppDatabase db;
late String testUserId;
late String testCardId;
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
db = AppDatabase.connect(
host: host,
port: port,
database: database,
username: username,
password: password,
);
await db.migrator.createAll();
// Создать тестовые данные
final user = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User'),
email: Value('test@example.com'),
),
);
testUserId = user.id;
await db.userDao.createUserData(
UserDatasCompanion.insert(userId: testUserId),
);
final card = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test',
translation: 'тест',
image: 'test.png',
),
);
testCardId = card.id;
});
tearDown(() async {
await db.delete(db.wordStatistics).go();
});
tearDownAll(() async {
await db.delete(db.wordStatistics).go();
await db.delete(db.gameCards).go();
await db.delete(db.userDatas).go();
await db.delete(db.users).go();
await db.close();
});
group('SoftDeleteMixin', () {
group('selectActive', () {
test('возвращает только активные записи', () async {
// Создать несколько записей
final stats1 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
final stats2 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 3,
incorrectAnswers: 1,
);
final card3 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test3',
translation: 'тест3',
image: 'test3.png',
),
);
final stats3 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card3.id,
correctAnswers: 1,
incorrectAnswers: 0,
);
// Проверить что все записи активны
var activeStats = await db.wordStatisticsDao.selectActive().get();
expect(activeStats.length, equals(3));
// Удалить одну запись (soft delete)
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats2.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// selectActive должен вернуть только активные
activeStats = await db.wordStatisticsDao.selectActive().get();
expect(activeStats.length, equals(2));
expect(
activeStats.map((s) => s.id).toSet(),
containsAll([stats1.id, stats3.id]),
);
expect(
activeStats.map((s) => s.id).toSet(),
isNot(contains(stats2.id)),
);
});
test('не возвращает удаленные записи', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 1,
incorrectAnswers: 0,
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final activeStats = await db.wordStatisticsDao.selectActive().get();
expect(activeStats, isEmpty);
});
test('работает с where условиями', () async {
// Создать записи для разных пользователей
final user2 = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User 2'),
),
);
await db.userDao.createUserData(
UserDatasCompanion.insert(userId: user2.id),
);
final stats1 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
final stats2 = await db.wordStatisticsDao.create(
userId: user2.id,
cardId: testCardId,
correctAnswers: 3,
incorrectAnswers: 1,
);
// selectActive с where должен фильтровать и по isDeleted, и по условию
final user1Stats = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
expect(user1Stats.length, equals(1));
expect(user1Stats.first.id, equals(stats1.id));
// Удалить запись пользователя 1
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats1.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Теперь selectActive для пользователя 1 должен вернуть пустой список
final user1StatsAfterDelete = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(testUserId)))
.get();
expect(user1StatsAfterDelete, isEmpty);
// Но запись пользователя 2 все еще активна
final user2Stats = await (db.wordStatisticsDao.selectActive()
..where((w) => w.userId.equals(user2.id)))
.get();
expect(user2Stats.length, equals(1));
expect(user2Stats.first.id, equals(stats2.id));
});
});
group('getActiveById', () {
test('возвращает запись если она активна', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
final result = await db.wordStatisticsDao.getActiveById(stats.id);
expect(result, isNotNull);
expect(result!.id, equals(stats.id));
expect(result.userId, equals(testUserId));
expect(result.cardId, equals(testCardId));
});
test('возвращает null если запись не существует', () async {
final result = await db.wordStatisticsDao.getActiveById('non_existent_id');
expect(result, isNull);
});
test('возвращает null если запись удалена', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getActiveById(stats.id);
expect(result, isNull);
});
test('не возвращает удаленные записи даже если ID существует', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 1,
incorrectAnswers: 0,
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Проверить что запись физически существует в БД, но getActiveById не возвращает её
final allRecords = await db.select(db.wordStatistics).get();
expect(allRecords.length, equals(1));
expect(allRecords.first.isDeleted, isTrue);
final activeRecord = await db.wordStatisticsDao.getActiveById(stats.id);
expect(activeRecord, isNull);
});
});
});
}

View file

@ -0,0 +1,485 @@
import 'dart:io';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
import 'package:mnemo_cards_backend/database/tables/relations.dart';
/// Unit тесты для WordStatisticsDao
///
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
/// - Тестовая БД: mnemo_cards_test
/// - Пользователь: postgres (или настроить через переменные окружения)
void main() {
late AppDatabase db;
late String testUserId;
late String testCardId;
late String testPackId;
setUpAll(() async {
// Подключение к тестовой БД
// Можно использовать переменные окружения для настройки
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
db = AppDatabase.connect(
host: host,
port: port,
database: database,
username: username,
password: password,
);
// Создать схему БД
await db.migrator.createAll();
// Создать тестовые данные
// 1. Создать пользователя
final user = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User'),
email: Value('test@example.com'),
),
);
testUserId = user.id;
// 2. Создать UserData
await db.userDao.createUserData(
UserDatasCompanion.insert(
userId: testUserId,
),
);
// 3. Создать пак
final pack = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'Test Subtitle',
size: 10,
),
);
testPackId = pack.id;
// 4. Создать карточку
final card = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test',
translation: 'тест',
image: 'test.png',
),
);
testCardId = card.id;
// 5. Связать карточку с паком
await db.packDao.linkCardToPack(testCardId, testPackId);
// 6. Связать пользователя с паком
await db.packDao.linkUserToPack(testUserId, testPackId);
});
tearDown(() async {
// Очистить WordStatistics после каждого теста
await db.delete(db.wordStatistics).go();
});
tearDownAll(() async {
// Очистить все тестовые данные
await db.delete(db.wordStatistics).go();
await db.delete(db.cardPackCards).go();
await db.delete(db.userPacks).go();
await db.delete(db.gameCards).go();
await db.delete(db.cardPacks).go();
await db.delete(db.userDatas).go();
await db.delete(db.users).go();
await db.close();
});
group('WordStatisticsDao', () {
group('create', () {
test('создает новую запись статистики', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
expect(stats.id, isNotEmpty);
expect(stats.userId, equals(testUserId));
expect(stats.cardId, equals(testCardId));
expect(stats.correctAnswers, equals(5));
expect(stats.incorrectAnswers, equals(2));
expect(stats.mastery, closeTo(5.0 / 7.0, 0.001));
expect(stats.lastReviewed, isNotNull);
expect(stats.isDeleted, isFalse);
});
test('рассчитывает mastery корректно', () async {
final stats1 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 10,
incorrectAnswers: 0,
);
expect(stats1.mastery, equals(1.0));
// Создать другую карточку для второго теста
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
final stats2 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 0,
incorrectAnswers: 0,
);
expect(stats2.mastery, equals(0.0));
final stats3 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: 'card3',
correctAnswers: 7,
incorrectAnswers: 3,
);
expect(stats3.mastery, closeTo(0.7, 0.001));
});
});
group('getByUserAndCard', () {
test('возвращает null если запись не существует', () async {
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
'non_existent_card',
);
expect(stats, isNull);
});
test('возвращает существующую запись', () async {
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 3,
incorrectAnswers: 1,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats, isNotNull);
expect(stats!.userId, equals(testUserId));
expect(stats.cardId, equals(testCardId));
expect(stats.correctAnswers, equals(3));
expect(stats.incorrectAnswers, equals(1));
});
test('не возвращает удаленные записи', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 1,
incorrectAnswers: 0,
);
// Soft delete
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(result, isNull);
});
});
group('updateStatistics', () {
test('обновляет существующую статистику', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
await db.wordStatisticsDao.updateStatistics(
id: stats.id,
correctAnswers: 8,
incorrectAnswers: 3,
lastReviewed: DateTime.now(),
);
final updated = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(updated, isNotNull);
expect(updated!.correctAnswers, equals(8));
expect(updated.incorrectAnswers, equals(3));
expect(updated.mastery, closeTo(8.0 / 11.0, 0.001));
});
test('пересчитывает mastery при обновлении', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 1,
incorrectAnswers: 1,
);
await db.wordStatisticsDao.updateStatistics(
id: stats.id,
correctAnswers: 10,
incorrectAnswers: 0,
lastReviewed: DateTime.now(),
);
final updated = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(updated!.mastery, equals(1.0));
});
});
group('getPackStatistics', () {
test('возвращает статистику по карточкам пака', () async {
// Создать еще одну карточку в том же паке
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
await db.packDao.linkCardToPack(card2.id, testPackId);
// Создать статистику для обеих карточек
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 3,
incorrectAnswers: 1,
);
final packStats = await db.wordStatisticsDao.getPackStatistics(
testUserId,
testPackId,
);
expect(packStats.length, equals(2));
expect(
packStats.map((s) => s.cardId).toSet(),
containsAll([testCardId, card2.id]),
);
});
test('не возвращает статистику из других паков', () async {
// Создать другой пак и карточку
final pack2 = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack 2',
subtitle: 'Test Subtitle 2',
size: 5,
),
);
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
await db.packDao.linkCardToPack(card2.id, pack2.id);
// Создать статистику для карточки из другого пака
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 1,
incorrectAnswers: 0,
);
// Статистика по первому паку должна быть пустой
final packStats = await db.wordStatisticsDao.getPackStatistics(
testUserId,
testPackId,
);
expect(packStats, isEmpty);
});
});
group('getUserStatistics', () {
test('возвращает всю статистику пользователя', () async {
// Создать несколько карточек
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
final card3 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test3',
translation: 'тест3',
image: 'test3.png',
),
);
// Создать статистику для всех карточек
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 3,
incorrectAnswers: 1,
);
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card3.id,
correctAnswers: 1,
incorrectAnswers: 0,
);
final userStats = await db.wordStatisticsDao.getUserStatistics(
testUserId,
);
expect(userStats.length, equals(3));
expect(
userStats.map((s) => s.cardId).toSet(),
containsAll([testCardId, card2.id, card3.id]),
);
});
test('не возвращает статистику других пользователей', () async {
// Создать другого пользователя
final user2 = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_2_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User 2'),
),
);
await db.userDao.createUserData(
UserDatasCompanion.insert(userId: user2.id),
);
// Создать статистику для обоих пользователей
await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
await db.wordStatisticsDao.create(
userId: user2.id,
cardId: testCardId,
correctAnswers: 1,
incorrectAnswers: 0,
);
final userStats = await db.wordStatisticsDao.getUserStatistics(
testUserId,
);
expect(userStats.length, equals(1));
expect(userStats.first.userId, equals(testUserId));
});
});
group('SoftDeleteMixin', () {
test('selectActive фильтрует удаленные записи', () async {
final stats1 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
final stats2 = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: card2.id,
correctAnswers: 3,
incorrectAnswers: 1,
);
// Удалить одну запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats1.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// selectActive должен вернуть только активную запись
final activeStats = await db.wordStatisticsDao.selectActive().get();
expect(activeStats.length, equals(1));
expect(activeStats.first.id, equals(stats2.id));
});
test('getActiveById не возвращает удаленные записи', () async {
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
// Удалить запись
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
final result = await db.wordStatisticsDao.getActiveById(stats.id);
expect(result, isNull);
});
});
});
}

View file

@ -0,0 +1,40 @@
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
void main() {
group('Database Schema Tests', () {
test('AppDatabase can be instantiated', () {
// This test verifies that the database class can be created
// Actual connection will be tested with real PostgreSQL instance
expect(() => AppDatabase.connect(
host: 'localhost',
port: 5432,
database: 'test_db',
username: 'test_user',
password: 'test_pass',
), returnsNormally);
});
test('All tables are registered', () {
// Verify that all expected tables are in the database
final db = AppDatabase.connect(
host: 'localhost',
port: 5432,
database: 'test_db',
username: 'test_user',
password: 'test_pass',
);
// Check that DAOs are available
expect(db.userDao, isNotNull);
expect(db.packDao, isNotNull);
expect(db.testDao, isNotNull);
expect(db.paymentDao, isNotNull);
expect(db.subscriptionDao, isNotNull);
expect(db.taskDao, isNotNull);
expect(db.promoCodeDao, isNotNull);
expect(db.discountDao, isNotNull);
expect(db.statisticsDao, isNotNull);
});
});
}

View file

@ -0,0 +1,319 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
import 'package:mnemo_cards_backend/statistics/word_statistics_manager.dart';
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
/// Smoke тесты для проверки базовой функциональности после деплоя
///
/// Эти тесты проверяют:
/// - Создание БД и таблиц
/// - Основные CRUD операции
/// - Интеграцию WordStatisticsManager с TestManager
/// - Расчет статистики через StatisticsCalculator
///
/// Требования:
/// - PostgreSQL должен быть запущен
/// - Тестовая БД: mnemo_cards_test
void main() {
late AppDatabase db;
late WordStatisticsManager wordStatsManager;
late StatisticsCalculator statisticsCalculator;
late String testUserId;
late String testCardId;
late String testPackId;
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
db = AppDatabase.connect(
host: host,
port: port,
database: database,
username: username,
password: password,
);
// Создать схему БД
await db.migrator.createAll();
// Создать тестовые данные
final user = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'smoke_test_user_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Smoke Test User'),
email: Value('smoke@test.com'),
),
);
testUserId = user.id;
await db.userDao.createUserData(
UserDatasCompanion.insert(userId: testUserId),
);
final pack = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Smoke Test Pack',
subtitle: 'Test Subtitle',
size: 10,
),
);
testPackId = pack.id;
final card = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test',
translation: 'тест',
image: 'test.png',
),
);
testCardId = card.id;
await db.packDao.linkCardToPack(testCardId, testPackId);
await db.packDao.linkUserToPack(testUserId, testPackId);
wordStatsManager = WordStatisticsManager(db);
statisticsCalculator = StatisticsCalculator(db);
});
tearDownAll(() async {
// Очистить тестовые данные
await db.delete(db.wordStatistics).go();
await db.delete(db.cardPackCards).go();
await db.delete(db.userPacks).go();
await db.delete(db.gameCards).go();
await db.delete(db.cardPacks).go();
await db.delete(db.userDatas).go();
await db.delete(db.users).go();
await db.close();
});
group('Smoke Tests', () {
test('БД создается без ошибок', () {
expect(db, isNotNull);
expect(db.userDao, isNotNull);
expect(db.packDao, isNotNull);
expect(db.wordStatisticsDao, isNotNull);
expect(db.statisticsDao, isNotNull);
});
test('WordStatistics записываются после ответа', () async {
// Имитация ответа на карточку (как в TestManager.submitTest)
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats, isNotNull);
expect(stats!.correctAnswers, equals(1));
expect(stats.incorrectAnswers, equals(0));
expect(stats.mastery, equals(1.0));
});
test('packProgress рассчитывается корректно', () async {
// Записать несколько ответов
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
// Создать еще одну карточку в паке
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
await db.packDao.linkCardToPack(card2.id, testPackId);
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: card2.id,
isCorrect: false,
);
// Рассчитать packProgress
final packProgress = await statisticsCalculator.calculatePackProgress(
testUserId,
testPackId,
);
expect(packProgress.packId, equals(testPackId));
expect(packProgress.totalCards, equals(10));
expect(packProgress.learnedCards, equals(2)); // 2 карточки с ответами
expect(packProgress.averageAccuracy, closeTo(0.5, 0.01)); // 1 правильный, 1 неправильный
});
test('studyDates рассчитываются из StudySessions', () async {
// Создать сессию изучения
final now = DateTime.now();
await db.statisticsDao.createSession(
StudySessionsCompanion.insert(
userId: testUserId,
packId: testPackId,
startTime: PgDateTime(now),
endTime: Value(PgDateTime(now.add(const Duration(minutes: 10)))),
durationMinutes: 10,
),
);
final studyDates = await statisticsCalculator.calculateStudyDates(testUserId);
expect(studyDates, isNotEmpty);
// Проверить что дата сегодняшнего дня присутствует
final today = DateTime(now.year, now.month, now.day);
expect(
studyDates.any((d) =>
d.year == today.year &&
d.month == today.month &&
d.day == today.day),
isTrue,
);
});
test('categoryMinutes рассчитываются из StudySessions', () async {
// Создать несколько сессий
final now = DateTime.now();
await db.statisticsDao.createSession(
StudySessionsCompanion.insert(
userId: testUserId,
packId: testPackId,
startTime: PgDateTime(now),
endTime: Value(PgDateTime(now.add(const Duration(minutes: 15)))),
durationMinutes: 15,
),
);
await db.statisticsDao.createSession(
StudySessionsCompanion.insert(
userId: testUserId,
packId: testPackId,
startTime: PgDateTime(now.add(const Duration(hours: 1))),
endTime: Value(PgDateTime(now.add(const Duration(hours: 1, minutes: 20)))),
durationMinutes: 20,
),
);
final categoryMinutes = await statisticsCalculator.calculateCategoryMinutes(
testUserId,
);
expect(categoryMinutes, isNotEmpty);
// Должно быть минимум 35 минут (15 + 20)
final totalMinutes = categoryMinutes.values.fold<int>(0, (sum, minutes) => sum + minutes);
expect(totalMinutes, greaterThanOrEqualTo(35));
});
test('soft delete работает корректно', () async {
// Создать запись статистики
final stats = await db.wordStatisticsDao.create(
userId: testUserId,
cardId: testCardId,
correctAnswers: 5,
incorrectAnswers: 2,
);
// Soft delete
await (db.update(db.wordStatistics)
..where((w) => w.id.equals(stats.id)))
.write(
WordStatisticsCompanion(
isDeleted: const Value(true),
deletedAt: Value(PgDateTime(DateTime.now())),
),
);
// Проверить что запись не возвращается через getActiveById
final activeRecord = await db.wordStatisticsDao.getActiveById(stats.id);
expect(activeRecord, isNull);
// Проверить что запись физически существует в БД
final allRecords = await db.select(db.wordStatistics).get();
expect(allRecords.length, greaterThan(0));
expect(allRecords.any((r) => r.id == stats.id && r.isDeleted), isTrue);
});
test('WordStatisticsManager обновляет существующую статистику', () async {
// Первый ответ
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
// Второй ответ
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
// Третий ответ (неправильный)
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: false,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats, isNotNull);
expect(stats!.correctAnswers, equals(2));
expect(stats.incorrectAnswers, equals(1));
expect(stats.mastery, closeTo(2.0 / 3.0, 0.001));
});
test('getPackStatistics возвращает статистику по карточкам пака', () async {
// Создать еще одну карточку в паке
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
await db.packDao.linkCardToPack(card2.id, testPackId);
// Записать ответы для обеих карточек
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await wordStatsManager.recordAnswer(
userId: testUserId,
cardId: card2.id,
isCorrect: false,
);
final packStats = await wordStatsManager.getPackStatistics(
testUserId,
testPackId,
);
expect(packStats.length, equals(2));
expect(
packStats.map((s) => s.cardId).toSet(),
containsAll([testCardId, card2.id]),
);
});
});
}

View file

@ -0,0 +1,320 @@
import 'dart:io';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/statistics/word_statistics_manager.dart';
import 'package:mnemo_cards_backend/database/tables/users.dart';
import 'package:mnemo_cards_backend/database/tables/packs.dart';
/// Unit тесты для WordStatisticsManager
///
/// Требования:
/// - PostgreSQL должен быть запущен на localhost:5432
/// - Тестовая БД: mnemo_cards_test
void main() {
late AppDatabase db;
late WordStatisticsManager manager;
late String testUserId;
late String testCardId;
setUpAll(() async {
// Подключение к тестовой БД
final host = Platform.environment['TEST_DB_HOST'] ?? 'localhost';
final port = int.tryParse(Platform.environment['TEST_DB_PORT'] ?? '5432') ?? 5432;
final database = Platform.environment['TEST_DB_NAME'] ?? 'mnemo_cards_test';
final username = Platform.environment['TEST_DB_USER'] ?? 'postgres';
final password = Platform.environment['TEST_DB_PASSWORD'] ?? 'postgres';
db = AppDatabase.connect(
host: host,
port: port,
database: database,
username: username,
password: password,
);
await db.migrator.createAll();
// Создать тестовые данные
final user = await db.userDao.createUser(
UsersCompanion.insert(
externalUserId: 'test_user_${DateTime.now().millisecondsSinceEpoch}',
name: Value('Test User'),
email: Value('test@example.com'),
),
);
testUserId = user.id;
await db.userDao.createUserData(
UserDatasCompanion.insert(userId: testUserId),
);
final card = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test',
translation: 'тест',
image: 'test.png',
),
);
testCardId = card.id;
manager = WordStatisticsManager(db);
});
tearDown(() async {
await db.delete(db.wordStatistics).go();
});
tearDownAll(() async {
await db.delete(db.wordStatistics).go();
await db.delete(db.gameCards).go();
await db.delete(db.userDatas).go();
await db.delete(db.users).go();
await db.close();
});
group('WordStatisticsManager', () {
group('recordAnswer', () {
test('создает новую запись при первом ответе', () async {
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats, isNotNull);
expect(stats!.correctAnswers, equals(1));
expect(stats.incorrectAnswers, equals(0));
expect(stats.mastery, equals(1.0));
expect(stats.lastReviewed, isNotNull);
});
test('обновляет существующую запись при повторном ответе', () async {
// Первый ответ - правильный
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
// Второй ответ - неправильный
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: false,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats, isNotNull);
expect(stats!.correctAnswers, equals(1));
expect(stats.incorrectAnswers, equals(1));
expect(stats.mastery, equals(0.5));
});
test('увеличивает счетчики корректно', () async {
// Несколько правильных ответов
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
// Один неправильный
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: false,
);
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats!.correctAnswers, equals(3));
expect(stats.incorrectAnswers, equals(1));
expect(stats.mastery, equals(0.75));
});
test('обновляет lastReviewed при каждом ответе', () async {
final before = DateTime.now();
await Future.delayed(const Duration(milliseconds: 10));
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await Future.delayed(const Duration(milliseconds: 10));
final after = DateTime.now();
final stats = await db.wordStatisticsDao.getByUserAndCard(
testUserId,
testCardId,
);
expect(stats!.lastReviewed, isNotNull);
final lastReviewed = stats.lastReviewed!.toDateTime();
expect(lastReviewed.isAfter(before), isTrue);
expect(lastReviewed.isBefore(after), isTrue);
});
});
group('calculateMastery', () {
test('возвращает 0.0 для нулевых значений', () {
expect(manager.calculateMastery(0, 0), equals(0.0));
});
test('возвращает 1.0 для 100% правильных ответов', () {
expect(manager.calculateMastery(10, 0), equals(1.0));
expect(manager.calculateMastery(5, 0), equals(1.0));
});
test('возвращает 0.0 для 100% неправильных ответов', () {
expect(manager.calculateMastery(0, 10), equals(0.0));
expect(manager.calculateMastery(0, 5), equals(0.0));
});
test('рассчитывает процент корректно', () {
expect(manager.calculateMastery(7, 3), equals(0.7));
expect(manager.calculateMastery(1, 1), equals(0.5));
expect(manager.calculateMastery(3, 7), equals(0.3));
});
test('работает с большими числами', () {
expect(manager.calculateMastery(100, 0), equals(1.0));
expect(manager.calculateMastery(75, 25), equals(0.75));
expect(manager.calculateMastery(50, 50), equals(0.5));
});
});
group('getPackStatistics', () {
test('возвращает статистику по карточкам пака', () async {
// Создать пак и связать карточку
final pack = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Test Pack',
subtitle: 'Test Subtitle',
size: 10,
),
);
await db.packDao.linkCardToPack(testCardId, pack.id);
// Создать еще одну карточку в паке
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
await db.packDao.linkCardToPack(card2.id, pack.id);
// Записать ответы
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await manager.recordAnswer(
userId: testUserId,
cardId: card2.id,
isCorrect: true,
);
final packStats = await manager.getPackStatistics(
testUserId,
pack.id,
);
expect(packStats.length, equals(2));
expect(
packStats.map((s) => s.cardId).toSet(),
containsAll([testCardId, card2.id]),
);
});
test('возвращает пустой список если нет статистики', () async {
final pack = await db.packDao.createPack(
CardPacksCompanion.insert(
title: 'Empty Pack',
subtitle: 'Empty Subtitle',
size: 5,
),
);
final packStats = await manager.getPackStatistics(
testUserId,
pack.id,
);
expect(packStats, isEmpty);
});
});
group('getUserStatistics', () {
test('возвращает всю статистику пользователя', () async {
// Создать несколько карточек
final card2 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test2',
translation: 'тест2',
image: 'test2.png',
),
);
final card3 = await db.packDao.createCard(
GameCardsCompanion.insert(
original: 'test3',
translation: 'тест3',
image: 'test3.png',
),
);
// Записать ответы для всех карточек
await manager.recordAnswer(
userId: testUserId,
cardId: testCardId,
isCorrect: true,
);
await manager.recordAnswer(
userId: testUserId,
cardId: card2.id,
isCorrect: true,
);
await manager.recordAnswer(
userId: testUserId,
cardId: card3.id,
isCorrect: false,
);
final userStats = await manager.getUserStatistics(testUserId);
expect(userStats.length, equals(3));
expect(
userStats.map((s) => s.cardId).toSet(),
containsAll([testCardId, card2.id, card3.id]),
);
});
});
});
}

View file

@ -2,7 +2,6 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
import 'package:telegram_web_app/telegram_web_app.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import 'package:yx_state_flutter/yx_state_flutter.dart';
@ -66,7 +65,9 @@ class _AppInitializerState extends State<_AppInitializer> {
}
}
Future<void> _tryTelegramWebAppAuth() async {
/// Try Telegram Web App authentication
/// Returns true if authentication was successful, false otherwise
Future<bool> _tryTelegramWebAppAuth() async {
try {
log('Checking Telegram Web App authentication...', name: 'App');
@ -89,14 +90,16 @@ class _AppInitializerState extends State<_AppInitializer> {
widget.appScope.userScopeHolder.notifyAuthChanged();
}
// Don't continue with normal auto-login if Telegram auth succeeded
return;
// Return true to indicate successful authentication
return true;
} else {
log('Telegram Web App authentication not available or failed', name: 'App');
return false;
}
} catch (e, s) {
log('Error during Telegram Web App authentication', error: e, stackTrace: s);
// Continue with normal flow - this is not a critical error
return false;
}
}
@ -105,40 +108,47 @@ class _AppInitializerState extends State<_AppInitializer> {
log('Starting app initialization...', name: 'App');
// Try Telegram Web App authentication first
await _tryTelegramWebAppAuth();
final telegramAuthSuccess = await _tryTelegramWebAppAuth();
// Try auto-login first with timeout
log('Attempting auto-login...', name: 'App');
UserDto? user;
try {
user = await widget.appScope.authService.autoLogin().timeout(
const Duration(seconds: 3),
onTimeout: () {
log('Auto-login timed out, continuing as guest', name: 'App');
return null;
},
);
} catch (e) {
log('Auto-login failed with error, continuing as guest', error: e, name: 'App');
user = null;
}
if (widget.appScope.userScopeHolder.scope == null) {
log('Creating UserScope...', name: 'App');
await widget.appScope.userScopeHolder.create();
}
if (user != null) {
log('Auto-login successful, creating UserScope', name: 'App');
// Create UserScope only for authenticated users
await widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify router about auth change
widget.appScope.userScopeHolder.notifyAuthChanged();
log('UserScope created and user set', name: 'App');
// If Telegram Web App auth succeeded, skip normal auto-login
// (tokens are already saved and user is set)
if (telegramAuthSuccess) {
log('Telegram Web App auth succeeded, skipping normal auto-login', name: 'App');
} else {
log('No saved session, starting as guest without UserScope', name: 'App');
// Try auto-login with timeout
log('Attempting auto-login...', name: 'App');
UserDto? user;
try {
user = await widget.appScope.authService.autoLogin().timeout(
const Duration(seconds: 3),
onTimeout: () {
log('Auto-login timed out, continuing as guest', name: 'App');
return null;
},
);
} catch (e) {
log('Auto-login failed with error, continuing as guest', error: e, name: 'App');
user = null;
}
// Create UserScope if needed
if (widget.appScope.userScopeHolder.scope == null) {
log('Creating UserScope...', name: 'App');
await widget.appScope.userScopeHolder.create();
}
if (user != null) {
log('Auto-login successful, setting user', name: 'App');
await widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify router about auth change
widget.appScope.userScopeHolder.notifyAuthChanged();
log('UserScope created and user set', name: 'App');
} else {
log('No saved session, starting as guest', name: 'App');
}
}
log('Setting _isInitialized = true', name: 'App');
setState(() {
_isInitialized = true;

View file

@ -139,6 +139,11 @@ class ApiConfigV2 {
static String packCardImage(String packId, String cardId) =>
'/packs/$packId/cards/$cardId/image';
/// GET /api/v2/packs/{packId}/card/{cardId}/imageBack
/// Get card back image
static String packCardImageBack(String packId, String cardId) =>
'/packs/$packId/cards/$cardId/imageBack';
/// GET /api/v2/voice/{voiceId}
/// Get voice file
static String voiceFile(String voiceId) => '/voice/$voiceId';
@ -229,6 +234,12 @@ class ApiConfigV2 {
return '$baseUrl${packCardImage(packId, cardId)}';
}
/// Get card back image URL for a specific card
/// Returns: http://baseUrl/api/v2/packs/{packId}/cards/{cardId}/imageBack
static String getCardImageBackUrl(String packId, String cardId) {
return '$baseUrl${packCardImageBack(packId, cardId)}';
}
/// Get voice file URL by id
/// Returns: http://baseUrl/api/v2/voice/{voiceId}
static String getVoiceFileUrl(String voiceId) {

View file

@ -22,6 +22,7 @@ class AppColors {
static const Color backgroundBlue = Color(0xFFf0f0f0);
static const Color testBlue = Color(0xffD0EAFF);
static const Color borderGray = Color(0xffABABAB);
static const Color mnemoRed = Color(0xFFE53935);
/// Цветовая палитра для черного (для MaterialColor)
static const Map<int, Color> blackSwatch = {

View file

@ -411,32 +411,134 @@ class _CardSide extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Column(
child: isFront
? _buildFrontLayout()
: _buildBackLayout(),
),
);
}
Widget _buildFrontLayout() {
return Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
// Изображение
// Верхняя секция: Original + Translation + Voice Controls
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Column(
children: [
// Voice Controls
CardVoiceControls(
packId: packId,
cardId: card.id,
accentColor: packColor,
),
const SizedBox(height: 16),
// Original текст - нормальный цвет для хорошей читаемости
if (card.original != null && card.original!.isNotEmpty)
MnemoText(
card.original,
textStyle: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 3,
),
// Translation - серый и меньше
if (card.translation != null && card.translation!.isNotEmpty) ...[
const SizedBox(height: 12),
MnemoText(
card.translation,
textStyle: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.center,
maxLines: 2,
),
],
],
),
),
// Картинка в середине
Expanded(
flex: 3,
child: _buildImage(),
),
// Текст
Expanded(
flex: 2,
child: Container(
// Mnemo фраза внизу - красным цветом
if (card.mnemo != null && card.mnemo!.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
color: packColor.withOpacity(0.05),
child: isFront
? _buildFrontText()
: _buildBackText(),
child: Center(
child: SingleChildScrollView(
child: MnemoText(
card.mnemo,
textStyle: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.mnemoRed,
),
textAlign: TextAlign.center,
maxLines: 4,
),
),
),
),
),
],
),
),
);
},
);
}
Widget _buildBackLayout() {
final hasImageBack = card.imageBack != null && card.imageBack!.isNotEmpty;
if (hasImageBack) {
// Если есть imageBack - показываем его в центре и текст под ним
return Column(
children: [
// Пустое пространство сверху (для выравнивания по высоте с front)
const Spacer(flex: 1),
// Изображение на задней стороне
Expanded(
flex: 3,
child: _buildImageBack(),
),
// Текст back под изображением
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: _buildBackText(),
),
const Spacer(flex: 1),
],
);
} else {
// Если нет imageBack - показываем текст по центру
return Center(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: _buildBackText(),
),
);
}
}
Widget _buildImage() {
if (card.image == null || card.image!.isEmpty) {
return Container(
@ -485,89 +587,91 @@ class _CardSide extends StatelessWidget {
);
}
Widget _buildFrontText() {
return Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CardVoiceControls(
packId: packId,
cardId: card.id,
accentColor: packColor,
),
const SizedBox(height: 12),
if (card.original != null && card.original!.isNotEmpty)
MnemoText(
card.original,
textStyle: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: packColor,
),
textAlign: TextAlign.center,
maxLines: 3,
),
if (card.translation != null && card.translation!.isNotEmpty) ...[
const SizedBox(height: 16),
Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return MnemoText(
card.translation,
textStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
maxLines: 3,
);
},
),
],
],
Widget _buildImageBack() {
if (card.imageBack == null || card.imageBack!.isEmpty) {
return Container(
color: packColor.withOpacity(0.1),
child: Center(
child: Icon(
Icons.collections_bookmark,
size: 100,
color: packColor.withOpacity(0.3),
),
),
),
);
}
final imageUrl = ApiConfigV2.getCardImageBackUrl(packId, card.id);
return Image.network(
imageUrl,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
color: packColor,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: packColor.withOpacity(0.1),
child: Center(
child: Icon(
Icons.broken_image,
size: 100,
color: packColor.withOpacity(0.3),
),
),
);
},
);
}
Widget _buildBackText() {
return Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (card.mnemo != null && card.mnemo!.isNotEmpty)
MnemoText(
card.mnemo,
textStyle: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: packColor,
),
textAlign: TextAlign.center,
maxLines: 5,
)
else
Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Text(
'Нет мнемоники',
return Builder(
builder: (context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (card.back != null && card.back!.isNotEmpty)
MnemoText(
card.back,
textStyle: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 5,
)
else
Text(
'Нет текста на обратной стороне',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w400,
color: colorScheme.onSurface.withOpacity(0.38),
),
textAlign: TextAlign.center,
);
},
),
],
),
),
),
],
),
),
);
},
);
}
}