tudo sobre a memoria da ia, formatação, memória e conhecimento de pdfs, junto da inserção de pdfs
This commit is contained in:
@@ -7,6 +7,28 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- **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`
|
- 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)`
|
- Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)`
|
||||||
|
|||||||
@@ -96,7 +96,22 @@ This document tracks the overall progress of the AI Study Assistant project deve
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
### **🔧 Development Setup (100%)**
|
### **<EFBFBD> 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### **<2A>🔧 Development Setup (100%)**
|
||||||
|
|
||||||
- [x] Flutter SDK configuration
|
- [x] Flutter SDK configuration
|
||||||
|
|
||||||
@@ -404,6 +419,24 @@ This document tracks the overall progress of the AI Study Assistant project deve
|
|||||||
|
|
||||||
### **Last 24 Hours:**
|
### **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
|
- ✅ **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`
|
- 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)`
|
- Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)`
|
||||||
|
|||||||
144
lib/core/services/chat_memory_service.dart
Normal file
144
lib/core/services/chat_memory_service.dart
Normal file
@@ -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<String> _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<void> 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<List<Map<String, dynamic>>> 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<List<Map<String, String>>> buildMessagesForAPI({
|
||||||
|
required String currentUserMessage,
|
||||||
|
int historyLimit = 20,
|
||||||
|
}) async {
|
||||||
|
final messages = <Map<String, String>>[];
|
||||||
|
|
||||||
|
// 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<void> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import '../utils/logger.dart';
|
|||||||
class ContentService {
|
class ContentService {
|
||||||
static final FirebaseAuth _auth = FirebaseAuth.instance;
|
static final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
static final FirebaseFirestore _firestore = FirebaseFirestore.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
|
/// Upload and process content file
|
||||||
static Future<String> uploadContent({
|
static Future<String> uploadContent({
|
||||||
|
|||||||
354
lib/core/services/materials_rag_service.dart
Normal file
354
lib/core/services/materials_rag_service.dart
Normal file
@@ -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<String, List<String>> _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<String> 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<String> 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<String> 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<List<String>> _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<String>()
|
||||||
|
.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<String> 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<String> _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<String> _chunkText(String text, int chunkSize, int overlap) {
|
||||||
|
final List<String> 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<String> _selectRelevantChunks(
|
||||||
|
List<String> 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<MapEntry<String, int>> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'rag_service.dart';
|
import 'rag_service.dart';
|
||||||
|
import 'chat_memory_service.dart';
|
||||||
|
import 'materials_rag_service.dart';
|
||||||
import '../models/content_chunk.dart';
|
import '../models/content_chunk.dart';
|
||||||
|
|
||||||
/// Service for RAG-enhanced AI communication using Ollama API
|
/// Service for RAG-enhanced AI communication using Ollama API
|
||||||
@@ -11,7 +13,7 @@ class RAGAIService {
|
|||||||
static const int _timeoutSeconds = 60;
|
static const int _timeoutSeconds = 60;
|
||||||
static const int _maxTokens = 4000;
|
static const int _maxTokens = 4000;
|
||||||
|
|
||||||
/// Generate AI response with RAG context
|
/// Generate AI response with RAG context, conversation memory, and teacher materials
|
||||||
static Future<RAGResponse> generateRAGResponse({
|
static Future<RAGResponse> generateRAGResponse({
|
||||||
required String userQuery,
|
required String userQuery,
|
||||||
required String context,
|
required String context,
|
||||||
@@ -21,13 +23,70 @@ class RAGAIService {
|
|||||||
try {
|
try {
|
||||||
Logger.info('Generating RAG response with ${sources.length} sources');
|
Logger.info('Generating RAG response with ${sources.length} sources');
|
||||||
|
|
||||||
// 1. Build the prompt with context
|
// PASSO 1 — Criar a lista messages vazia
|
||||||
final prompt = _buildRAGPrompt(userQuery, context, mode);
|
List<Map<String, String>> messages = [];
|
||||||
|
|
||||||
// 2. Call Ollama API
|
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO)
|
||||||
final response = await _callOllamaAPI(prompt);
|
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(
|
final ragResponse = _createRAGResponse(
|
||||||
query: userQuery,
|
query: userQuery,
|
||||||
aiResponse: response,
|
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(
|
static String _buildRAGPrompt(
|
||||||
String userQuery,
|
String userQuery,
|
||||||
String context,
|
String context,
|
||||||
|
String materialsContext,
|
||||||
TutorMode mode,
|
TutorMode mode,
|
||||||
) {
|
) {
|
||||||
final promptBuilder = StringBuffer();
|
final promptBuilder = StringBuffer();
|
||||||
@@ -63,6 +123,13 @@ class RAGAIService {
|
|||||||
);
|
);
|
||||||
promptBuilder.writeln('Seja claro, paciente e educativo.\n');
|
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
|
// Add context
|
||||||
promptBuilder.writeln('=== CONTEÚDO EDUCACIONAL DISPONÍVEL ===');
|
promptBuilder.writeln('=== CONTEÚDO EDUCACIONAL DISPONÍVEL ===');
|
||||||
promptBuilder.writeln(context);
|
promptBuilder.writeln(context);
|
||||||
@@ -113,18 +180,29 @@ class RAGAIService {
|
|||||||
return promptBuilder.toString();
|
return promptBuilder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call Ollama API
|
/// System message for O GOAT identity (for legacy calls)
|
||||||
static Future<String> _callOllamaAPI(String prompt) async {
|
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<String> _callOllamaAPIWithMessages(
|
||||||
|
List<Map<String, String>> messages,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
Logger.info('Calling Ollama API with model: $_model');
|
Logger.info('Calling Ollama API with ${messages.length} messages');
|
||||||
|
|
||||||
final url = Uri.parse(_baseUrl);
|
final url = Uri.parse(_baseUrl);
|
||||||
|
|
||||||
final requestBody = {
|
final requestBody = {
|
||||||
'model': _model,
|
'model': _model,
|
||||||
'messages': [
|
'messages': messages,
|
||||||
{'role': 'user', 'content': prompt},
|
|
||||||
],
|
|
||||||
'stream': false,
|
'stream': false,
|
||||||
'options': {'temperature': 0.7, 'top_p': 0.9, 'max_tokens': _maxTokens},
|
'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<String> _callOllamaAPI(String prompt) async {
|
||||||
|
return _callOllamaAPIWithMessages([
|
||||||
|
{'role': 'system', 'content': _systemMessage},
|
||||||
|
{'role': 'user', 'content': prompt},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Create RAGResponse from AI response
|
/// Create RAGResponse from AI response
|
||||||
static RAGResponse _createRAGResponse({
|
static RAGResponse _createRAGResponse({
|
||||||
required String query,
|
required String query,
|
||||||
@@ -405,4 +491,72 @@ class RAGAIService {
|
|||||||
return 'Service test failed: $e';
|
return 'Service test failed: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and O GOAT identity
|
||||||
|
static Future<String> ask(String userQuery) async {
|
||||||
|
Logger.info('USING RAG AI SERVICE');
|
||||||
|
|
||||||
|
// PASSO 1 — Criar a lista messages vazia
|
||||||
|
List<Map<String, String>> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ class RAGService {
|
|||||||
static const int maxContextTokens = 4000;
|
static const int maxContextTokens = 4000;
|
||||||
static const int maxChunksInContext = 5;
|
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
|
/// Process a user query through RAG pipeline
|
||||||
static Future<RAGResponse> processQuery({
|
static Future<RAGResponse> processQuery({
|
||||||
required String userQuery,
|
required String userQuery,
|
||||||
@@ -219,6 +230,7 @@ class RAGService {
|
|||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'model': 'qwen3-coder:30b',
|
'model': 'qwen3-coder:30b',
|
||||||
'messages': [
|
'messages': [
|
||||||
|
{'role': 'system', 'content': _systemMessage},
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': prompt},
|
||||||
],
|
],
|
||||||
'stream': false,
|
'stream': false,
|
||||||
|
|||||||
@@ -327,13 +327,19 @@ class _TutorChatPageState extends State<TutorChatPage>
|
|||||||
|
|
||||||
void _addWelcomeMessage() {
|
void _addWelcomeMessage() {
|
||||||
final welcomeMessage = {
|
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
|
Estou aqui para te ajudar a aprender de forma confiante e motivadora!
|
||||||
🤔 **Fazer perguntas socráticas** para guiar seu aprendizado
|
|
||||||
🔍 **Explorar tópicos** de forma interativa
|
|
||||||
|
|
||||||
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,
|
'isUser': false,
|
||||||
'timestamp': DateTime.now(),
|
'timestamp': DateTime.now(),
|
||||||
'sources': <SourceCitation>[],
|
'sources': <SourceCitation>[],
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import '../../../../core/services/auth_service.dart';
|
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)
|
/// Simple AI Tutor chat interface page (for testing)
|
||||||
class TutorChatPageSimple extends StatefulWidget {
|
class TutorChatPageSimple extends StatefulWidget {
|
||||||
@@ -193,19 +194,43 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Text(
|
child: isUser
|
||||||
content,
|
? Text(
|
||||||
style: TextStyle(
|
content,
|
||||||
color: isUser
|
style: TextStyle(
|
||||||
? Colors.white
|
color: Colors.white,
|
||||||
: const Color(0xFF2D3748),
|
fontSize: 16,
|
||||||
fontSize: 16,
|
height: 1.4,
|
||||||
height: 1.4,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: isUser
|
),
|
||||||
? FontWeight.w500
|
)
|
||||||
: FontWeight.normal,
|
: 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) ...[
|
if (isUser) ...[
|
||||||
@@ -373,20 +398,20 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
|
|||||||
|
|
||||||
void _addWelcomeMessage() {
|
void _addWelcomeMessage() {
|
||||||
final welcomeMessage = {
|
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:
|
**O que posso fazer por ti:**
|
||||||
📚 Respostas baseadas em conteúdo educacional
|
📚 Responder com base no material do teu professor
|
||||||
🔍 Busca vetorial semântica
|
🔍 Usar os PDFs e documentos disponibilizados
|
||||||
🤖 Integração com Ollama API
|
<EFBFBD> Explicar conceitos de forma clara e organizada
|
||||||
📖 Citações de fontes relevantes
|
🎯 Adaptar-me ao método de ensino do teu professor
|
||||||
🎯 Modo de aprendizado adaptativo
|
|
||||||
|
|
||||||
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,
|
'isUser': false,
|
||||||
'timestamp': DateTime.now(),
|
'timestamp': DateTime.now(),
|
||||||
};
|
};
|
||||||
@@ -419,53 +444,28 @@ Faça sua pergunta sobre qualquer assunto educacional!''',
|
|||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Direct call to Ollama API based on working example
|
// Use RAGAIService with memory, PDFs, and O GOAT identity
|
||||||
print('Processing query: $userMessage');
|
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
|
final preview = replyText.length > 50
|
||||||
.post(
|
? replyText.substring(0, 50)
|
||||||
url,
|
: replyText;
|
||||||
headers: {'Content-Type': 'application/json'},
|
Logger.info('Ollama response received: $preview...');
|
||||||
body: jsonEncode({
|
|
||||||
'model': 'qwen3-coder:30b',
|
|
||||||
'messages': [
|
|
||||||
{'role': 'user', 'content': userMessage},
|
|
||||||
],
|
|
||||||
'stream': false,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 60));
|
|
||||||
|
|
||||||
print('API response status: ${response.statusCode}');
|
setState(() {
|
||||||
print('API response body: ${response.body}');
|
_messages.add({
|
||||||
|
'content': replyText,
|
||||||
if (response.statusCode == 200) {
|
'isUser': false,
|
||||||
final data = jsonDecode(response.body);
|
'timestamp': DateTime.now(),
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
} else {
|
_isLoading = false;
|
||||||
throw Exception('Erro HTTP ${response.statusCode}');
|
});
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback to mock response if API fails
|
// Fallback to error message if API fails
|
||||||
print('Ollama API error: $e');
|
Logger.error('RAG AI Service error: $e');
|
||||||
print('Stack trace: ${StackTrace.current}');
|
final aiResponse = 'Desculpe, ocorreu um erro ao processar a pergunta. Tente novamente.';
|
||||||
final aiResponse = _generateMockResponse(userMessage);
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add({
|
_messages.add({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import '../../../../core/services/rag_service.dart';
|
import '../../../../core/services/rag_service.dart';
|
||||||
|
|
||||||
/// Widget for displaying chat messages with source citations
|
/// Widget for displaying chat messages with source citations
|
||||||
@@ -139,15 +140,43 @@ class MessageBubble extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
isUser
|
||||||
content,
|
? Text(
|
||||||
style: TextStyle(
|
content,
|
||||||
color: isUser ? Colors.white : const Color(0xFF2D3748),
|
style: TextStyle(
|
||||||
fontSize: 16,
|
color: Colors.white,
|
||||||
height: 1.4,
|
fontSize: 16,
|
||||||
fontWeight: isUser ? FontWeight.w500 : FontWeight.normal,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
import '../../../../features/materials/presentation/pages/teacher_materials_page.dart';
|
||||||
|
|
||||||
/// Quick access cards for teacher actions
|
/// Quick access cards for teacher actions
|
||||||
class TeacherQuickActionsWidget extends StatefulWidget {
|
class TeacherQuickActionsWidget extends StatefulWidget {
|
||||||
@@ -90,7 +91,12 @@ class _TeacherQuickActionsWidgetState extends State<TeacherQuickActionsWidget> {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () => context.go('/teacher/upload'),
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const TeacherMaterialsPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -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<TeacherMaterialsPage> createState() => _TeacherMaterialsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherMaterialsPageState extends State<TeacherMaterialsPage> {
|
||||||
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
final FirebaseStorage _storage = FirebaseStorage.instanceFor(
|
||||||
|
bucket: 'teachit-app.firebasestorage.app',
|
||||||
|
);
|
||||||
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
|
bool _isUploading = false;
|
||||||
|
|
||||||
|
Stream<QuerySnapshot> _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<QuerySnapshot>(
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<void> _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<void> _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<void> _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<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
pubspec.lock
48
pubspec.lock
@@ -345,6 +345,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -369,6 +393,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
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:
|
file_selector_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -568,6 +600,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1044,6 +1084,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "2.7.0"
|
||||||
|
markdown:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: markdown
|
||||||
|
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.3.1"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ dependencies:
|
|||||||
|
|
||||||
# File Handling
|
# File Handling
|
||||||
# file_picker: ^6.1.1 # Temporarily disabled due to compatibility issues
|
# file_picker: ^6.1.1 # Temporarily disabled due to compatibility issues
|
||||||
|
file_selector: ^1.0.3
|
||||||
image_picker: ^1.0.4
|
image_picker: ^1.0.4
|
||||||
|
|
||||||
|
# Markdown rendering
|
||||||
|
flutter_markdown: ^0.6.23
|
||||||
permission_handler: ^11.0.1
|
permission_handler: ^11.0.1
|
||||||
path: ^1.8.3
|
path: ^1.8.3
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user