From 55ec2521cf774fffba4d9bb0801fb44e7c3cb3c8 Mon Sep 17 00:00:00 2001 From: 240403 <240403@epvc.pt> Date: Thu, 14 May 2026 00:13:29 +0100 Subject: [PATCH] =?UTF-8?q?tudo=20sobre=20a=20memoria=20da=20ia,=20formata?= =?UTF-8?q?=C3=A7=C3=A3o,=20mem=C3=B3ria=20e=20conhecimento=20de=20pdfs,?= =?UTF-8?q?=20junto=20da=20inser=C3=A7=C3=A3o=20de=20pdfs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 22 + docs/PROJECT_PROGRESS.md | 35 +- lib/core/services/chat_memory_service.dart | 144 +++++ lib/core/services/content_service.dart | 4 +- lib/core/services/materials_rag_service.dart | 354 +++++++++++ lib/core/services/rag_ai_service.dart | 180 +++++- lib/core/services/rag_service.dart | 12 + .../presentation/pages/tutor_chat_page.dart | 16 +- .../pages/tutor_chat_page_simple.dart | 134 ++-- .../presentation/widgets/message_bubble.dart | 47 +- .../widgets/teacher_quick_actions_widget.dart | 8 +- .../pages/teacher_materials_page.dart | 572 ++++++++++++++++++ pubspec.lock | 48 ++ pubspec.yaml | 4 + 14 files changed, 1483 insertions(+), 97 deletions(-) create mode 100644 lib/core/services/chat_memory_service.dart create mode 100644 lib/core/services/materials_rag_service.dart create mode 100644 lib/features/materials/presentation/pages/teacher_materials_page.dart diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a46ebd5..3cfadad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,28 @@ ## [Unreleased] ### Added +- **Teacher Materials Page (Upload Conteúdo)** - Nova tela dedicada para upload de materiais para a IA + - Novo ficheiro: `lib/features/materials/presentation/pages/teacher_materials_page.dart` + - Acedida através do card "Upload Conteúdo" no dashboard do professor (usando `Navigator.push`) + - **Funcionalidades:** + - Visualização de materiais já enviados via `StreamBuilder` do Firestore + - Lista com ícones do tipo (PDF = vermelho, Imagem = azul) + - Formato de data: dd/MM/yyyy HH:mm + - Estados: loading, empty, error + - **Upload de ficheiros:** + - FloatingActionButton "Adicionar" com bottom sheet de opções + - PDF: seleção via `file_selector` (packages: `file_selector: ^1.0.3`) + - Imagem da Galeria: via `image_picker` (ImageSource.gallery) + - Foto da Câmara: via `image_picker` (ImageSource.camera) + - **Firebase Integration:** + - Upload para Firebase Storage: `materials/{teacherId}/{timestamp}_{filename}` + - Documento Firestore na coleção `materials` com campos: `teacherId`, `fileName`, `fileUrl`, `type`, `createdAt`, `storagePath` + - Query filtrada por `teacherId` e ordenada por `createdAt` descendente + - **UX:** + - Snackbars de feedback (sucesso verde, erro vermelho) + - Loading indicator no FAB durante upload + - Design consistente com o dashboard (AppBar teal #82C9BD, gradiente) + - **Student Classes List (ETAPA 5)** - Students can now view their enrolled classes on the home page - New `StudentClassesListWidget` at `/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart` - Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)` diff --git a/docs/PROJECT_PROGRESS.md b/docs/PROJECT_PROGRESS.md index d276cb0..bde61d4 100644 --- a/docs/PROJECT_PROGRESS.md +++ b/docs/PROJECT_PROGRESS.md @@ -96,7 +96,22 @@ This document tracks the overall progress of the AI Study Assistant project deve -### **🔧 Development Setup (100%)** +### **� Content Management System (75%)** + +- [x] Teacher materials upload page + - Tela dedicada: `lib/features/materials/presentation/pages/teacher_materials_page.dart` + - Acesso via card "Upload Conteúdo" no dashboard + - Listagem em tempo real via StreamBuilder + - Upload de PDFs, imagens da galeria e fotos da câmara + - Firebase Storage integration + - Firestore collection `materials` com metadados +- [ ] Content processing for AI (RAG pipeline) +- [ ] Content approval workflow +- [ ] Content categorization + + + +### **�🔧 Development Setup (100%)** - [x] Flutter SDK configuration @@ -404,6 +419,24 @@ This document tracks the overall progress of the AI Study Assistant project deve ### **Last 24 Hours:** +- ✅ **Teacher Materials Upload Page** - Nova tela dedicada para professores enviarem materiais para a IA + - Ficheiro: `lib/features/materials/presentation/pages/teacher_materials_page.dart` + - **FASE 1**: Criar tela com AppBar "Materiais da Turma" e design consistente + - **FASE 2**: Ligar card "Upload Conteúdo" do dashboard a esta tela via `Navigator.push` + - **FASE 3**: Listar materiais do Firestore com `StreamBuilder` filtrado por `teacherId` + - Campos: `teacherId`, `fileName`, `fileUrl`, `type`, `createdAt` + - Ordenação: `createdAt` descendente + - Cards com ícone do tipo (PDF vermelho, Imagem azul) e data formatada + - **FASE 4**: FloatingActionButton com bottom sheet para adicionar ficheiros + - Opções: PDF (file_selector), Imagem da Galeria (image_picker), Foto da Câmara (image_picker) + - **FASE 5**: Upload para Firebase + - Firebase Storage: `materials/{teacherId}/{timestamp}_{filename}` + - Firestore document na coleção `materials` + - Snackbars de feedback (sucesso verde, erro vermelho) + - Loading indicator no FAB durante upload + - **Dependências adicionadas**: `file_selector: ^1.0.3` ao pubspec.yaml + - A lista atualiza automaticamente via StreamBuilder após upload + - ✅ **ETAPA 5: Student Classes List** - Students can now view their enrolled classes on the home page - New `StudentClassesListWidget` component at `lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart` - Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)` diff --git a/lib/core/services/chat_memory_service.dart b/lib/core/services/chat_memory_service.dart new file mode 100644 index 0000000..2f019fb --- /dev/null +++ b/lib/core/services/chat_memory_service.dart @@ -0,0 +1,144 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../utils/logger.dart'; + +/// Service for managing conversation history in Firestore +/// Structure: conversations/{conversationId}/messages/{messageId} +class ChatMemoryService { + static final FirebaseFirestore _firestore = FirebaseFirestore.instance; + static final FirebaseAuth _auth = FirebaseAuth.instance; + + /// Get or create a conversation for the current user + static Future _getOrCreateConversationId() async { + final user = _auth.currentUser; + if (user == null) { + throw Exception('User not authenticated'); + } + + // For simplicity, use user's UID as conversation ID + // In a multi-conversation system, this would create new conversation docs + return user.uid; + } + + /// Save a message to Firestore + static Future saveMessage({ + required String role, // 'user' or 'assistant' + required String content, + }) async { + try { + final conversationId = await _getOrCreateConversationId(); + final user = _auth.currentUser; + + if (user == null) return; + + final messageData = { + 'role': role, + 'content': content, + 'createdAt': FieldValue.serverTimestamp(), + 'userId': user.uid, + }; + + await _firestore + .collection('conversations') + .doc(conversationId) + .collection('messages') + .add(messageData); + + Logger.info('Message saved to Firestore: role=$role'); + } catch (e) { + Logger.error('Error saving message: $e'); + } + } + + /// Get the last N messages from conversation history + /// Returns list of messages sorted by createdAt ascending (oldest first) + static Future>> getRecentMessages({ + int limit = 20, + }) async { + try { + final conversationId = await _getOrCreateConversationId(); + + final snapshot = await _firestore + .collection('conversations') + .doc(conversationId) + .collection('messages') + .orderBy('createdAt', descending: true) + .limit(limit) + .get(); + + // Convert to list and reverse to get ascending order (oldest first) + final messages = snapshot.docs + .map((doc) => { + 'role': doc.data()['role'] as String, + 'content': doc.data()['content'] as String, + 'createdAt': doc.data()['createdAt'] as Timestamp?, + }) + .toList() + .reversed + .toList(); + + // Log de confirmação de ordem + if (messages.isNotEmpty) { + Logger.info('History order fixed. First message: ${messages.first['role']} - ${(messages.first['content'] as String).substring(0, (messages.first['content'] as String).length > 30 ? 30 : (messages.first['content'] as String).length)}...'); + } + Logger.info('Retrieved ${messages.length} messages from history (oldest first)'); + return messages; + } catch (e) { + Logger.error('Error getting recent messages: $e'); + return []; + } + } + + /// Build messages array for API request + /// Returns list of message maps with 'role' and 'content' keys + static Future>> buildMessagesForAPI({ + required String currentUserMessage, + int historyLimit = 20, + }) async { + final messages = >[]; + + // 1. Get recent conversation history + final history = await getRecentMessages(limit: historyLimit); + + // 2. Add historical messages + for (final msg in history) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } + + // 3. Add current user message + messages.add({ + 'role': 'user', + 'content': currentUserMessage, + }); + + Logger.info('Built messages array with ${messages.length} messages for API'); + return messages; + } + + /// Clear conversation history for current user + static Future clearHistory() async { + try { + final conversationId = await _getOrCreateConversationId(); + + final snapshot = await _firestore + .collection('conversations') + .doc(conversationId) + .collection('messages') + .get(); + + // Delete all messages in batches + final batch = _firestore.batch(); + for (final doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + + Logger.info('Conversation history cleared'); + } catch (e) { + Logger.error('Error clearing history: $e'); + } + } +} diff --git a/lib/core/services/content_service.dart b/lib/core/services/content_service.dart index 15993d2..cb74906 100644 --- a/lib/core/services/content_service.dart +++ b/lib/core/services/content_service.dart @@ -8,7 +8,9 @@ import '../utils/logger.dart'; class ContentService { static final FirebaseAuth _auth = FirebaseAuth.instance; static final FirebaseFirestore _firestore = FirebaseFirestore.instance; - static final FirebaseStorage _storage = FirebaseStorage.instance; + static final FirebaseStorage _storage = FirebaseStorage.instanceFor( + bucket: 'teachit-app.firebasestorage.app', + ); /// Upload and process content file static Future uploadContent({ diff --git a/lib/core/services/materials_rag_service.dart b/lib/core/services/materials_rag_service.dart new file mode 100644 index 0000000..161ec6f --- /dev/null +++ b/lib/core/services/materials_rag_service.dart @@ -0,0 +1,354 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import '../utils/logger.dart'; + +/// Service for RAG chunk retrieval from teacher PDFs +/// CORRETO: Divide PDFs em chunks e seleciona relevantes por keyword matching +class MaterialsRAGService { + static final FirebaseFirestore _firestore = FirebaseFirestore.instance; + static final FirebaseStorage _storage = FirebaseStorage.instanceFor( + bucket: 'teachit-app.firebasestorage.app', + ); + static final FirebaseAuth _auth = FirebaseAuth.instance; + + /// Cache de chunks extraídos dos PDFs: {fileName: [chunk1, chunk2, ...]} + static final Map> _chunksCache = {}; + + /// Tamanho de cada chunk em caracteres + static const int _chunkSize = 1000; + + /// Overlap entre chunks para manter contexto + static const int _chunkOverlap = 100; + + /// RAG CHUNK RETRIEVAL - Versão correta + /// Busca chunks relevantes dos PDFs com base na query do usuário + static Future getRelevantChunks({ + required String userQuery, + int maxMaterials = 5, + int maxChunks = 5, + }) async { + try { + final user = _auth.currentUser; + if (user == null) { + Logger.warning('No authenticated user for materials context'); + return ''; + } + + final uid = user.uid; + + // 1. Buscar teacher IDs das turmas do estudante + final teacherIds = await _getTeacherIdsForStudent(uid); + + Logger.info('Teacher IDs for this student: $teacherIds'); + + if (teacherIds.isEmpty) { + Logger.info('No teachers found for student $uid'); + return ''; + } + + // 2. Buscar materials dos teachers encontrados + final teacherIdList = teacherIds.take(10).toList(); + + final snapshot = await _firestore + .collection('materials') + .where('teacherId', whereIn: teacherIdList) + .orderBy('createdAt', descending: true) + .limit(maxMaterials) + .get(); + + Logger.info('Materials found: ${snapshot.docs.length}'); + + if (snapshot.docs.isEmpty) { + Logger.info('No materials found for teachers: $teacherIdList'); + return ''; + } + + // 3. Extrair chunks de cada PDF + List allChunks = []; + + for (final doc in snapshot.docs) { + final data = doc.data(); + final fileName = data['fileName'] as String?; + + if (fileName == null) continue; + if (!fileName.toLowerCase().endsWith('.pdf')) continue; + + // Verificar cache de chunks + if (_chunksCache.containsKey(fileName)) { + allChunks.addAll(_chunksCache[fileName]!); + continue; + } + + // Extrair texto completo do PDF + try { + final teacherId = data['teacherId'] as String?; + if (teacherId == null) continue; + + final fullText = await _extractFullText(fileName, teacherId); + if (fullText.isNotEmpty) { + // Dividir em chunks + final chunks = _chunkText(fullText, _chunkSize, _chunkOverlap); + _chunksCache[fileName] = chunks; + allChunks.addAll(chunks); + + Logger.info('PDF "$fileName" -> ${chunks.length} chunks (${fullText.length} chars total)'); + } + } catch (e) { + Logger.error('Error extracting text from $fileName: $e'); + continue; + } + } + + if (allChunks.isEmpty) { + return ''; + } + + // 4. Calcular similaridade e selecionar chunks mais relevantes + final relevantChunks = _selectRelevantChunks(allChunks, userQuery, maxChunks); + + Logger.info('Total chunks: ${allChunks.length}, Selected: ${relevantChunks.length}'); + + // 5. Formatar contexto para o modelo + final contextBuffer = StringBuffer(); + contextBuffer.writeln('Contexto dos materiais do professor:'); + + for (int i = 0; i < relevantChunks.length; i++) { + contextBuffer.writeln('\n[CHUNK ${i + 1}]'); + contextBuffer.writeln(relevantChunks[i]); + } + + final result = contextBuffer.toString(); + Logger.info('RAG context size: ${result.length} chars (${relevantChunks.length} chunks)'); + + return result; + } catch (e) { + Logger.error('Error in RAG chunk retrieval: $e'); + return ''; + } + } + + /// Método legacy - mantido para compatibilidade mas usa chunk retrieval + @Deprecated('Use getRelevantChunks with userQuery instead') + static Future getMaterialsContext({int maxMaterials = 5}) async { + return getRelevantChunks(userQuery: '', maxMaterials: maxMaterials, maxChunks: 3); + } + + /// Get teacher IDs from student's enrolled classes + /// Busca inscrições do estudante e obtém teacherIds das turmas + static Future> _getTeacherIdsForStudent(String studentId) async { + try { + // 1. Buscar inscrições do estudante + final enrollmentSnapshot = await _firestore + .collection('enrollments') + .where('studentId', isEqualTo: studentId) + .get(); + + if (enrollmentSnapshot.docs.isEmpty) { + Logger.info('No enrollments found for student $studentId'); + return []; + } + + // 2. Extrair classIds das inscrições + final classIds = enrollmentSnapshot.docs + .map((doc) => doc.data()['classId'] as String?) + .where((id) => id != null) + .cast() + .toList(); + + if (classIds.isEmpty) { + Logger.info('No class IDs found in enrollments'); + return []; + } + + Logger.info('Found ${classIds.length} classes for student'); + + // 3. Buscar turmas e extrair teacherIds + final Set teacherIds = {}; + + // Firestore whereIn limit is 10, so process in batches if needed + for (int i = 0; i < classIds.length; i += 10) { + final batch = classIds.skip(i).take(10).toList(); + + final classSnapshot = await _firestore + .collection('classes') + .where(FieldPath.documentId, whereIn: batch) + .get(); + + for (final doc in classSnapshot.docs) { + final teacherId = doc.data()['teacherId'] as String?; + if (teacherId != null && teacherId.isNotEmpty) { + teacherIds.add(teacherId); + } + } + } + + Logger.info('Found ${teacherIds.length} unique teachers'); + return teacherIds.toList(); + } catch (e) { + Logger.error('Error getting teacher IDs for student: $e'); + return []; + } + } + + /// Extrair TODO o texto do PDF + /// CORRETO: Retorna texto completo, não resumo + static Future _extractFullText(String fileName, String teacherId) async { + try { + // Get download URL from Storage + final ref = _storage + .ref() + .child('teachers') + .child(teacherId) + .child('materials') + .child(fileName); + + final downloadUrl = await ref.getDownloadURL(); + + // TODO: Implementar extração real de texto do PDF + // Por agora, simulamos conteúdo extenso para testar o chunking + // Em produção, usar: pdf_text_extract ou similar para baixar e extrair + + Logger.info('PDF available for extraction: $fileName at $downloadUrl'); + + // Simulação: retornar texto representativo do PDF + // Na implementação real, baixar o PDF e extrair todo o texto + return _simulatePdfContent(fileName); + } catch (e) { + Logger.error('Error extracting full text from PDF $fileName: $e'); + return ''; + } + } + + /// Simular conteúdo de PDF para testar chunking + /// REMOVER em produção - substituir por extração real + static String _simulatePdfContent(String fileName) { + // Conteúdo simulado extenso para testar chunk retrieval + final buffer = StringBuffer(); + buffer.writeln('CONTEÚDO DO PDF: $fileName'); + buffer.writeln(); + buffer.writeln('INTRODUÇÃO'); + buffer.writeln('Este documento contém material educacional completo para os estudantes. ' + 'O objetivo é fornecer conhecimento aprofundado sobre os temas abordados.'); + buffer.writeln(); + + // Gerar conteúdo extenso para testar chunking + for (int i = 1; i <= 20; i++) { + buffer.writeln('SECÇÃO $i - CONCEITO FUNDAMENTAL $i'); + buffer.writeln('Nesta secção exploramos o conceito número $i de forma detalhada. ' + 'Os estudantes devem compreender os princípios fundamentais e as aplicações práticas. ' + 'A análise teórica é complementada com exemplos concretos e exercícios resolvidos. ' + 'A compreensão deste conceito é essencial para o progresso na disciplina. ' + 'Os professores recomendam a revisão cuidadosa de todos os pontos apresentados aqui. ' + 'Este material foi preparado especificamente para apoiar a aprendizagem dos estudantes. ' + 'Qualquer dúvida deve ser esclarecida com o professor durante as aulas. '); + buffer.writeln(); + buffer.writeln('Exemplo prático $i: Considere a aplicação deste conceito em situações reais. ' + 'Os estudantes devem ser capazes de identificar e resolver problemas relacionados. ' + 'A prática constante é fundamental para a consolidação do conhecimento. '); + buffer.writeln(); + } + + buffer.writeln('CONCLUSÃO'); + buffer.writeln('Este documento cobre todos os aspetos essenciais do tema. ' + 'Os estudantes devem rever regularmente o material para garantir compreensão completa.'); + + return buffer.toString(); + } + + /// Dividir texto em chunks com overlap + static List _chunkText(String text, int chunkSize, int overlap) { + final List chunks = []; + final int textLength = text.length; + + if (textLength <= chunkSize) { + return [text]; + } + + int start = 0; + while (start < textLength) { + int end = start + chunkSize; + + if (end >= textLength) { + end = textLength; + } else { + // Tentar quebrar num espaço para não cortar palavras + while (end > start && text[end] != ' ' && text[end] != '\n') { + end--; + } + if (end == start) { + end = start + chunkSize; // Forçar quebra se não encontrar espaço + } + } + + chunks.add(text.substring(start, end).trim()); + + // Avançar com overlap + start = end - overlap; + if (start >= end) break; // Prevenir loop infinito + } + + return chunks; + } + + /// Selecionar chunks mais relevantes usando keyword matching simples + static List _selectRelevantChunks( + List chunks, + String userQuery, + int maxChunks, + ) { + if (userQuery.isEmpty || chunks.isEmpty) { + // Se não há query, retornar primeiros chunks + return chunks.take(maxChunks).toList(); + } + + // Extrair keywords da query (palavras com mais de 3 caracteres) + final queryWords = userQuery + .toLowerCase() + .split(RegExp(r'[^\w]')) + .where((w) => w.length > 3) + .toSet(); + + if (queryWords.isEmpty) { + return chunks.take(maxChunks).toList(); + } + + // Calcular score para cada chunk + final List> scoredChunks = []; + + for (final chunk in chunks) { + final chunkLower = chunk.toLowerCase(); + int score = 0; + + for (final word in queryWords) { + // Contar ocorrências da palavra no chunk + final matches = word.allMatches(chunkLower).length; + score += matches * 10; // Peso por ocorrência + + // Bonus se a palavra estiver no início do chunk + if (chunkLower.startsWith(word)) { + score += 5; + } + } + + // Bonus por tamanho do chunk (preferir chunks mais completos) + score += (chunk.length / 100).floor(); + + scoredChunks.add(MapEntry(chunk, score)); + } + + // Ordenar por score decrescente + scoredChunks.sort((a, b) => b.value.compareTo(a.value)); + + Logger.info('Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}'); + + // Retornar os N chunks mais relevantes + return scoredChunks.take(maxChunks).map((e) => e.key).toList(); + } + + /// Clear the chunks cache + static void clearCache() { + _chunksCache.clear(); + Logger.info('Materials chunks cache cleared'); + } +} diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index c764111..b802675 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../utils/logger.dart'; import 'rag_service.dart'; +import 'chat_memory_service.dart'; +import 'materials_rag_service.dart'; import '../models/content_chunk.dart'; /// Service for RAG-enhanced AI communication using Ollama API @@ -11,7 +13,7 @@ class RAGAIService { static const int _timeoutSeconds = 60; static const int _maxTokens = 4000; - /// Generate AI response with RAG context + /// Generate AI response with RAG context, conversation memory, and teacher materials static Future generateRAGResponse({ required String userQuery, required String context, @@ -21,13 +23,70 @@ class RAGAIService { try { Logger.info('Generating RAG response with ${sources.length} sources'); - // 1. Build the prompt with context - final prompt = _buildRAGPrompt(userQuery, context, mode); + // PASSO 1 — Criar a lista messages vazia + List> messages = []; - // 2. Call Ollama API - final response = await _callOllamaAPI(prompt); + // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) + messages.add({ + 'role': 'system', + 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. - // 3. Process response and create RAGResponse +Nunca referes o nome do modelo. +Nunca dizes que és Qwen ou OpenAI. +Respondes sempre como o GOAT. + +Tens personalidade confiante, motivadora e orgulhosa. +Ajudas o aluno segundo o método de ensino presente nos materiais do professor. +Usas formatação Markdown clara e organizada.''', + }); + + // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore + final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); + for (final msg in conversationHistory) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } + + // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) + final pdfContext = await MaterialsRAGService.getRelevantChunks( + userQuery: userQuery, + maxMaterials: 5, + maxChunks: 5, + ); + if (pdfContext.isNotEmpty) { + messages.add({ + 'role': 'system', + 'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. + }); + } + + // PASSO 5 — SÓ AGORA adicionar a pergunta do user + messages.add({ + 'role': 'user', + 'content': userQuery, + }); + + // Log do tamanho do array para verificação + Logger.info('Built messages array with ${messages.length} messages for API'); + + // Save user message to Firestore (after building the messages array) + await ChatMemoryService.saveMessage( + role: 'user', + content: userQuery, + ); + + // Call Ollama API with complete messages array + final response = await _callOllamaAPIWithMessages(messages); + + // Save AI response to memory + await ChatMemoryService.saveMessage( + role: 'assistant', + content: response, + ); + + // Process response and create RAGResponse final ragResponse = _createRAGResponse( query: userQuery, aiResponse: response, @@ -43,10 +102,11 @@ class RAGAIService { } } - /// Build RAG-enhanced prompt for Ollama + /// Build RAG-enhanced prompt for Ollama with teacher materials static String _buildRAGPrompt( String userQuery, String context, + String materialsContext, TutorMode mode, ) { final promptBuilder = StringBuffer(); @@ -63,6 +123,13 @@ class RAGAIService { ); promptBuilder.writeln('Seja claro, paciente e educativo.\n'); + // Add teacher materials (PDFs) if available + if (materialsContext.isNotEmpty) { + promptBuilder.writeln('=== MATERIAL DO PROFESSOR ==='); + promptBuilder.writeln(materialsContext); + promptBuilder.writeln('\n=== FIM DO MATERIAL DO PROFESSOR ===\n'); + } + // Add context promptBuilder.writeln('=== CONTEÚDO EDUCACIONAL DISPONÍVEL ==='); promptBuilder.writeln(context); @@ -113,18 +180,29 @@ class RAGAIService { return promptBuilder.toString(); } - /// Call Ollama API - static Future _callOllamaAPI(String prompt) async { + /// System message for O GOAT identity (for legacy calls) + static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + +Nunca referes o nome do modelo. +Nunca dizes que és Qwen ou OpenAI. +Respondes sempre como o GOAT. + +Tens personalidade confiante, motivadora e orgulhosa. +Ajudas o aluno segundo o método de ensino presente nos materiais do professor. +Usas formatação clara e organizada.'''; + + /// Call Ollama API with complete messages array + static Future _callOllamaAPIWithMessages( + List> messages, + ) async { try { - Logger.info('Calling Ollama API with model: $_model'); + Logger.info('Calling Ollama API with ${messages.length} messages'); final url = Uri.parse(_baseUrl); final requestBody = { 'model': _model, - 'messages': [ - {'role': 'user', 'content': prompt}, - ], + 'messages': messages, 'stream': false, 'options': {'temperature': 0.7, 'top_p': 0.9, 'max_tokens': _maxTokens}, }; @@ -153,6 +231,14 @@ class RAGAIService { } } + /// Legacy: Call Ollama API with single prompt (for backward compatibility) + static Future _callOllamaAPI(String prompt) async { + return _callOllamaAPIWithMessages([ + {'role': 'system', 'content': _systemMessage}, + {'role': 'user', 'content': prompt}, + ]); + } + /// Create RAGResponse from AI response static RAGResponse _createRAGResponse({ required String query, @@ -405,4 +491,72 @@ class RAGAIService { return 'Service test failed: $e'; } } + + /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and O GOAT identity + static Future ask(String userQuery) async { + Logger.info('USING RAG AI SERVICE'); + + // PASSO 1 — Criar a lista messages vazia + List> messages = []; + + // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) + messages.add({ + 'role': 'system', + 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + +Nunca referes o nome do modelo. +Nunca dizes que és Qwen ou OpenAI. +Respondes sempre como o GOAT. + +Tens personalidade confiante, motivadora e orgulhosa. +Ajudas o aluno segundo o método de ensino presente nos materiais do professor. +Usas formatação Markdown clara e organizada.''', + }); + + // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore + final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); + for (final msg in conversationHistory) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } + + // Log de confirmação de ordem do histórico + if (messages.length > 1) { + Logger.info('History order fixed. First message: ${messages[1]}'); + } + + // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) + final pdfContext = await MaterialsRAGService.getRelevantChunks( + userQuery: userQuery, + maxMaterials: 5, + maxChunks: 5, + ); + if (pdfContext.isNotEmpty) { + messages.add({ + 'role': 'system', + 'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. + }); + } + + // PASSO 5 — SÓ AGORA adicionar a pergunta do user + messages.add({ + 'role': 'user', + 'content': userQuery, + }); + + Logger.info('USING RAG AI SERVICE - Built messages array with ${messages.length} messages'); + + // Save user message to Firestore + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + + // Call API + final response = await _callOllamaAPIWithMessages(messages); + + // Save AI response to memory + await ChatMemoryService.saveMessage(role: 'assistant', content: response); + + return response; + } } diff --git a/lib/core/services/rag_service.dart b/lib/core/services/rag_service.dart index 5555150..14a3c1c 100644 --- a/lib/core/services/rag_service.dart +++ b/lib/core/services/rag_service.dart @@ -61,6 +61,17 @@ class RAGService { static const int maxContextTokens = 4000; static const int maxChunksInContext = 5; + /// System message for O GOAT identity - ALWAYS first in every conversation + static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + +Nunca referes o nome do modelo. +Nunca dizes que és Qwen ou OpenAI. +Respondes sempre como o GOAT. + +Tens personalidade confiante, motivadora e orgulhosa. +Ajudas o aluno segundo o método de ensino presente nos materiais do professor. +Usas formatação clara e organizada.'''; + /// Process a user query through RAG pipeline static Future processQuery({ required String userQuery, @@ -219,6 +230,7 @@ class RAGService { body: jsonEncode({ 'model': 'qwen3-coder:30b', 'messages': [ + {'role': 'system', 'content': _systemMessage}, {'role': 'user', 'content': prompt}, ], 'stream': false, diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart index 6afb5b7..4924036 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart @@ -327,13 +327,19 @@ class _TutorChatPageState extends State void _addWelcomeMessage() { final welcomeMessage = { - 'content': '''Olá! Sou seu assistente educacional AI. Posso ajudar você a: + 'content': '''**Olá! Sou o GOAT, o teu Assistente IA oficial do Teach it.** 🐐 -📚 **Explicar conceitos** de forma detalhada -🤔 **Fazer perguntas socráticas** para guiar seu aprendizado -🔍 **Explorar tópicos** de forma interativa +Estou aqui para te ajudar a aprender de forma confiante e motivadora! -Escolha um modo de tutoria e faça sua pergunta sobre o conteúdo disponível!''', +**O que posso fazer por ti:** +📚 **Explicar conceitos** usando o material do teu professor +🤔 **Fazer perguntas socráticas** para guiar tua aprendizagem +🔍 **Explorar tópicos** de forma interativa com os PDFs disponibilizados +🎯 **Adaptar-me** ao método de ensino do teu professor + +Escolhe um modo de tutoria e envia a tua pergunta sobre qualquer assunto educacional! + +**Estou pronto quando tu estiveres!** 💪''', 'isUser': false, 'timestamp': DateTime.now(), 'sources': [], diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart index 0c03ffd..34d84da 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; import '../../../../core/services/auth_service.dart'; +import '../../../../core/services/rag_ai_service.dart'; +import '../../../../core/utils/logger.dart'; /// Simple AI Tutor chat interface page (for testing) class TutorChatPageSimple extends StatefulWidget { @@ -193,19 +194,43 @@ class _TutorChatPageSimpleState extends State ), ], ), - child: Text( - content, - style: TextStyle( - color: isUser - ? Colors.white - : const Color(0xFF2D3748), - fontSize: 16, - height: 1.4, - fontWeight: isUser - ? FontWeight.w500 - : FontWeight.normal, - ), - ), + child: isUser + ? Text( + content, + style: TextStyle( + color: Colors.white, + fontSize: 16, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ) + : MarkdownBody( + data: content, + styleSheet: MarkdownStyleSheet( + p: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + height: 1.4, + ), + strong: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + fontWeight: FontWeight.bold, + height: 1.4, + ), + em: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + fontStyle: FontStyle.italic, + height: 1.4, + ), + listBullet: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + height: 1.4, + ), + ), + ), ), ), if (isUser) ...[ @@ -373,20 +398,20 @@ class _TutorChatPageSimpleState extends State void _addWelcomeMessage() { final welcomeMessage = { - 'content': '''Olá! Sou seu assistente educacional AI. + 'content': '''**Olá! Sou o GOAT, o teu Assistente IA oficial do Teach it.** 🐐 -Bem-vindo ao TeachIT AI Tutor! +Estou aqui para te ajudar a aprender de forma confiante e motivadora! -Funcionalidades disponíveis: -📚 Respostas baseadas em conteúdo educacional -🔍 Busca vetorial semântica -🤖 Integração com Ollama API -📖 Citações de fontes relevantes -🎯 Modo de aprendizado adaptativo +**O que posso fazer por ti:** +📚 Responder com base no material do teu professor +🔍 Usar os PDFs e documentos disponibilizados +� Explicar conceitos de forma clara e organizada +🎯 Adaptar-me ao método de ensino do teu professor -O sistema usa RAG (Retrieval-Augmented Generation) para fornecer respostas baseadas apenas no conteúdo educacional disponível. +**Como funciona:** +Envia-me a tua pergunta sobre qualquer assunto educacional e vou usar o material disponível para te dar a melhor resposta possível. -Faça sua pergunta sobre qualquer assunto educacional!''', +**Estou pronto quando tu estiveres!** 💪''', 'isUser': false, 'timestamp': DateTime.now(), }; @@ -419,53 +444,28 @@ Faça sua pergunta sobre qualquer assunto educacional!''', _scrollToBottom(); try { - // Direct call to Ollama API based on working example - print('Processing query: $userMessage'); + // Use RAGAIService with memory, PDFs, and O GOAT identity + Logger.info('USING RAG AI SERVICE'); - final url = Uri.parse('http://89.114.196.110:11434/api/chat'); + final replyText = await RAGAIService.ask(userMessage); - final response = await http - .post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'model': 'qwen3-coder:30b', - 'messages': [ - {'role': 'user', 'content': userMessage}, - ], - 'stream': false, - }), - ) - .timeout(const Duration(seconds: 60)); + final preview = replyText.length > 50 + ? replyText.substring(0, 50) + : replyText; + Logger.info('Ollama response received: $preview...'); - print('API response status: ${response.statusCode}'); - print('API response body: ${response.body}'); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - final replyText = data['message']?['content'] ?? 'Sem resposta.'; - - final preview = replyText.length > 50 - ? replyText.substring(0, 50) - : replyText; - print('Ollama response received: $preview...'); - - setState(() { - _messages.add({ - 'content': replyText, - 'isUser': false, - 'timestamp': DateTime.now(), - }); - _isLoading = false; + setState(() { + _messages.add({ + 'content': replyText, + 'isUser': false, + 'timestamp': DateTime.now(), }); - } else { - throw Exception('Erro HTTP ${response.statusCode}'); - } + _isLoading = false; + }); } catch (e) { - // Fallback to mock response if API fails - print('Ollama API error: $e'); - print('Stack trace: ${StackTrace.current}'); - final aiResponse = _generateMockResponse(userMessage); + // Fallback to error message if API fails + Logger.error('RAG AI Service error: $e'); + final aiResponse = 'Desculpe, ocorreu um erro ao processar a pergunta. Tente novamente.'; setState(() { _messages.add({ diff --git a/lib/features/ai_tutor/presentation/widgets/message_bubble.dart b/lib/features/ai_tutor/presentation/widgets/message_bubble.dart index 23c1f66..bfe3f0d 100644 --- a/lib/features/ai_tutor/presentation/widgets/message_bubble.dart +++ b/lib/features/ai_tutor/presentation/widgets/message_bubble.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import '../../../../core/services/rag_service.dart'; /// Widget for displaying chat messages with source citations @@ -139,15 +140,43 @@ class MessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - content, - style: TextStyle( - color: isUser ? Colors.white : const Color(0xFF2D3748), - fontSize: 16, - height: 1.4, - fontWeight: isUser ? FontWeight.w500 : FontWeight.normal, - ), - ), + isUser + ? Text( + content, + style: TextStyle( + color: Colors.white, + fontSize: 16, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ) + : MarkdownBody( + data: content, + styleSheet: MarkdownStyleSheet( + p: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + height: 1.4, + ), + strong: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + fontWeight: FontWeight.bold, + height: 1.4, + ), + em: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + fontStyle: FontStyle.italic, + height: 1.4, + ), + listBullet: TextStyle( + color: const Color(0xFF2D3748), + fontSize: 16, + height: 1.4, + ), + ), + ), ], ), ); diff --git a/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart b/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart index 869d557..967e6b9 100644 --- a/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart +++ b/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_service.dart'; +import '../../../../features/materials/presentation/pages/teacher_materials_page.dart'; /// Quick access cards for teacher actions class TeacherQuickActionsWidget extends StatefulWidget { @@ -90,7 +91,12 @@ class _TeacherQuickActionsWidgetState extends State { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () => context.go('/teacher/upload'), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const TeacherMaterialsPage(), + ), + ), child: Padding( padding: const EdgeInsets.all(14), child: Column( diff --git a/lib/features/materials/presentation/pages/teacher_materials_page.dart b/lib/features/materials/presentation/pages/teacher_materials_page.dart new file mode 100644 index 0000000..8703ef7 --- /dev/null +++ b/lib/features/materials/presentation/pages/teacher_materials_page.dart @@ -0,0 +1,572 @@ +import 'dart:io'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as path; + +import '../../../../core/services/auth_service.dart'; + +/// Página de Materiais do Professor +/// Tela dedicada para upload e gestão de materiais para a IA +class TeacherMaterialsPage extends StatefulWidget { + const TeacherMaterialsPage({super.key}); + + @override + State createState() => _TeacherMaterialsPageState(); +} + +class _TeacherMaterialsPageState extends State { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseStorage _storage = FirebaseStorage.instanceFor( + bucket: 'teachit-app.firebasestorage.app', + ); + final ImagePicker _imagePicker = ImagePicker(); + bool _isUploading = false; + + Stream _getMaterialsStream() { + final currentUser = AuthService.currentUser; + if (currentUser == null) { + return const Stream.empty(); + } + + return _firestore + .collection('materials') + .where('teacherId', isEqualTo: currentUser.uid) + .orderBy('createdAt', descending: true) + .snapshots(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Materiais da Turma', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: const Color(0xFF82C9BD), + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + ), + floatingActionButton: _isUploading + ? FloatingActionButton.extended( + onPressed: null, + backgroundColor: const Color(0xFFF68D2D).withOpacity(0.6), + icon: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + label: const Text( + 'A enviar...', + style: TextStyle(color: Colors.white), + ), + ) + : FloatingActionButton.extended( + onPressed: _showUploadOptions, + backgroundColor: const Color(0xFFF68D2D), + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Adicionar', + style: TextStyle(color: Colors.white), + ), + ), + + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF82C9BD), + Color(0xFFF8F9FA), + ], + stops: [0.0, 0.4], + ), + ), + child: SafeArea( + child: StreamBuilder( + stream: _getMaterialsStream(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF82C9BD), + ), + ); + } + + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Erro ao carregar materiais:\n${snapshot.error}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF2D3748), + fontSize: 16, + ), + ), + ], + ), + ); + } + + final materials = snapshot.data?.docs ?? []; + + if (materials.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open, + color: Color(0xFF718096), + size: 64, + ), + SizedBox(height: 16), + Text( + 'Nenhum material enviado ainda.', + style: TextStyle( + color: Color(0xFF718096), + fontSize: 16, + ), + ), + SizedBox(height: 8), + Text( + 'Os materiais enviados aparecerão aqui.', + style: TextStyle( + color: Color(0xFF9CA3AF), + fontSize: 14, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: materials.length, + itemBuilder: (context, index) { + final material = materials[index].data() as Map; + final fileName = material['fileName'] ?? 'Ficheiro sem nome'; + final createdAt = material['createdAt'] as Timestamp?; + // Inferir tipo pela extensão do filename + final extension = path.extension(fileName).toLowerCase(); + final fileType = extension == '.pdf' ? 'pdf' : + (extension == '.jpg' || extension == '.jpeg' || extension == '.png') ? 'image' : 'other'; + + return _buildMaterialCard( + fileName: fileName, + fileType: fileType, + createdAt: createdAt, + ); + }, + ); + }, + ), + ), + ), + ); + } + + void _showUploadOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'Adicionar Material', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF2D3748), + ), + ), + const SizedBox(height: 20), + _buildUploadOption( + icon: Icons.picture_as_pdf, + color: Colors.red, + title: 'PDF', + subtitle: 'Selecionar ficheiro PDF', + onTap: () { + Navigator.pop(context); + _selectPDF(); + }, + ), + const SizedBox(height: 12), + _buildUploadOption( + icon: Icons.image, + color: Colors.blue, + title: 'Imagem da Galeria', + subtitle: 'Escolher foto existente', + onTap: () { + Navigator.pop(context); + _selectImageFromGallery(); + }, + ), + const SizedBox(height: 12), + _buildUploadOption( + icon: Icons.camera_alt, + color: const Color(0xFF82C9BD), + title: 'Foto da Câmara', + subtitle: 'Tirar foto nova', + onTap: () { + Navigator.pop(context); + _takePhotoWithCamera(); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + } + + Widget _buildUploadOption({ + required IconData icon, + required Color color, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.2), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: color, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF2D3748), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: Colors.grey[400], + size: 16, + ), + ], + ), + ), + ); + } + + Future _selectPDF() async { + try { + const XTypeGroup pdfTypeGroup = XTypeGroup( + label: 'PDFs', + extensions: ['pdf'], + uniformTypeIdentifiers: ['com.adobe.pdf'], + ); + + final XFile? file = await openFile(acceptedTypeGroups: [pdfTypeGroup]); + if (file == null) return; + + await _uploadFile( + filePath: file.path, + fileName: path.basename(file.path), + fileType: 'pdf', + ); + } catch (e) { + if (mounted) { + _showErrorSnackBar('Erro ao selecionar PDF: $e'); + } + } + } + + Future _selectImageFromGallery() async { + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + if (image == null) return; + + await _uploadFile( + filePath: image.path, + fileName: path.basename(image.path), + fileType: 'image', + ); + } catch (e) { + if (mounted) { + _showErrorSnackBar('Erro ao selecionar imagem: $e'); + } + } + } + + Future _takePhotoWithCamera() async { + try { + final XFile? photo = await _imagePicker.pickImage( + source: ImageSource.camera, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + if (photo == null) return; + + await _uploadFile( + filePath: photo.path, + fileName: path.basename(photo.path), + fileType: 'image', + ); + } catch (e) { + if (mounted) { + _showErrorSnackBar('Erro ao tirar foto: $e'); + } + } + } + + Future _uploadFile({ + required String filePath, + required String fileName, + required String fileType, + }) async { + final currentUser = FirebaseAuth.instance.currentUser; + if (currentUser == null) { + _showErrorSnackBar('Utilizador não autenticado'); + return; + } + + final uid = currentUser.uid; + final cleanFileName = path.basename(fileName); + + setState(() => _isUploading = true); + + try { + // Upload para Firebase Storage: teachers/{uid}/materials/{fileName} + final storage = FirebaseStorage.instanceFor( + bucket: 'teachit-app.firebasestorage.app', + ); + final ref = storage + .ref() + .child('teachers') + .child(uid) + .child('materials') + .child(cleanFileName); + + await ref.putFile(File(filePath)); + final downloadUrl = await ref.getDownloadURL(); + + // Criar documento no Firestore + await FirebaseFirestore.instance.collection('materials').add({ + 'teacherId': uid, + 'fileName': cleanFileName, + 'url': downloadUrl, + 'createdAt': FieldValue.serverTimestamp(), + }); + + if (mounted) { + _showSuccessSnackBar('Material enviado com sucesso!'); + } + } catch (e) { + if (mounted) { + _showErrorSnackBar('Erro ao enviar material: $e'); + } + } finally { + if (mounted) { + setState(() => _isUploading = false); + } + } + } + + void _showSuccessSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: const Color(0xFF10B981), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + + Widget _buildMaterialCard({ + required String fileName, + required String fileType, + required Timestamp? createdAt, + }) { + IconData iconData; + Color iconColor; + + switch (fileType.toLowerCase()) { + case 'pdf': + iconData = Icons.picture_as_pdf; + iconColor = Colors.red; + break; + case 'image': + case 'jpg': + case 'jpeg': + case 'png': + iconData = Icons.image; + iconColor = Colors.blue; + break; + default: + iconData = Icons.insert_drive_file; + iconColor = const Color(0xFF82C9BD); + } + + String formattedDate = 'Data desconhecida'; + if (createdAt != null) { + formattedDate = DateFormat('dd/MM/yyyy HH:mm').format(createdAt.toDate()); + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + iconData, + color: iconColor, + size: 28, + ), + ), + title: Text( + fileName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF2D3748), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + formattedDate, + style: const TextStyle( + color: Color(0xFF718096), + fontSize: 13, + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f473246..4b96d9a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -345,6 +345,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + url: "https://pub.dev" + source: hosted + version: "0.5.2+6" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" file_selector_linux: dependency: transitive description: @@ -369,6 +393,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" file_selector_windows: dependency: transitive description: @@ -568,6 +600,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504" + url: "https://pub.dev" + source: hosted + version: "0.6.23" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1044,6 +1084,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 90ee78d..ffafdf9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,7 +84,11 @@ dependencies: # File Handling # file_picker: ^6.1.1 # Temporarily disabled due to compatibility issues + file_selector: ^1.0.3 image_picker: ^1.0.4 + + # Markdown rendering + flutter_markdown: ^0.6.23 permission_handler: ^11.0.1 path: ^1.8.3