From c6dfbb3289f40b67cc796101898e853ad8f8a8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=BCneyt=20=C5=9Eahin?= Date: Mon, 16 Mar 2026 15:03:51 +0300 Subject: [PATCH] =?UTF-8?q?D=C3=B6k=C3=BCman=20k=C4=B1sm=C4=B1=20g=C3=BCnc?= =?UTF-8?q?ellendi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Döküman kısmına flash kart ve quiz kısmı eklendi.Ek olarak da birkaç küçüü döküman bölümleri güncellendi --- backend/src/routes/documents.ts | 59 +- backend/src/services/document_rag.service.ts | 118 ++- backend/src/services/llm_backend.service.ts | 40 + lib/app/shell/app_shell.dart | 1 + .../presentation/document_detail_screen.dart | 427 +++++++++-- .../presentation/documents_screen.dart | 319 ++++++-- .../presentation/flashcard_screen.dart | 711 ++++++++++++++++++ .../documents/presentation/quiz_screen.dart | 666 ++++++++++++++++ .../presentation/session_running_screen.dart | 1 + lib/shared/data/api_document_repository.dart | 33 + lib/shared/data/flashcard_repository.dart | 152 ++++ lib/shared/data/providers.dart | 1 + lib/shared/data/quiz_repository.dart | 218 ++++++ lib/shared/models/models.dart | 62 ++ lib/shared/services/api_service.dart | 4 +- llm_backend/app/api/rag.py | 70 +- llm_backend/app/core/config.py | 2 +- llm_backend/app/core/prompts.py | 54 +- llm_backend/app/models/chat_models.py | 39 + llm_backend/app/services/llm_service.py | 57 +- pubspec.lock | 32 +- 21 files changed, 2911 insertions(+), 155 deletions(-) create mode 100644 lib/features/documents/presentation/flashcard_screen.dart create mode 100644 lib/features/documents/presentation/quiz_screen.dart create mode 100644 lib/shared/data/flashcard_repository.dart create mode 100644 lib/shared/data/quiz_repository.dart diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index f5bd406..3a30e31 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -181,7 +181,64 @@ router.post('/:id/chat', async (req: AuthRequest, res: Response, next: NextFunct } }); -// GET /documents/:id/chat/history - Document chat history +// POST /documents/:id/quiz - Quiz soruları üret +router.post('/:id/quiz', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const doc = await documentService.getDocument(userId, req.params.id); + + if (doc.status !== 'ready') { + throw new ConflictError('Doküman henüz işlenmedi. Lütfen bekleyin.'); + } + + const count = Math.min(Math.max(parseInt(req.body?.count ?? '10', 10), 1), 20); + const difficulty = ['easy', 'medium', 'hard'].includes(req.body?.difficulty) + ? (req.body.difficulty as 'easy' | 'medium' | 'hard') + : 'medium'; + const instructions = req.body?.instructions as string | undefined; + + const questions = await documentRagService.generateQuiz({ + documentId: doc.id, + count, + difficulty, + instructions, + }); + + res.json({ questions, count: questions.length, difficulty }); + } catch (e) { + next(e); + } +}); + +// POST /documents/:id/flashcards - Flash kart üret +router.post('/:id/flashcards', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const doc = await documentService.getDocument(userId, req.params.id); + + if (doc.status !== 'ready') { + throw new ConflictError('Doküman henüz işlenmedi. Lütfen bekleyin.'); + } + + const count = Math.min(Math.max(parseInt(req.body?.count ?? '15', 10), 1), 25); + const difficulty = ['easy', 'medium', 'hard'].includes(req.body?.difficulty) + ? (req.body.difficulty as 'easy' | 'medium' | 'hard') + : 'medium'; + const instructions = req.body?.instructions as string | undefined; + + const cards = await documentRagService.generateFlashcards({ + documentId: doc.id, + count, + difficulty, + instructions, + }); + + res.json({ cards, count: cards.length, difficulty }); + } catch (e) { + next(e); + } +}); + router.get('/:id/chat/history', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const userId = req.user!.userId; diff --git a/backend/src/services/document_rag.service.ts b/backend/src/services/document_rag.service.ts index c680053..ead3a12 100644 --- a/backend/src/services/document_rag.service.ts +++ b/backend/src/services/document_rag.service.ts @@ -10,7 +10,12 @@ import { createWorker } from 'tesseract.js'; import textract from 'textract'; import xlsx from 'xlsx'; import { pool } from '../db/pool'; -import { answerWithContext, embedTexts } from './llm_backend.service'; +import { + answerWithContext, + embedTexts, + generateQuiz as llmGenerateQuiz, + generateFlashcards as llmGenerateFlashcards, +} from './llm_backend.service'; const CHUNK_WORDS = 150; const CHUNK_OVERLAP = 20; @@ -179,9 +184,15 @@ export async function chatWithDocument(params: { } const context = chunks.map((c) => c.chunk_text).join('\n\n---\n\n'); - const answer = await answerWithContext(params.question, context, params.history ?? []); + let answer = await answerWithContext(params.question, context, params.history ?? []); - const sources: RagSource[] = chunks.map((c) => { + let usedContext = true; + if (answer.includes('[BAĞLAM_KULLANILMADI]')) { + usedContext = false; + answer = answer.replace(/\[BAĞLAM_KULLANILMADI\]/g, '').trim(); + } + + const sources: RagSource[] = usedContext ? chunks.map((c) => { const page = c.metadata?.page; const label = page ? `Sayfa ${page}` : `Bölüm ${c.chunk_index + 1}`; return { @@ -190,11 +201,110 @@ export async function chatWithDocument(params: { pageLabel: label, docTitle: params.docTitle, }; - }); + }) : []; return { answer, sources }; } +// ── Quiz / Test Sorusu Üretimi ──────────────────────────────────────────────── + +export interface QuizQuestion { + question: string; + options: [string, string, string, string]; // A, B, C, D + answer: string; // 'A' | 'B' | 'C' | 'D' + explanation: string; +} + +const DIFFICULTY_PROMPTS: Record = { + easy: 'ZORLUK: KOLAY. Sorular doğrudan metindeki temel tanımları ve en açık bilgileri sormalıdır.', + medium: 'ZORLUK: ORTA. Sorular metindeki olayların/kavramların ilişkilerini anlamayı gerektirmelidir.', + hard: 'ZORLUK: ZOR. Sorular metin üzerinden analiz ve derin çıkarım yapmayı gerektirmelidir.', +}; + +export async function generateQuiz(params: { + documentId: string; + count: number; + difficulty: 'easy' | 'medium' | 'hard'; + instructions?: string; +}): Promise { + const chunkRes = await pool.query( + `SELECT chunk_text FROM document_chunks + WHERE document_id = $1 + ORDER BY RANDOM() + LIMIT $2`, + [params.documentId, Math.min(params.count * 3, 30)] + ); + + if (chunkRes.rows.length === 0) { + throw new Error('Belgede işlenmiş içerik bulunamadı.'); + } + + const context = chunkRes.rows.map((r: { chunk_text: string }) => r.chunk_text).join('\n\n---\n\n'); + + // Call the dedicated LLM endpoint + const data = await llmGenerateQuiz(context.slice(0, 8000), params.count, params.difficulty, params.instructions); + + // The endpoint returns a dict: { "questions": [...] } + let questions: QuizQuestion[] = data.questions || []; + + return questions + .filter( + (q) => + q.question && + Array.isArray(q.options) && + q.answer && + q.explanation + ) + .slice(0, params.count); +} + +// ── Flash Kart Üretimi ─────────────────────────────────────────────────────── + +export interface Flashcard { + front: string; // ön yüz: kavram / soru + back: string; // arka yüz: tanım / cevap +} + +const FLASHCARD_DIFFICULTY_PROMPTS: Record = { + easy: 'ZORLUK: KOLAY. Kartlar temel tanımları ve en açık kavramları içermelidir.', + medium: 'ZORLUK: ORTA. Kartlar kavramlar arası ilişkileri ve nedenleri kapsamalıdır.', + hard: 'ZORLUK: ZOR. Kartlar derin analiz ve çıkarım gerektiren bilgileri içermelidir.', +}; + +export async function generateFlashcards(params: { + documentId: string; + count: number; + difficulty: 'easy' | 'medium' | 'hard'; + instructions?: string; +}): Promise { + const chunkRes = await pool.query( + `SELECT chunk_text FROM document_chunks + WHERE document_id = $1 + ORDER BY RANDOM() + LIMIT $2`, + [params.documentId, Math.min(params.count * 3, 30)] + ); + + if (chunkRes.rows.length === 0) { + throw new Error('Belgede işlenmiş içerik bulunamadı.'); + } + + const context = chunkRes.rows.map((r: { chunk_text: string }) => r.chunk_text).join('\n\n---\n\n'); + + const data = await llmGenerateFlashcards(context.slice(0, 8000), params.count, params.difficulty, params.instructions); + + let cards: Flashcard[] = data.cards || []; + + return cards + .filter( + (c) => + c.front && + c.back + ) + .slice(0, params.count); +} + + async function extractText(filePath: string, mimeType: string): Promise { const extension = path.extname(filePath).toLowerCase(); if (mimeType.includes('pdf') || extension === '.pdf') { diff --git a/backend/src/services/llm_backend.service.ts b/backend/src/services/llm_backend.service.ts index b186db3..4cbf6e4 100644 --- a/backend/src/services/llm_backend.service.ts +++ b/backend/src/services/llm_backend.service.ts @@ -58,6 +58,46 @@ export async function answerWithContext( return data.answer; } +export async function generateQuiz( + context: string, + count: number, + difficulty: string, + instructions?: string +): Promise { + const response = await fetch(`${config.LLM_BACKEND_URL}/rag/quiz`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ context, count, difficulty, instructions }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM quiz error: ${response.status} ${body}`); + } + + return response.json(); +} + +export async function generateFlashcards( + context: string, + count: number, + difficulty: string, + instructions?: string +): Promise { + const response = await fetch(`${config.LLM_BACKEND_URL}/rag/flashcards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ context, count, difficulty, instructions }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM flashcard error: ${response.status} ${body}`); + } + + return response.json(); +} + export async function askCoach(message: string): Promise { const response = await fetch(`${config.LLM_BACKEND_URL}/chat`, { method: 'POST', diff --git a/lib/app/shell/app_shell.dart b/lib/app/shell/app_shell.dart index 52c4963..8e4aaf6 100644 --- a/lib/app/shell/app_shell.dart +++ b/lib/app/shell/app_shell.dart @@ -69,6 +69,7 @@ class AppShell extends ConsumerWidget { ), floatingActionButton: navigationShell.currentIndex == 2 ? FloatingActionButton( + heroTag: 'fab_upload_document', onPressed: () { showDocumentUploadOptions( context: context, diff --git a/lib/features/documents/presentation/document_detail_screen.dart b/lib/features/documents/presentation/document_detail_screen.dart index 2bec8be..706bed2 100644 --- a/lib/features/documents/presentation/document_detail_screen.dart +++ b/lib/features/documents/presentation/document_detail_screen.dart @@ -3,7 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:learning_coach/core/constants/app_strings.dart'; import 'package:learning_coach/core/providers/locale_provider.dart'; +import 'package:learning_coach/features/documents/presentation/flashcard_screen.dart'; +import 'package:learning_coach/features/documents/presentation/quiz_screen.dart'; +import 'package:learning_coach/shared/data/flashcard_repository.dart'; import 'package:learning_coach/shared/data/providers.dart'; +import 'package:learning_coach/shared/data/quiz_repository.dart'; import 'package:learning_coach/shared/models/models.dart'; class DocumentDetailScreen extends ConsumerWidget { @@ -16,6 +20,18 @@ class DocumentDetailScreen extends ConsumerWidget { final locale = ref.watch(localeProvider); final scheme = Theme.of(context).colorScheme; + // quiz oturumlarını dinle (state değişince rebuild tetiklenir) + ref.watch(quizRepositoryProvider); + final sessions = ref + .read(quizRepositoryProvider.notifier) + .sessionsForDoc(document.id); + + // flash kart setlerini dinle + ref.watch(flashcardRepositoryProvider); + final flashcardSets = ref + .read(flashcardRepositoryProvider.notifier) + .setsForDoc(document.id); + return Scaffold( appBar: AppBar( title: Text(document.title), @@ -31,17 +47,11 @@ class DocumentDetailScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildStatusBadge( - context, - document.status, - locale, - document.processingProgress, - ), + _buildStatusBadge(context, document.status, locale, document.processingProgress), const SizedBox(height: 24), - Text( - AppStrings.getSummaryTitle(locale), - style: Theme.of(context).textTheme.headlineSmall, - ), + + // Özet + Text(AppStrings.getSummaryTitle(locale), style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(16), @@ -51,46 +61,97 @@ class DocumentDetailScreen extends ConsumerWidget { border: Border.all(color: scheme.outlineVariant), ), child: Text( - document.summary.isNotEmpty - ? document.summary - : AppStrings.getSummaryProcessing(locale), + document.summary.isNotEmpty ? document.summary : AppStrings.getSummaryProcessing(locale), style: Theme.of(context).textTheme.bodyLarge, ), ), - const SizedBox(height: 32), + + const SizedBox(height: 24), + + // Sohbet butonu SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: document.status == DocStatus.ready - ? () => context.go('/docs/chat', extra: document) - : null, + onPressed: document.status == DocStatus.ready ? () => context.go('/docs/chat', extra: document) : null, icon: const Icon(Icons.chat), label: Text(AppStrings.getAskDocHint(locale)), ), ), + const SizedBox(height: 12), + + // Test Hazırla butonu + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: document.status == DocStatus.ready + ? () async { + await showQuizSettingsSheet(context, document); + } + : null, + icon: const Icon(Icons.quiz_rounded), + label: const Text('Test Hazırla'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF6366F1), + side: const BorderSide(color: Color(0xFF6366F1)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 8), + + // Flash Kart Oluştur butonu + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: document.status == DocStatus.ready + ? () async { + await showFlashcardSettingsSheet(context, document); + } + : null, + icon: const Icon(Icons.style_rounded), + label: const Text('Flash Kart Oluştur'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF8B5CF6), + side: const BorderSide(color: Color(0xFF8B5CF6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + + // Oluşturulan Testler + if (sessions.isNotEmpty) ...[ + const SizedBox(height: 32), + Text( + 'Oluşturulan Testler', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + for (final s in sessions) _QuizSessionCard(session: s), + ], + + // Oluşturulan Flash Kart Setleri + if (flashcardSets.isNotEmpty) ...[ + const SizedBox(height: 32), + Text( + 'Oluşturulan Flash Kartlar', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + for (final s in flashcardSets) _FlashcardSetCard(set: s), + ], ], ), ), ); } - Widget _buildStatusBadge( - BuildContext context, - DocStatus status, - String locale, - double progress, - ) { + Widget _buildStatusBadge(BuildContext context, DocStatus status, String locale, double progress) { final scheme = Theme.of(context).colorScheme; - - Color color; - String label; - IconData icon; - + Color color; String label; IconData icon; switch (status) { case DocStatus.processing: color = Colors.orange; - label = - '${AppStrings.getDocStatusProcessing(locale)} %${(progress * 100).round()}'; + label = '${AppStrings.getDocStatusProcessing(locale)} %${(progress * 100).round()}'; icon = Icons.sync; break; case DocStatus.ready: @@ -117,10 +178,7 @@ class DocumentDetailScreen extends ConsumerWidget { children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 8), - Text( - label, - style: TextStyle(color: color, fontWeight: FontWeight.bold), - ), + Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold)), ], ), ); @@ -133,32 +191,297 @@ class DocumentDetailScreen extends ConsumerWidget { title: const Text('Doküman Silinsin mi?'), content: const Text('Bu işlem geri alınamaz.'), actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('İptal'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Sil'), - ), + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('İptal')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Sil')), ], ), ); - if (shouldDelete != true) return; - try { await ref.read(apiDocumentRepositoryProvider).deleteDocument(document.id); ref.invalidate(documentsProvider); - if (context.mounted) { - Navigator.pop(context); - } + if (context.mounted) Navigator.pop(context); } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Hata: $e'))); - } + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } +} + +// ─── Quiz Session Card ──────────────────────────────────────────────────────── + +class _QuizSessionCard extends StatelessWidget { + final QuizSession session; + const _QuizSessionCard({required this.session}); + + Color get _diffColor { + switch (session.difficulty) { + case 'easy': return const Color(0xFF10B981); + case 'hard': return const Color(0xFFEF4444); + default: return const Color(0xFFF59E0B); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (session.isGenerating) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Test Hazırlanıyor...', style: TextStyle(fontWeight: FontWeight.bold, color: scheme.primary)), + const SizedBox(height: 4), + Text('${session.difficultyLabel} · ${session.questionCount} soru', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + ), + ], + ), + ); } + + final bestAttempt = session.attempts.isEmpty + ? null + : session.attempts.reduce((a, b) => a.percent > b.percent ? a : b); + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + children: [ + // Zorluk ikonu + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: _diffColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + session.difficulty == 'easy' ? '😊' : session.difficulty == 'hard' ? '🔥' : '🎯', + style: const TextStyle(fontSize: 22), + ), + ), + ), + const SizedBox(width: 12), + + // Bilgi + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: _diffColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)), + child: Text(session.difficultyLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _diffColor)), + ), + const SizedBox(width: 6), + Text('${session.questionCount} soru', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + const SizedBox(height: 4), + if (bestAttempt != null) + Text( + 'En iyi: %${bestAttempt.percent} · ${session.attempts.length} deneme', + style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant), + ) + else + Text('Henüz denenmedi', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + ), + + // Info butonu + IconButton( + icon: Container( + width: 32, height: 32, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: scheme.outlineVariant, width: 1.5)), + child: const Icon(Icons.info_outline_rounded, size: 16), + ), + onPressed: session.attempts.isEmpty + ? null + : () => showQuizHistorySheet(context, session), + tooltip: 'Denemeler', + ), + + // Başlat butonu + const SizedBox(width: 4), + IconButton( + icon: Container( + width: 36, height: 36, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]), + shape: BoxShape.circle, + ), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 20), + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => QuizScreen(session: session), + ), + ), + tooltip: 'Başlat', + ), + ], + ), + ); + } +} + +// ─── Flashcard Set Card ─────────────────────────────────────────────────────── + +class _FlashcardSetCard extends StatelessWidget { + final FlashcardSet set; + const _FlashcardSetCard({required this.set}); + + Color get _diffColor { + switch (set.difficulty) { + case 'easy': return const Color(0xFF10B981); + case 'hard': return const Color(0xFFEF4444); + default: return const Color(0xFFF59E0B); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (set.isGenerating) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Kartlar Hazırlanıyor...', style: TextStyle(fontWeight: FontWeight.bold, color: scheme.primary)), + const SizedBox(height: 4), + Text('${set.difficultyLabel} · ${set.cardCount} kart', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + children: [ + // Zorluk ikonu + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: _diffColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + set.difficulty == 'easy' ? '😊' : set.difficulty == 'hard' ? '🔥' : '🎯', + style: const TextStyle(fontSize: 22), + ), + ), + ), + const SizedBox(width: 12), + + // Bilgi + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: _diffColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)), + child: Text(set.difficultyLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _diffColor)), + ), + const SizedBox(width: 6), + Text('${set.cardCount} kart', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + const SizedBox(height: 4), + Text( + '${set.createdAt.day}.${set.createdAt.month}.${set.createdAt.year}', + style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant), + ), + ], + ), + ), + + // Kart listesi butonu + IconButton( + icon: Container( + width: 32, height: 32, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: scheme.outlineVariant, width: 1.5)), + child: const Icon(Icons.list_rounded, size: 16), + ), + onPressed: () => showFlashcardHistorySheet(context, set), + tooltip: 'Kartları Gör', + ), + + // Çalış butonu + const SizedBox(width: 4), + IconButton( + icon: Container( + width: 36, height: 36, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]), + shape: BoxShape.circle, + ), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 20), + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => FlashcardStudyScreen(set: set)), + ), + tooltip: 'Hemen Çalış', + ), + ], + ), + ); } } diff --git a/lib/features/documents/presentation/documents_screen.dart b/lib/features/documents/presentation/documents_screen.dart index f2d14b7..e01f6ca 100644 --- a/lib/features/documents/presentation/documents_screen.dart +++ b/lib/features/documents/presentation/documents_screen.dart @@ -8,7 +8,7 @@ import 'package:learning_coach/core/constants/app_strings.dart'; import 'package:learning_coach/core/providers/locale_provider.dart'; import 'package:learning_coach/shared/data/providers.dart'; import 'package:learning_coach/shared/models/models.dart'; -import 'package:learning_coach/shared/widgets/document_upload_options.dart'; + class DocumentsScreen extends ConsumerStatefulWidget { const DocumentsScreen({super.key}); @@ -22,7 +22,8 @@ class _DocumentsScreenState extends ConsumerState { void _startPolling() { if (_poller != null) return; - _poller = Timer.periodic(const Duration(seconds: 3), (_) { + // 500ms — embedding request'leri aralarında bile yakalayabilmek için + _poller = Timer.periodic(const Duration(milliseconds: 500), (_) { ref.invalidate(documentsProvider); }); } @@ -78,15 +79,9 @@ class _DocumentsScreenState extends ConsumerState { toolbarHeight: 80, actions: const [SizedBox(width: 16)], ), - floatingActionButton: FloatingActionButton( - onPressed: () => showDocumentUploadOptions( - context: context, - ref: ref, - locale: locale, - ), - child: const Icon(Icons.add), - ), + floatingActionButton: null, body: docsAsync.when( + skipLoadingOnRefresh: true, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Hata: $err')), data: (docs) => docs.isEmpty @@ -181,25 +176,112 @@ class _DocumentsScreenState extends ConsumerState { } } -class _DocumentCard extends StatelessWidget { +// ─── Document Card (Stateful — yerel animasyonlu progress için) ─────────────── +class _DocumentCard extends StatefulWidget { final Document document; final String locale; const _DocumentCard({required this.document, required this.locale}); + @override + State<_DocumentCard> createState() => _DocumentCardState(); +} + +class _DocumentCardState extends State<_DocumentCard> { + // Yerel sahte ilerleme: DB değerinden düşükse yavaşça artar, + // DB değeri gelince ona snap eder (geriye gidemez). + double _localProgress = 0.0; + Timer? _fakeTimer; + + @override + void initState() { + super.initState(); + if (widget.document.status == DocStatus.processing) { + _localProgress = widget.document.processingProgress; + _startFakeTimer(); + } + } + + @override + void didUpdateWidget(_DocumentCard old) { + super.didUpdateWidget(old); + final real = widget.document.processingProgress; + // Gerçek değer geldiyse her zaman ona geç (geriye gitme) + if (real > _localProgress) { + setState(() => _localProgress = real); + } + if (widget.document.status == DocStatus.processing) { + _startFakeTimer(); + } else { + _stopFakeTimer(); + } + } + + void _startFakeTimer() { + if (_fakeTimer != null) return; + // Hız: totalChunks'a orantılı — büyük belge yavaş, küçük belge hızlı + // Her tick 200ms → saniyede 5 tick + // Hedef: embedding fazı (~%5→%90) totalChunks / batchSize * 2sn'de geçilsin + final chunks = widget.document.totalChunks; + double incrementPerTick; + if (chunks <= 0) { + incrementPerTick = 0.008; // bilinmiyorsa orta hız + } else { + final batchCount = (chunks / 10).ceil(); // batch başına ~2s (local Ollama) + final estimatedSeconds = batchCount * 2.0; + // %5'ten %90'a = 0.85 mesafe, estimatedSeconds * 5 tick + incrementPerTick = (0.85 / (estimatedSeconds * 5)).clamp(0.0005, 0.01); + } + + _fakeTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { + if (!mounted) return; + final real = widget.document.processingProgress; + // Gerçek değer varsa onu al (geriye gitme) + final base = real > _localProgress ? real : _localProgress; + // %99'a kadar hiç durmadan ilerle (summary aşaması dahil) + if (base < 0.99) { + setState(() => _localProgress = (base + incrementPerTick).clamp(0, 0.99)); + } + }); + } + + void _stopFakeTimer() { + _fakeTimer?.cancel(); + _fakeTimer = null; + } + + @override + void dispose() { + _stopFakeTimer(); + super.dispose(); + } + + String _stepLabel(double progress) { + // Gerçek DB değerini kullan (fazı doğru yansıtmak için) + final real = widget.document.processingProgress; + if (real <= 0.02) return 'Hazırlanıyor...'; + if (real <= 0.06) return 'Metin okunuyor...'; + if (real < 0.92) return 'Analiz ediliyor...'; + return 'Tamamlanıyor...'; + } + @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final doc = widget.document; + final locale = widget.locale; Color statusColor; IconData statusIcon; Color gradientStart; Color gradientEnd; - String statusLabel; - final progressPercent = (document.processingProgress * 100).round(); - switch (document.status) { + final isProcessing = doc.status == DocStatus.processing; + final displayProgress = isProcessing ? _localProgress : doc.processingProgress; + final progressPercent = (displayProgress * 100).round().clamp(0, 99); + + switch (doc.status) { case DocStatus.ready: statusColor = const Color(0xFF10B981); statusIcon = Icons.check_circle_rounded; @@ -212,8 +294,7 @@ class _DocumentCard extends StatelessWidget { statusIcon = Icons.sync_rounded; gradientStart = const Color(0xFFF59E0B); gradientEnd = const Color(0xFFF97316); - statusLabel = - '${AppStrings.getDocStatusProcessing(locale)} %$progressPercent'; + statusLabel = _stepLabel(displayProgress); break; case DocStatus.failed: statusColor = const Color(0xFFEF4444); @@ -240,81 +321,128 @@ class _DocumentCard extends StatelessWidget { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => context.go('/docs/detail', extra: document), + onTap: isProcessing + ? null + : () => context.go('/docs/detail', extra: doc), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(18.0), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - gradientStart.withValues(alpha: 0.2), - gradientEnd.withValues(alpha: 0.2), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - Icons.description_rounded, - color: statusColor, - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - document.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 6), - Text( - statusLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: statusColor, - fontWeight: FontWeight.w600, + Row( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + gradientStart.withValues(alpha: 0.2), + gradientEnd.withValues(alpha: 0.2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.description_rounded, + color: statusColor, + size: 28, ), - const SizedBox(height: 6), - Row( + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.access_time_rounded, - size: 14, - color: scheme.onSurfaceVariant, - ), - const SizedBox(width: 4), Text( - DateFormat( - 'd MMM, HH:mm', - ).format(document.uploadedAt), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: scheme.onSurfaceVariant, - fontWeight: FontWeight.w500, + doc.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + statusLabel, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + if (isProcessing) ...[ + const SizedBox(width: 6), + Text( + '%$progressPercent', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: statusColor, + fontWeight: FontWeight.bold, + ), ), + ], + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.access_time_rounded, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + DateFormat('d MMM, HH:mm').format(doc.uploadedAt), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], ), ], ), - ], - ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: isProcessing + ? _SpinningIcon(color: statusColor) + : Icon(statusIcon, color: statusColor, size: 24), + ), + ], ), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - shape: BoxShape.circle, + if (isProcessing) ...[ + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: TweenAnimationBuilder( + tween: Tween( + begin: _localProgress, + end: displayProgress, + ), + duration: const Duration(milliseconds: 400), + curve: Curves.easeOut, + builder: (context, value, _) { + return LinearProgressIndicator( + value: value, + minHeight: 6, + backgroundColor: + gradientStart.withValues(alpha: 0.15), + valueColor: + AlwaysStoppedAnimation(gradientStart), + ); + }, + ), ), - child: Icon(statusIcon, color: statusColor, size: 24), - ), + ], ], ), ), @@ -323,3 +451,40 @@ class _DocumentCard extends StatelessWidget { ); } } + +/// Dönen ikon — processing durumunda +class _SpinningIcon extends StatefulWidget { + final Color color; + const _SpinningIcon({required this.color}); + + @override + State<_SpinningIcon> createState() => _SpinningIconState(); +} + +class _SpinningIconState extends State<_SpinningIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _ctrl, + child: Icon(Icons.sync_rounded, color: widget.color, size: 24), + ); + } +} diff --git a/lib/features/documents/presentation/flashcard_screen.dart b/lib/features/documents/presentation/flashcard_screen.dart new file mode 100644 index 0000000..9f4880b --- /dev/null +++ b/lib/features/documents/presentation/flashcard_screen.dart @@ -0,0 +1,711 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:learning_coach/shared/data/flashcard_repository.dart'; +import 'package:learning_coach/shared/data/providers.dart'; +import 'package:learning_coach/shared/models/models.dart'; +import 'package:uuid/uuid.dart'; + +// ─── Show Flashcard Settings Sheet ─────────────────────────────────────────── + +Future showFlashcardSettingsSheet( + BuildContext context, + Document document, +) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _FlashcardSettingsSheet(document: document), + ); +} + +class _FlashcardSettingsSheet extends ConsumerStatefulWidget { + final Document document; + const _FlashcardSettingsSheet({required this.document}); + + @override + ConsumerState<_FlashcardSettingsSheet> createState() => _FlashcardSettingsSheetState(); +} + +class _FlashcardSettingsSheetState extends ConsumerState<_FlashcardSettingsSheet> { + int _cardCount = 15; + String _difficulty = 'medium'; + bool _loading = false; + String? _error; + final TextEditingController _promptController = TextEditingController(); + + @override + void dispose() { + _promptController.dispose(); + super.dispose(); + } + + static const _difficulties = [ + ('easy', 'Kolay', Icons.sentiment_satisfied_rounded, Color(0xFF10B981)), + ('medium', 'Orta', Icons.sentiment_neutral_rounded, Color(0xFFF59E0B)), + ('hard', 'Zor', Icons.sentiment_very_dissatisfied_rounded, Color(0xFFEF4444)), + ]; + + Future _createCards() async { + setState(() { _loading = true; _error = null; }); + + final dummySetId = const Uuid().v4(); + final set = FlashcardSet( + id: dummySetId, + documentId: widget.document.id, + documentTitle: widget.document.title, + difficulty: _difficulty, + cardCount: _cardCount, + cards: const [], + createdAt: DateTime.now(), + isGenerating: true, + ); + + final apiRepo = ref.read(apiDocumentRepositoryProvider); + final flashcardRepo = ref.read(flashcardRepositoryProvider.notifier); + + await flashcardRepo.addSet(set); + + if (!mounted) return; + final scaffoldMessenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + + // Start generation in background using the cached providers + apiRepo.generateFlashcards( + documentId: widget.document.id, + count: _cardCount, + difficulty: _difficulty, + instructions: _promptController.text.trim().isEmpty ? null : _promptController.text.trim(), + ).then((cards) { + if (cards.isNotEmpty) { + final updated = set.copyWith( + cards: cards, + cardCount: cards.length, + isGenerating: false, + ); + flashcardRepo.updateSet(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('✅ Flash kartlar başarıyla oluşturuldu ve hazır!'), backgroundColor: Colors.green, duration: Duration(seconds: 4)), + ); + } else { + final updated = set.copyWith(isGenerating: false, cardCount: 0); + flashcardRepo.updateSet(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('⚠️ Yeterli kart üretilemedi.'), backgroundColor: Colors.orange), + ); + } + }).catchError((e) { + debugPrint('Flashcard generation error: $e'); + final updated = set.copyWith(isGenerating: false, cardCount: 0); + flashcardRepo.updateSet(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('❌ Flash kart oluşturulurken bir hata oluştu.'), backgroundColor: Colors.red), + ); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(top: 80), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration(color: scheme.outlineVariant, borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 24), + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.style_rounded, color: Colors.white, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Flash Kart Oluştur', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + Text(widget.document.title, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + const SizedBox(height: 28), + + // Kart sayısı + Text('Kart Sayısı', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Row( + children: [('Daha az', 10), ('Standart', 15), ('Daha fazla', 20)].map((pair) { + final (label, n) = pair; + final sel = _cardCount == n; + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _cardCount = n), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 52, + decoration: BoxDecoration( + gradient: sel ? const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]) : null, + color: sel ? null : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: sel ? Colors.transparent : scheme.outlineVariant), + ), + child: Center( + child: Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold, color: sel ? Colors.white : scheme.onSurface)), + ), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Zorluk + Text('Zorluk', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Column( + children: _difficulties.map((d) { + final (key, label, icon, color) = d; + final sel = _difficulty == key; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GestureDetector( + onTap: () => setState(() => _difficulty = key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: sel ? color.withValues(alpha: 0.1) : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: sel ? color : scheme.outlineVariant, width: sel ? 2 : 1), + ), + child: Row( + children: [ + Icon(icon, color: sel ? color : scheme.onSurfaceVariant), + const SizedBox(width: 12), + Text(label, style: TextStyle(fontWeight: sel ? FontWeight.bold : FontWeight.normal, color: sel ? color : scheme.onSurface)), + ], + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // AI Prompt (Opsiyonel) + Text('Özel Talimat (İsteğe Bağlı)', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + TextField( + controller: _promptController, + maxLines: 3, + minLines: 1, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Örn: Sadece 3. bölümdeki kavramlara odaklan, biyoloji terimleriyle açıkla vs.', + hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 13), + filled: true, + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: const Color(0xFF6366F1), width: 2), + ), + ), + ), + + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12)), + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, height: 56, + child: FilledButton( + onPressed: _loading ? null : _createCards, + style: FilledButton.styleFrom(backgroundColor: const Color(0xFF6366F1), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + child: _loading + ? const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), + SizedBox(width: 12), + Text('Kartlar Oluşturuluyor...', style: TextStyle(color: Colors.white)), + ]) + : const Text('Kartları Oluştur', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Flashcard Study Screen ─────────────────────────────────────────────────── + +class FlashcardStudyScreen extends StatefulWidget { + final FlashcardSet set; + const FlashcardStudyScreen({super.key, required this.set}); + + @override + State createState() => _FlashcardStudyScreenState(); +} + +class _FlashcardStudyScreenState extends State + with TickerProviderStateMixin { + int _currentIndex = 0; + bool _flipped = false; + int _knownCount = 0; + bool _showResult = false; + + late final AnimationController _flipCtrl; + late final Animation _flipAnim; + + @override + void initState() { + super.initState(); + _flipCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); + _flipAnim = Tween(begin: 0, end: 1).animate(CurvedAnimation(parent: _flipCtrl, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _flipCtrl.dispose(); + super.dispose(); + } + + FlashCard get _card => widget.set.cards[_currentIndex]; + + Future _flip() async { + if (!_flipped) { + await _flipCtrl.forward(); + } else { + await _flipCtrl.reverse(); + } + setState(() => _flipped = !_flipped); + } + + void _next({required bool known}) { + if (known) _knownCount++; + if (_currentIndex < widget.set.cards.length - 1) { + setState(() { + _currentIndex++; + _flipped = false; + }); + _flipCtrl.reset(); + } else { + setState(() => _showResult = true); + } + } + + Color get _diffColor { + switch (widget.set.difficulty) { + case 'easy': return const Color(0xFF10B981); + case 'hard': return const Color(0xFFEF4444); + default: return const Color(0xFFF59E0B); + } + } + + @override + Widget build(BuildContext context) { + if (_showResult) return _buildResult(context); + return _buildStudy(context); + } + + Widget _buildStudy(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final total = widget.set.cards.length; + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + title: Text('${_currentIndex + 1} / $total', style: const TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => Navigator.of(context).pop()), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LinearProgressIndicator( + value: total > 0 ? _currentIndex / total : 0, + minHeight: 6, + borderRadius: BorderRadius.circular(4), + backgroundColor: scheme.surfaceContainerHighest, + valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Karta dokunarak çevir', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: _diffColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8)), + child: Text(widget.set.difficultyLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _diffColor)), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: GestureDetector( + onTap: _flip, + child: AnimatedBuilder( + animation: _flipAnim, + builder: (context, child) { + final angle = _flipAnim.value * math.pi; + final isBack = angle > (math.pi / 2); + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: isBack + ? Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(math.pi), + child: _buildCardFace(context, isBack: true), + ) + : _buildCardFace(context, isBack: false), + ); + }, + ), + ), + ), + ), + if (_flipped) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _next(known: false), + icon: const Icon(Icons.close_rounded, color: Color(0xFFEF4444)), + label: const Text('Bilmiyorum', style: TextStyle(color: Color(0xFFEF4444))), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: Color(0xFFEF4444)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: () => _next(known: true), + icon: const Icon(Icons.check_rounded, color: Colors.white), + label: const Text('Biliyorum', style: TextStyle(color: Colors.white)), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ), + ], + ), + ), + ] else + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: SizedBox( + width: double.infinity, height: 52, + child: OutlinedButton( + onPressed: _flip, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF6366F1)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + child: const Text('Cevabı Göster', style: TextStyle(color: Color(0xFF6366F1), fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCardFace(BuildContext context, {required bool isBack}) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: isBack + ? const LinearGradient(colors: [Color(0xFF10B981), Color(0xFF059669)], begin: Alignment.topLeft, end: Alignment.bottomRight) + : const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], begin: Alignment.topLeft, end: Alignment.bottomRight), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: (isBack ? const Color(0xFF10B981) : const Color(0xFF6366F1)).withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8)), + child: Text( + isBack ? 'ARKA YÜZ' : 'ÖN YÜZ', + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 1.2), + ), + ), + const SizedBox(height: 20), + Text( + isBack ? _card.back : _card.front, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, height: 1.5), + textAlign: TextAlign.center, + ), + if (isBack) ...[ + const SizedBox(height: 24), + const Text('Yukarı veya aşağı kaydır ↕', style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ], + ), + ), + ); + } + + Widget _buildResult(BuildContext context) { + final total = widget.set.cards.length; + final percent = total > 0 ? ((_knownCount / total) * 100).round() : 0; + final scheme = Theme.of(context).colorScheme; + + Color rc; String emoji; String txt; + if (percent >= 80) { rc = const Color(0xFF10B981); emoji = '🎉'; txt = 'Harika!'; } + else if (percent >= 60) { rc = const Color(0xFFF59E0B); emoji = '👍'; txt = 'İyi İş!'; } + else { rc = const Color(0xFFEF4444); emoji = '📚'; txt = 'Tekrar Çalış'; } + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(emoji, style: const TextStyle(fontSize: 72)), + const SizedBox(height: 16), + Text(txt, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: rc)), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 20, offset: const Offset(0, 8))]), + child: Column( + children: [ + Text('$_knownCount / $total', style: Theme.of(context).textTheme.displayMedium?.copyWith(fontWeight: FontWeight.bold, color: rc)), + const SizedBox(height: 8), + Text('$percent% bilindi', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: scheme.onSurfaceVariant)), + const SizedBox(height: 20), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator(value: total > 0 ? _knownCount / total : 0, minHeight: 10, backgroundColor: rc.withValues(alpha: 0.15), valueColor: AlwaysStoppedAnimation(rc)), + ), + ], + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: const Text('Geri Dön'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => setState(() { + _currentIndex = 0; _flipped = false; _knownCount = 0; _showResult = false; + _flipCtrl.reset(); + }), + style: FilledButton.styleFrom(backgroundColor: const Color(0xFF6366F1), padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: const Text('Tekrar', style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +// ─── Flashcard History Sheet ───────────────────────────────────────────────── + +void showFlashcardHistorySheet(BuildContext context, FlashcardSet set) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _FlashcardHistorySheet(set: set), + ); +} + +class _FlashcardHistorySheet extends StatelessWidget { + final FlashcardSet set; + const _FlashcardHistorySheet({required this.set}); + + Color get _diffColor { + switch (set.difficulty) { + case 'easy': return const Color(0xFF10B981); + case 'hard': return const Color(0xFFEF4444); + default: return const Color(0xFFF59E0B); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(top: 120), + decoration: BoxDecoration(color: scheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(28))), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container(width: 40, height: 4, decoration: BoxDecoration(color: scheme.outlineVariant, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.style_rounded, color: Color(0xFF6366F1)), + const SizedBox(width: 10), + Text('Flash Kart Detayı', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: _diffColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8)), + child: Text(set.difficultyLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _diffColor)), + ), + const SizedBox(width: 8), + Text('· ${set.cardCount} kart', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant)), + ], + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), + itemCount: set.cards.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (_, i) { + final card = set.cards[i]; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: scheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text('ÖN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: const Color(0xFF6366F1))), + ), + const SizedBox(width: 8), + Expanded(child: Text(card.front, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14))), + ], + ), + const Divider(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('ARKA', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFF10B981))), + ), + const SizedBox(width: 8), + Expanded(child: Text(card.back, style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant))), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/documents/presentation/quiz_screen.dart b/lib/features/documents/presentation/quiz_screen.dart new file mode 100644 index 0000000..669be3c --- /dev/null +++ b/lib/features/documents/presentation/quiz_screen.dart @@ -0,0 +1,666 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:learning_coach/shared/data/providers.dart'; +import 'package:learning_coach/shared/data/quiz_repository.dart'; +import 'package:learning_coach/shared/models/models.dart'; +import 'package:uuid/uuid.dart'; + +// ─── Quiz Settings Bottom Sheet ─────────────────────────────────────────────── + +Future showQuizSettingsSheet( + BuildContext context, + Document document, +) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _QuizSettingsSheet(document: document), + ); +} + +class _QuizSettingsSheet extends ConsumerStatefulWidget { + final Document document; + const _QuizSettingsSheet({required this.document}); + + @override + ConsumerState<_QuizSettingsSheet> createState() => _QuizSettingsSheetState(); +} + +class _QuizSettingsSheetState extends ConsumerState<_QuizSettingsSheet> { + int _questionCount = 10; + String _difficulty = 'medium'; + bool _loading = false; + String? _error; + final TextEditingController _promptController = TextEditingController(); + + @override + void dispose() { + _promptController.dispose(); + super.dispose(); + } + + static const _difficulties = [ + ('easy', 'Kolay', Icons.sentiment_satisfied_rounded, Color(0xFF10B981)), + ('medium', 'Orta', Icons.sentiment_neutral_rounded, Color(0xFFF59E0B)), + ('hard', 'Zor', Icons.sentiment_very_dissatisfied_rounded, Color(0xFFEF4444)), + ]; + + Future _startQuiz() async { + setState(() { _loading = true; _error = null; }); + + final dummySessionId = const Uuid().v4(); + final session = QuizSession( + id: dummySessionId, + documentId: widget.document.id, + documentTitle: widget.document.title, + difficulty: _difficulty, + questionCount: _questionCount, + questions: const [], + createdAt: DateTime.now(), + isGenerating: true, + ); + + final apiRepo = ref.read(apiDocumentRepositoryProvider); + final quizRepo = ref.read(quizRepositoryProvider.notifier); + + await quizRepo.addSession(session); + + if (!mounted) return; + final scaffoldMessenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + + apiRepo.generateQuiz( + documentId: widget.document.id, + count: _questionCount, + difficulty: _difficulty, + instructions: _promptController.text.trim().isEmpty ? null : _promptController.text.trim(), + ).then((questions) { + if (questions.isNotEmpty) { + final updated = session.copyWith( + questions: questions, + questionCount: questions.length, + isGenerating: false, + ); + quizRepo.updateSession(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('✅ Test başarıyla oluşturuldu ve hazır!'), backgroundColor: Colors.green, duration: Duration(seconds: 4)), + ); + } else { + final updated = session.copyWith(isGenerating: false, questionCount: 0); + quizRepo.updateSession(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('⚠️ Yeterli soru üretilemedi.'), backgroundColor: Colors.orange), + ); + } + }).catchError((e) { + debugPrint('Quiz generation error: $e'); + final updated = session.copyWith(isGenerating: false, questionCount: 0); + quizRepo.updateSession(updated); + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('❌ Test oluşturulurken bir hata oluştu.'), backgroundColor: Colors.red), + ); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(top: 80), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.quiz_rounded, color: Colors.white, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Test Hazırla', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + Text(widget.document.title, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + const SizedBox(height: 28), + Text('Soru Sayısı', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Row( + children: [('Daha az', 5), ('Standart', 10), ('Daha fazla', 20)].map((pair) { + final (label, n) = pair; + final sel = _questionCount == n; + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _questionCount = n), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 52, + decoration: BoxDecoration( + gradient: sel ? const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]) : null, + color: sel ? null : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: sel ? Colors.transparent : scheme.outlineVariant), + ), + child: Center( + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: sel ? Colors.white : scheme.onSurface, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + Text('Zorluk', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Column( + children: _difficulties.map((d) { + final (key, label, icon, color) = d; + final sel = _difficulty == key; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GestureDetector( + onTap: () => setState(() => _difficulty = key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: sel ? color.withValues(alpha: 0.1) : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: sel ? color : scheme.outlineVariant, width: sel ? 2 : 1), + ), + child: Row( + children: [ + Icon(icon, color: sel ? color : scheme.onSurfaceVariant), + const SizedBox(width: 12), + Text(label, style: TextStyle(fontWeight: sel ? FontWeight.bold : FontWeight.normal, color: sel ? color : scheme.onSurface)), + ], + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // AI Prompt (Opsiyonel) + Text('Özel Talimat (İsteğe Bağlı)', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + TextField( + controller: _promptController, + maxLines: 3, + minLines: 1, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Örn: Sadece 3. bölümdeki kavramlara odaklan, zorlayıcı mantık soruları sor vs.', + hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 13), + filled: true, + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: const Color(0xFF6366F1), width: 2), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12)), + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, height: 56, + child: FilledButton( + onPressed: _loading ? null : _startQuiz, + style: FilledButton.styleFrom(backgroundColor: const Color(0xFF6366F1), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + child: _loading + ? const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), + SizedBox(width: 12), + Text('Sorular Hazırlanıyor...', style: TextStyle(color: Colors.white)), + ]) + : const Text('Testi Başlat', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Quiz Screen ────────────────────────────────────────────────────────────── + +class QuizScreen extends ConsumerStatefulWidget { + final QuizSession session; + + const QuizScreen({super.key, required this.session}); + + @override + ConsumerState createState() => _QuizScreenState(); +} + +class _QuizScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + int _currentIndex = 0; + String? _selectedOption; + bool _answered = false; + int _correctCount = 0; + bool _showResult = false; + + late final AnimationController _animCtrl; + late final Animation _fadeAnim; + + final _letters = ['A', 'B', 'C', 'D']; + + QuizQuestion get _q => widget.session.questions[_currentIndex]; + + @override + void initState() { + super.initState(); + _animCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeInOut); + _animCtrl.forward(); + } + + @override + void dispose() { + _animCtrl.dispose(); + super.dispose(); + } + + void _selectOption(String letter) { + if (_answered) return; + setState(() { + _selectedOption = letter; + _answered = true; + if (letter == _q.answer) _correctCount++; + }); + } + + Future _next() async { + if (_currentIndex < widget.session.questions.length - 1) { + await _animCtrl.reverse(); + setState(() { _currentIndex++; _selectedOption = null; _answered = false; }); + await _animCtrl.forward(); + } else { + // Sonucu kaydet + await ref.read(quizRepositoryProvider.notifier).recordAttempt( + sessionId: widget.session.id, + documentId: widget.session.documentId, + correctCount: _correctCount, + total: widget.session.questions.length, + ); + setState(() => _showResult = true); + } + } + + Color _optColor(String letter) { + if (!_answered) return Colors.transparent; + if (letter == _q.answer) return const Color(0xFF10B981); + if (letter == _selectedOption) return const Color(0xFFEF4444); + return Colors.transparent; + } + + Color _optBorder(String letter) { + if (!_answered) return letter == _selectedOption ? const Color(0xFF6366F1) : Colors.grey.withValues(alpha: 0.3); + return _optColor(letter); + } + + @override + Widget build(BuildContext context) { + if (_showResult) return _buildResult(context); + return _buildQuiz(context); + } + + Widget _buildQuiz(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final total = widget.session.questions.length; + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: AppBar( + title: Text('${_currentIndex + 1} / $total', style: const TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => Navigator.of(context).pop()), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LinearProgressIndicator( + value: _currentIndex / total, + minHeight: 6, + borderRadius: BorderRadius.circular(4), + backgroundColor: scheme.surfaceContainerHighest, + valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ), + Expanded( + child: FadeTransition( + opacity: _fadeAnim, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)], begin: Alignment.topLeft, end: Alignment.bottomRight), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF6366F1).withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6))], + ), + child: Text(_q.question, style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600, height: 1.5)), + ), + const SizedBox(height: 24), + ...List.generate(_q.options.length, (i) { + final letter = _letters[i]; + final text = _q.options[i]; + final bg = _optColor(letter); + final border = _optBorder(letter); + final isCorrect = _answered && letter == _q.answer; + final isWrong = _answered && letter == _selectedOption && !isCorrect; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GestureDetector( + onTap: () => _selectOption(letter), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: bg.withValues(alpha: _answered ? 0.12 : 0), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border, width: _answered && (isCorrect || isWrong) ? 2 : 1.2), + ), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration(color: bg.withValues(alpha: _answered ? 0.2 : 0.08), shape: BoxShape.circle, border: Border.all(color: border, width: 1.2)), + child: Center( + child: _answered && isCorrect + ? Icon(Icons.check_rounded, size: 18, color: bg) + : _answered && isWrong + ? Icon(Icons.close_rounded, size: 18, color: bg) + : Text(letter, style: TextStyle(fontWeight: FontWeight.bold, color: border)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text(text, style: TextStyle(fontSize: 15, fontWeight: isCorrect ? FontWeight.bold : FontWeight.normal, color: isCorrect ? const Color(0xFF10B981) : isWrong ? const Color(0xFFEF4444) : scheme.onSurface)), + ), + ], + ), + ), + ), + ); + }), + if (_answered) ...[ + const SizedBox(height: 4), + AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 400), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.2)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.lightbulb_rounded, color: Color(0xFF6366F1), size: 18), + const SizedBox(width: 10), + Expanded(child: Text(_q.explanation, style: TextStyle(color: scheme.onSurface, height: 1.5))), + ], + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, height: 52, + child: FilledButton( + onPressed: _next, + style: FilledButton.styleFrom(backgroundColor: const Color(0xFF6366F1), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: Text( + _currentIndex < widget.session.questions.length - 1 ? 'Sonraki Soru →' : 'Sonuçları Gör', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildResult(BuildContext context) { + final total = widget.session.questions.length; + final percent = ((_correctCount / total) * 100).round(); + final scheme = Theme.of(context).colorScheme; + + Color rc; String emoji; String txt; + if (percent >= 80) { rc = const Color(0xFF10B981); emoji = '🎉'; txt = 'Harika!'; } + else if (percent >= 60) { rc = const Color(0xFFF59E0B); emoji = '👍'; txt = 'İyi İş!'; } + else { rc = const Color(0xFFEF4444); emoji = '📚'; txt = 'Tekrar Çalış'; } + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(emoji, style: const TextStyle(fontSize: 72)), + const SizedBox(height: 16), + Text(txt, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: rc)), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 20, offset: const Offset(0, 8))]), + child: Column( + children: [ + Text('$_correctCount / $total', style: Theme.of(context).textTheme.displayMedium?.copyWith(fontWeight: FontWeight.bold, color: rc)), + const SizedBox(height: 8), + Text('$percent% Başarı', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: scheme.onSurfaceVariant)), + const SizedBox(height: 20), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator(value: _correctCount / total, minHeight: 10, backgroundColor: rc.withValues(alpha: 0.15), valueColor: AlwaysStoppedAnimation(rc)), + ), + ], + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: const Text('Geri Dön'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => setState(() { _currentIndex = 0; _selectedOption = null; _answered = false; _correctCount = 0; _showResult = false; _animCtrl.forward(from: 0); }), + style: FilledButton.styleFrom(backgroundColor: const Color(0xFF6366F1), padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: const Text('Tekrar', style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +// ─── Quiz History Bottom Sheet ──────────────────────────────────────────────── + +void showQuizHistorySheet(BuildContext context, QuizSession session) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _QuizHistorySheet(session: session), + ); +} + +class _QuizHistorySheet extends StatelessWidget { + final QuizSession session; + const _QuizHistorySheet({required this.session}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final attempts = session.attempts.reversed.toList(); + + return Container( + margin: const EdgeInsets.only(top: 120), + decoration: BoxDecoration(color: scheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(28))), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container(width: 40, height: 4, decoration: BoxDecoration(color: scheme.outlineVariant, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.history_rounded, color: Color(0xFF6366F1)), + const SizedBox(width: 10), + Text('Deneme Geçmişi', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), + child: Text('${session.difficultyLabel} · ${session.questionCount} Soru', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant)), + ), + if (attempts.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Text('Henüz deneme yok', style: TextStyle(color: scheme.onSurfaceVariant)), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), + itemCount: attempts.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (_, i) { + final a = attempts[i]; + final rc = a.percent >= 80 ? const Color(0xFF10B981) : a.percent >= 60 ? const Color(0xFFF59E0B) : const Color(0xFFEF4444); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: rc.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(14), border: Border.all(color: rc.withValues(alpha: 0.25))), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: rc.withValues(alpha: 0.15), shape: BoxShape.circle), + child: Center(child: Text('${attempts.length - i}', style: TextStyle(fontWeight: FontWeight.bold, color: rc, fontSize: 16))), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${a.correctCount} doğru / ${a.total} soru · %${a.percent}', style: TextStyle(fontWeight: FontWeight.bold, color: rc)), + const SizedBox(height: 4), + Text('${a.date.day}.${a.date.month}.${a.date.year} ${a.date.hour}:${a.date.minute.toString().padLeft(2, '0')}', style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)), + ], + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 48, + child: LinearProgressIndicator(value: a.score, minHeight: 8, backgroundColor: rc.withValues(alpha: 0.2), valueColor: AlwaysStoppedAnimation(rc)), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/study/presentation/session_running_screen.dart b/lib/features/study/presentation/session_running_screen.dart index 7aeb01b..3381a32 100644 --- a/lib/features/study/presentation/session_running_screen.dart +++ b/lib/features/study/presentation/session_running_screen.dart @@ -184,6 +184,7 @@ class _SessionRunningScreenState extends ConsumerState mainAxisAlignment: MainAxisAlignment.center, children: [ FloatingActionButton.large( + heroTag: 'fab_session_pause', onPressed: () { setState(() { _isPaused = !_isPaused; diff --git a/lib/shared/data/api_document_repository.dart b/lib/shared/data/api_document_repository.dart index 56854e7..b24e674 100644 --- a/lib/shared/data/api_document_repository.dart +++ b/lib/shared/data/api_document_repository.dart @@ -159,4 +159,37 @@ class ApiDocumentRepository { rethrow; } } + + Future> generateQuiz({ + required String documentId, + required int count, + required String difficulty, // 'easy' | 'medium' | 'hard' + String? instructions, + }) async { + final response = await _dio.post>( + '/documents/$documentId/quiz', + data: {'count': count, 'difficulty': difficulty, 'instructions': instructions}, + ); + final data = response.data!['questions'] as List; + return data + .map((q) => QuizQuestion.fromJson(q as Map)) + .toList(); + } + + Future> generateFlashcards({ + required String documentId, + required int count, + required String difficulty, // 'easy' | 'medium' | 'hard' + String? instructions, + }) async { + final response = await _dio.post>( + '/documents/$documentId/flashcards', + data: {'count': count, 'difficulty': difficulty, 'instructions': instructions}, + ); + final data = response.data!['cards'] as List; + return data + .map((c) => FlashCard.fromJson(c as Map)) + .toList(); + } } + diff --git a/lib/shared/data/flashcard_repository.dart b/lib/shared/data/flashcard_repository.dart new file mode 100644 index 0000000..e29472d --- /dev/null +++ b/lib/shared/data/flashcard_repository.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:learning_coach/shared/models/models.dart'; + +// ─── FlashcardSet ───────────────────────────────────────────────────────────── + +class FlashcardSet { + final String id; + final String documentId; + final String documentTitle; + final String difficulty; + final int cardCount; + final List cards; + final DateTime createdAt; + final bool isGenerating; + + FlashcardSet({ + required this.id, + required this.documentId, + required this.documentTitle, + required this.difficulty, + required this.cardCount, + required this.cards, + required this.createdAt, + this.isGenerating = false, + }); + + FlashcardSet copyWith({ + String? id, + String? documentId, + String? documentTitle, + String? difficulty, + int? cardCount, + List? cards, + DateTime? createdAt, + bool? isGenerating, + }) { + return FlashcardSet( + id: id ?? this.id, + documentId: documentId ?? this.documentId, + documentTitle: documentTitle ?? this.documentTitle, + difficulty: difficulty ?? this.difficulty, + cardCount: cardCount ?? this.cardCount, + cards: cards ?? this.cards, + createdAt: createdAt ?? this.createdAt, + isGenerating: isGenerating ?? this.isGenerating, + ); + } + + String get difficultyLabel { + switch (difficulty) { + case 'easy': return 'Kolay'; + case 'hard': return 'Zor'; + default: return 'Orta'; + } + } + + Map toJson() => { + 'id': id, + 'documentId': documentId, + 'documentTitle': documentTitle, + 'difficulty': difficulty, + 'cardCount': cardCount, + 'cards': cards.map((c) => c.toJson()).toList(), + 'createdAt': createdAt.toIso8601String(), + 'isGenerating': isGenerating, + }; + + factory FlashcardSet.fromJson(Map j) => FlashcardSet( + id: j['id'] as String, + documentId: j['documentId'] as String, + documentTitle: j['documentTitle'] as String? ?? '', + difficulty: j['difficulty'] as String? ?? 'medium', + cardCount: j['cardCount'] as int? ?? 0, + cards: (j['cards'] as List? ?? []) + .map((c) => FlashCard.fromJson(c as Map)) + .toList(), + createdAt: DateTime.parse(j['createdAt'] as String), + isGenerating: j['isGenerating'] as bool? ?? false, + ); +} + +// ─── FlashcardRepository ───────────────────────────────────────────────────── + +class FlashcardRepository extends Notifier { + static const _kPrefsKey = 'flashcard_sets_v1'; + + final Map> _setsByDoc = {}; + + @override + int build() { + _load(); + return 0; + } + + List setsForDoc(String documentId) => + List.unmodifiable(_setsByDoc[documentId] ?? []); + + Future addSet(FlashcardSet set) async { + final list = _setsByDoc.putIfAbsent(set.documentId, () => []); + list.insert(0, set); + await _save(); + state = state + 1; + } + + Future updateSet(FlashcardSet set) async { + final list = _setsByDoc[set.documentId]; + if (list == null) return; + final idx = list.indexWhere((s) => s.id == set.id); + if (idx >= 0) { + list[idx] = set; + await _save(); + state = state + 1; + } + } + + Future _load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kPrefsKey); + if (raw == null) return; + final List all = jsonDecode(raw) as List; + for (final item in all) { + final s = FlashcardSet.fromJson(item as Map); + _setsByDoc.putIfAbsent(s.documentId, () => []).add(s); + } + state = state + 1; + } catch (e) { + debugPrint('FlashcardRepository load error: $e'); + } + } + + Future _save() async { + try { + final prefs = await SharedPreferences.getInstance(); + final all = _setsByDoc.values + .expand((list) => list) + .map((s) => s.toJson()) + .toList(); + await prefs.setString(_kPrefsKey, jsonEncode(all)); + } catch (e) { + debugPrint('FlashcardRepository save error: $e'); + } + } +} + +// Global provider +final flashcardRepositoryProvider = NotifierProvider( + FlashcardRepository.new, +); diff --git a/lib/shared/data/providers.dart b/lib/shared/data/providers.dart index 2ee2eab..967d930 100644 --- a/lib/shared/data/providers.dart +++ b/lib/shared/data/providers.dart @@ -5,6 +5,7 @@ import 'package:learning_coach/shared/data/api_goal_repository.dart'; import 'package:learning_coach/shared/data/api_stats_repository.dart'; import 'package:learning_coach/shared/data/api_study_session_repository.dart'; import 'package:learning_coach/shared/data/mock_data_repository.dart'; + import 'package:learning_coach/shared/models/models.dart'; import 'package:learning_coach/shared/providers/auth_provider.dart'; import 'package:learning_coach/shared/services/gamification_service.dart'; diff --git a/lib/shared/data/quiz_repository.dart b/lib/shared/data/quiz_repository.dart new file mode 100644 index 0000000..d8e337c --- /dev/null +++ b/lib/shared/data/quiz_repository.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:learning_coach/shared/models/models.dart'; + +// ─── QuizAttempt ───────────────────────────────────────────────────────────── + +class QuizAttempt { + final DateTime date; + final int correctCount; + final int total; + + const QuizAttempt({ + required this.date, + required this.correctCount, + required this.total, + }); + + double get score => total > 0 ? correctCount / total : 0; + int get percent => (score * 100).round(); + + Map toJson() => { + 'date': date.toIso8601String(), + 'correctCount': correctCount, + 'total': total, + }; + + factory QuizAttempt.fromJson(Map j) => QuizAttempt( + date: DateTime.parse(j['date'] as String), + correctCount: j['correctCount'] as int, + total: j['total'] as int, + ); +} + +// ─── QuizSession ────────────────────────────────────────────────────────────── + +class QuizSession { + final String id; + final String documentId; + final String documentTitle; + final String difficulty; + final int questionCount; + final List questions; + final DateTime createdAt; + final List attempts; + final bool isGenerating; + + QuizSession({ + required this.id, + required this.documentId, + required this.documentTitle, + required this.difficulty, + required this.questionCount, + required this.questions, + required this.createdAt, + this.attempts = const [], + this.isGenerating = false, + }); + + QuizSession copyWith({ + String? id, + String? documentId, + String? documentTitle, + String? difficulty, + int? questionCount, + List? questions, + DateTime? createdAt, + List? attempts, + bool? isGenerating, + }) { + return QuizSession( + id: id ?? this.id, + documentId: documentId ?? this.documentId, + documentTitle: documentTitle ?? this.documentTitle, + difficulty: difficulty ?? this.difficulty, + questionCount: questionCount ?? this.questionCount, + questions: questions ?? this.questions, + createdAt: createdAt ?? this.createdAt, + attempts: attempts ?? this.attempts, + isGenerating: isGenerating ?? this.isGenerating, + ); + } + + String get difficultyLabel { + switch (difficulty) { + case 'easy': return 'Kolay'; + case 'hard': return 'Zor'; + default: return 'Orta'; + } + } + + QuizSession withAttempt(QuizAttempt attempt) => copyWith( + attempts: [...attempts, attempt], + ); + + Map toJson() => { + 'id': id, + 'documentId': documentId, + 'documentTitle': documentTitle, + 'difficulty': difficulty, + 'questionCount': questionCount, + 'questions': questions.map((q) => { + 'question': q.question, + 'options': q.options, + 'answer': q.answer, + 'explanation': q.explanation, + }).toList(), + 'createdAt': createdAt.toIso8601String(), + 'attempts': attempts.map((a) => a.toJson()).toList(), + 'isGenerating': isGenerating, + }; + + factory QuizSession.fromJson(Map j) => QuizSession( + id: j['id'] as String, + documentId: j['documentId'] as String, + documentTitle: j['documentTitle'] as String? ?? '', + difficulty: j['difficulty'] as String? ?? 'medium', + questionCount: j['questionCount'] as int? ?? 0, + questions: (j['questions'] as List? ?? []) + .map((q) => QuizQuestion.fromJson(q as Map)) + .toList(), + createdAt: DateTime.parse(j['createdAt'] as String), + attempts: (j['attempts'] as List? ?? []) + .map((a) => QuizAttempt.fromJson(a as Map)) + .toList(), + isGenerating: j['isGenerating'] as bool? ?? false, + ); +} + +// ─── Riverpod Notifier ──────────────────────────────────────────────────────── + +class QuizRepository extends Notifier { + static const _kPrefsKey = 'quiz_sessions_v1'; + + final Map> _sessionsByDoc = {}; + + @override + int build() { + _load(); + return 0; + } + + List sessionsForDoc(String documentId) => + List.unmodifiable(_sessionsByDoc[documentId] ?? []); + + Future addSession(QuizSession session) async { + final list = _sessionsByDoc.putIfAbsent(session.documentId, () => []); + list.insert(0, session); + await _save(); + state = state + 1; + } + + Future updateSession(QuizSession session) async { + final list = _sessionsByDoc[session.documentId]; + if (list == null) return; + final idx = list.indexWhere((s) => s.id == session.id); + if (idx >= 0) { + list[idx] = session; + await _save(); + state = state + 1; + } + } + + Future recordAttempt({ + required String sessionId, + required String documentId, + required int correctCount, + required int total, + }) async { + final list = _sessionsByDoc[documentId]; + if (list == null) return; + final idx = list.indexWhere((s) => s.id == sessionId); + if (idx < 0) return; + final attempt = QuizAttempt( + date: DateTime.now(), + correctCount: correctCount, + total: total, + ); + list[idx] = list[idx].withAttempt(attempt); + await _save(); + state = state + 1; + } + + Future _load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kPrefsKey); + if (raw == null) return; + final List all = jsonDecode(raw) as List; + for (final item in all) { + final s = QuizSession.fromJson(item as Map); + _sessionsByDoc.putIfAbsent(s.documentId, () => []).add(s); + } + state = state + 1; + } catch (e) { + debugPrint('QuizRepository load error: $e'); + } + } + + Future _save() async { + try { + final prefs = await SharedPreferences.getInstance(); + final all = _sessionsByDoc.values + .expand((list) => list) + .map((s) => s.toJson()) + .toList(); + await prefs.setString(_kPrefsKey, jsonEncode(all)); + } catch (e) { + debugPrint('QuizRepository save error: $e'); + } + } +} + +// Global provider (Riverpod 3.x NotifierProvider) +final quizRepositoryProvider = NotifierProvider( + QuizRepository.new, +); diff --git a/lib/shared/models/models.dart b/lib/shared/models/models.dart index eff82a6..b83ee35 100644 --- a/lib/shared/models/models.dart +++ b/lib/shared/models/models.dart @@ -131,6 +131,7 @@ class Document extends Equatable { final DocStatus status; final DateTime uploadedAt; final double processingProgress; + final int totalChunks; Document({ String? id, @@ -139,6 +140,7 @@ class Document extends Equatable { this.status = DocStatus.processing, DateTime? uploadedAt, this.processingProgress = 0, + this.totalChunks = 0, }) : id = id ?? uuid.v4(), uploadedAt = uploadedAt ?? DateTime.now(); @@ -156,6 +158,9 @@ class Document extends Equatable { uploadedAt = DateTime.tryParse(uploadedAtRaw); } + final totalChunksRaw = json['total_chunks']; + final totalChunks = totalChunksRaw is num ? totalChunksRaw.toInt() : 0; + return Document( id: json['id'] as String?, title: (json['title'] as String?) ?? 'Untitled', @@ -163,6 +168,7 @@ class Document extends Equatable { status: status, uploadedAt: uploadedAt, processingProgress: progress, + totalChunks: totalChunks, ); } @@ -174,9 +180,65 @@ class Document extends Equatable { status, uploadedAt, processingProgress, + totalChunks, ]; } +// --- Quiz --- + +class QuizQuestion extends Equatable { + final String question; + final List options; // [A, B, C, D] + final String answer; // 'A' | 'B' | 'C' | 'D' + final String explanation; + + const QuizQuestion({ + required this.question, + required this.options, + required this.answer, + required this.explanation, + }); + + factory QuizQuestion.fromJson(Map json) { + return QuizQuestion( + question: json['question'] as String? ?? '', + options: List.from(json['options'] as List? ?? []), + answer: json['answer'] as String? ?? 'A', + explanation: json['explanation'] as String? ?? '', + ); + } + + @override + List get props => [question, options, answer, explanation]; +} + +// --- Flashcard --- + +class FlashCard extends Equatable { + final String front; // ön yüz: kavram / soru + final String back; // arka yüz: tanım / cevap + + const FlashCard({ + required this.front, + required this.back, + }); + + factory FlashCard.fromJson(Map json) { + return FlashCard( + front: json['front'] as String? ?? '', + back: json['back'] as String? ?? '', + ); + } + + Map toJson() => { + 'front': front, + 'back': back, + }; + + @override + List get props => [front, back]; +} + class CoachMessage extends Equatable { final String id; final String text; diff --git a/lib/shared/services/api_service.dart b/lib/shared/services/api_service.dart index 1bb0d99..3584641 100644 --- a/lib/shared/services/api_service.dart +++ b/lib/shared/services/api_service.dart @@ -42,8 +42,8 @@ class ApiService { BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 60), - sendTimeout: const Duration(seconds: 60), + receiveTimeout: const Duration(seconds: 300), + sendTimeout: const Duration(seconds: 300), ), ); print('🔌 ApiService Initialized'); diff --git a/llm_backend/app/api/rag.py b/llm_backend/app/api/rag.py index c9b14ad..95c27ad 100644 --- a/llm_backend/app/api/rag.py +++ b/llm_backend/app/api/rag.py @@ -5,8 +5,17 @@ EmbeddingResponse, RagAnswerRequest, RagAnswerResponse, + QuizGenerateRequest, + QuizGenerateResponse, + FlashcardGenerateRequest, + FlashcardGenerateResponse, +) +from app.services.llm_service import ( + get_embeddings, + ask_document, + generate_quiz, + generate_flashcards, ) -from app.services.llm_service import get_embeddings, ask_document router = APIRouter(prefix="/rag", tags=["RAG"]) @@ -31,3 +40,62 @@ def answer(req: RagAnswerRequest): except Exception as e: logger.exception("RAG answer error") raise HTTPException(status_code=500, detail=f"RAG answer error: {e}") + + +@router.post("/quiz") +def quiz_endpoint(req: QuizGenerateRequest): + try: + logger.info(f"Quiz üretimi istendi (count: {req.count}, diff: {req.difficulty})") + result_json = generate_quiz(req.context, req.count, req.difficulty, req.instructions) + import json + import re + + match = re.search(r'(\{.*\}|\[.*\])', result_json, re.DOTALL) + if match: + result_json = match.group(0) + + parsed = json.loads(result_json) + + questions = parsed.get("questions", []) if isinstance(parsed, dict) else parsed if isinstance(parsed, list) else [] + if isinstance(parsed, dict) and "quiz" in parsed: + questions = parsed["quiz"] + + for q in questions: + ans = str(q.get("answer", "")).strip() + options = q.get("options", []) + valid_letters = ["A", "B", "C", "D"] + + if ans not in valid_letters and options: + for i, opt in enumerate(options): + if i < 4 and (ans.lower() == str(opt).lower() or str(opt).lower().startswith(ans.lower()) or ans.lower().startswith(str(opt).lower())): + q["answer"] = valid_letters[i] + break + + return {"questions": questions} + except Exception as e: + logger.exception("Quiz generation error") + raise HTTPException(status_code=500, detail=f"Quiz error: {e}") + + +@router.post("/flashcards") +def flashcards_endpoint(req: FlashcardGenerateRequest): + try: + logger.info(f"Flash kart üretimi istendi (count: {req.count}, diff: {req.difficulty})") + result_json = generate_flashcards(req.context, req.count, req.difficulty, req.instructions) + import json + import re + + match = re.search(r'(\{.*\}|\[.*\])', result_json, re.DOTALL) + if match: + result_json = match.group(0) + + parsed = json.loads(result_json) + + cards = parsed.get("cards", []) if isinstance(parsed, dict) else parsed if isinstance(parsed, list) else [] + if isinstance(parsed, dict) and "flashcards" in parsed: + cards = parsed["flashcards"] + + return {"cards": cards} + except Exception as e: + logger.exception("Flashcard generation error") + raise HTTPException(status_code=500, detail=f"Flashcard error: {e}") diff --git a/llm_backend/app/core/config.py b/llm_backend/app/core/config.py index 2c237c5..cf9ff61 100644 --- a/llm_backend/app/core/config.py +++ b/llm_backend/app/core/config.py @@ -7,7 +7,7 @@ MODEL_NAME = os.getenv("MODEL_NAME") EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "nomic-embed-text") OLLAMA_EMBEDDINGS_URL = os.getenv("OLLAMA_EMBEDDINGS_URL") -REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", 60)) +REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", 300)) if not OLLAMA_EMBEDDINGS_URL and OLLAMA_URL: if OLLAMA_URL.endswith("/api/chat"): diff --git a/llm_backend/app/core/prompts.py b/llm_backend/app/core/prompts.py index 6d8ea30..b2bdfcf 100644 --- a/llm_backend/app/core/prompts.py +++ b/llm_backend/app/core/prompts.py @@ -10,10 +10,56 @@ """ RAG_SYSTEM_PROMPT = """ -Sen sadece verilen doküman bağlamına göre cevap ver. +Sen bir doküman asistanısın. Görevin kullanıcıya sağlanan doküman bağlamı hakkında yardımcı olmaktır. Kurallar: -- Cevabı yalnızca bağlamdaki bilgilere dayanarak üret. -- Bağlamda bilgi yoksa açıkça "Bu dokümanda böyle bir bilgi bulamadım." de. -- Kısa ve net cevap ver. +- Sorulan soruları, özet taleplerini veya test isteği gibi genel istekleri ağırlıklı olarak sağlanan bağlama dayanarak yanıtla. +- Eğer kullanıcının sorusu bağlamla ilgiliyse ancak bağlamda tam cevap yoksa, elindeki bilgilerle mantıklı çıkarımlar yapabilirsin veya genel bilgi birikimini kullanabilirsin ancak ana kaynağın her zaman doküman olmalıdır. +- "Dokümanın özeti nedir?", "Bana buradan soru sor" gibi genel talepleri mutlaka yerine getir. +- Küfür, argo, nefret söylemi veya uygunsuz içerikli taleplere kesinlikle yanıt verme ve bu tarz durumlarda nazikçe reddet. +- Cevapların anlaşılır, net ve kullanıcıyı yönlendirici olsun. +- ÇOK ÖNEMLİ: Eğer kullanıcının sadece selam vermesi gibi geyik yaptığı, veya dokümanda OLMAYAN tamamen bağımsız bir soru sorduğu durumlar yaşanırsa ve cevabını verirken bağlamı (dokümanı) HİÇ KULLANMADIYSAN, cevabının en sonuna tam olarak şu etiketi ekle: [BAĞLAM_KULLANILMADI] +""" + +QUIZ_SYSTEM_PROMPT = """ +Sen uzman bir akademisyen ve sınav hazırlayıcısın. Sana verilen doküman bağlamını (context) kullanarak, istenilen sayıda ve zorlukta çoktan seçmeli bir test (quiz) hazırlayacaksın. + +ÇOK ÖNEMLİ KURALLAR: +1. SADECE VE SADECE GEÇERLİ BİR JSON OLUŞTURACAKSIN. +2. JSON dışında hiçbir açıklama, giriş, selamlama veya markdown tag'i (```json vb.) KULLANMAYACAKSIN. +3. Çıktı formatı tam olarak aşağıdaki gibi olmalıdır: +{ + "questions": [ + { + "question": "Soru metni", + "options": ["A şıkkı", "B şıkkı", "C şıkkı", "D şıkkı"], + "answer": "A", + "explanation": "Doğru cevabın nedeni" + } + ] +} +4. answer alanı sadece "A", "B", "C" veya "D" olmalıdır. +5. options dizisi tam olarak 4 elemanlı olmalıdır. +6. Seçenekler metinlerinin başına "A)", "B)" gibi harfler KOYMA. +7. Tüm ürettiğin bilgiler kesinlikle sana sağlanan bağlama (context) dayanmalıdır. +8. Verilen metindeki sayısal gerçekleri ve önemli mantıksal bağlantıları kullanarak zorluk seviyesine (Kolay/Orta/Zor) uygun çeldiriciler oluştur. +""" + +FLASHCARD_SYSTEM_PROMPT = """ +Sen uzman bir eğitimci ve öğrenme bilimcisinin. Sana verilen doküman bağlamını (context) kullanarak, istenilen sayıda ve zorlukta çalışma kartları (flashcards) hazırlayacaksın. + +ÇOK ÖNEMLİ KURALLAR: +1. SADECE VE SADECE GEÇERLİ BİR JSON OLUŞTURACAKSIN. +2. JSON dışında hiçbir açıklama, giriş veya markdown tag'i (```json vb.) KULLANMAYACAKSIN. +3. Çıktı formatı tam olarak aşağıdaki gibi olmalıdır: +{ + "cards": [ + { + "front": "Kısa ve net bir kavram, terim veya soru", + "back": "Kavramın tanımı veya sorunun detaylı cevabı" + } + ] +} +4. Kartlar verilen zorluk seviyesine (Kolay/Orta/Zor) uygun olmalıdır. Zor seviye için temel kavramlardan ziyade, sentez, sonuç veya spesifik alt kavramları sor. +5. Tüm ürettiğin bilgiler kesinlikle sana sağlanan bağlama (context) dayanmalıdır. """ diff --git a/llm_backend/app/models/chat_models.py b/llm_backend/app/models/chat_models.py index e75bd6d..6d2d96e 100644 --- a/llm_backend/app/models/chat_models.py +++ b/llm_backend/app/models/chat_models.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, Field +from typing import Literal class ChatRequest(BaseModel): @@ -26,3 +27,41 @@ class RagAnswerRequest(BaseModel): class RagAnswerResponse(BaseModel): answer: str + + +# ── Quiz / Test Sorusu Üretimi ───────────────────────────────────────────────── + +class QuizGenerateRequest(BaseModel): + context: str = Field(..., min_length=1, description="Doküman bağlamı (chunk metinleri)") + count: int = Field(default=10, ge=1, le=25, description="Üretilecek soru sayısı") + difficulty: Literal["easy", "medium", "hard"] = Field(default="medium") + instructions: str | None = Field(default=None, description="Opsiyonel özel talimatlar") + + +class QuizQuestion(BaseModel): + question: str + options: list[str] # [A, B, C, D] + answer: str # "A" | "B" | "C" | "D" + explanation: str + + +class QuizGenerateResponse(BaseModel): + questions: list[QuizQuestion] + + +# ── Flash Kart Üretimi ───────────────────────────────────────────────────────── + +class FlashcardGenerateRequest(BaseModel): + context: str = Field(..., min_length=1, description="Doküman bağlamı (chunk metinleri)") + count: int = Field(default=15, ge=1, le=25, description="Üretilecek kart sayısı") + difficulty: Literal["easy", "medium", "hard"] = Field(default="medium") + instructions: str | None = Field(default=None, description="Opsiyonel özel talimatlar") + + +class Flashcard(BaseModel): + front: str # ön yüz: kavram / soru + back: str # arka yüz: tanım / cevap + + +class FlashcardGenerateResponse(BaseModel): + cards: list[Flashcard] diff --git a/llm_backend/app/services/llm_service.py b/llm_backend/app/services/llm_service.py index 6fbaefb..5fbef24 100644 --- a/llm_backend/app/services/llm_service.py +++ b/llm_backend/app/services/llm_service.py @@ -7,7 +7,12 @@ OLLAMA_EMBEDDINGS_URL, REQUEST_TIMEOUT, ) -from app.core.prompts import SYSTEM_PROMPT, RAG_SYSTEM_PROMPT +from app.core.prompts import ( + SYSTEM_PROMPT, + RAG_SYSTEM_PROMPT, + QUIZ_SYSTEM_PROMPT, + FLASHCARD_SYSTEM_PROMPT, +) from app.core.logger import logger @@ -109,3 +114,53 @@ def ask_document(question: str, context: str, history: list[dict] = []) -> str: response.raise_for_status() data = response.json() return data["message"]["content"] + + +def generate_quiz(context: str, count: int, difficulty: str, instructions: str | None = None) -> str: + messages = [{"role": "system", "content": QUIZ_SYSTEM_PROMPT}] + + req_text = f"Bağlam:\n{context}\n\nİstek: Lütfen bu bağlama göre {count} adet {difficulty} (zorluk) seviyede soru içeren bir seçenekli test hazırla." + if instructions: + req_text += f"\n\nÖzel Talimatlar:\n{instructions}" + + messages.append({ + "role": "user", + "content": req_text, + }) + + payload = { + "model": MODEL_NAME, + "messages": messages, + "stream": False, + "format": "json", + } + + response = requests.post(OLLAMA_URL, json=payload, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + data = response.json() + return data["message"]["content"] + + +def generate_flashcards(context: str, count: int, difficulty: str, instructions: str | None = None) -> str: + messages = [{"role": "system", "content": FLASHCARD_SYSTEM_PROMPT}] + + req_text = f"Bağlam:\n{context}\n\nİstek: Lütfen bu bağlama göre {count} adet {difficulty} (zorluk) seviyede flash kart hazırla." + if instructions: + req_text += f"\n\nÖzel Talimatlar:\n{instructions}" + + messages.append({ + "role": "user", + "content": req_text, + }) + + payload = { + "model": MODEL_NAME, + "messages": messages, + "stream": False, + "format": "json", + } + + response = requests.post(OLLAMA_URL, json=payload, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + data = response.json() + return data["message"]["content"] diff --git a/pubspec.lock b/pubspec.lock index ee64f6d..4b3f6ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -544,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -604,18 +612,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -977,26 +985,26 @@ packages: dependency: transitive description: name: test - sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.28.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.14" + version: "0.6.12" timing: dependency: transitive description: