Compare commits

...

49 Commits

Author SHA1 Message Date
34d7ae8afc últimas mudanças 2026-06-11 23:59:58 +01:00
3533d3436b Merge branch 'main' of https://gitea.epvc.pt/240405/TeachIT 2026-05-28 22:44:18 +01:00
0b5bf8fba7 Correções 2026-05-28 22:44:14 +01:00
3d3747d3a2 .md 2026-05-25 21:41:41 +01:00
c2fd663170 Teacher dashboard updates 2026-05-24 20:09:22 +01:00
1a98fff5e8 regularização do tamanho dos widgets 2026-05-24 17:46:58 +01:00
43018c753c mudar nome no prefil 2026-05-24 17:39:10 +01:00
90837cc82b transferencia nos professores 2026-05-24 17:29:06 +01:00
39637b2a62 mudanças nos nomes dos widgets 2026-05-24 17:23:17 +01:00
b3f6a5a0f0 quizzes e teacher dashboard, mudanças 2026-05-24 17:13:05 +01:00
f1a094979f aba de turmas dos professores melhorada com tudo oque era suposto 2026-05-24 16:48:18 +01:00
895ce64c6f Melhoria no funcionamento do histórico, Os nomes no dashboard do aluno carregam a primeira vez e ficam salvos para n ficarem sempre a carregar quando se volta ao dashboard, removi o butão de novo chat na interface de introdução da IA, mudei a aparencia dessa introdução e do histórico 2026-05-23 16:20:27 +01:00
7ee262f4c7 Melhorias no comportamento do chat com IA;
Adição do histórico de conversas com IA.
2026-05-21 23:20:18 +01:00
5bda59f7af historico 2026-05-21 11:49:56 +01:00
2f411d08a4 Coisas 2026-05-21 11:39:30 +01:00
98dcd621c7 Historico de quizzes e inicio de atualização da IA para leitura de pdfs de matemática (incompleto) 2026-05-20 01:32:37 +01:00
80ed2b1346 Visualização de conteudo 2026-05-19 21:54:37 +01:00
54d7042b94 Reformulação de materias uploaded 2026-05-19 19:44:30 +01:00
8043ee42fe Dark mode em upload pdf 2026-05-18 23:05:33 +01:00
7f12f3eb1f Finalização de detalhes e pequenas adições em dashboards de alunos e professores 2026-05-18 22:48:27 +01:00
c0ade9ef76 Modificações no chatbot (dashboard aluno) e analytics (dashboard stor) 2026-05-18 20:43:13 +01:00
9b53eb06b6 Quiz e tutor chat modificações 2026-05-18 14:27:30 +01:00
ad825f47d7 Pequenas adições em teacher dashboard 2026-05-17 23:34:21 +01:00
4a5209b239 Histórico a funcionar 2026-05-17 23:24:27 +01:00
058bbaaea2 FINALMENTE ACABOU PAPAPAAAAA 2026-05-17 23:01:22 +01:00
c979692fd9 Mudanças na aba de quiz 2026-05-17 22:21:23 +01:00
2a2194699b ultimas resoluções 2026-05-17 20:07:42 +01:00
e388ca3b67 Muitas coisas e já me esqueci delas todas, cenas principalmente no dashboard do aluno bug fixes e etc 2026-05-17 19:42:49 +01:00
7a26223a01 VERSAO FINAL (core features) 2026-05-17 18:27:22 +01:00
ba58228467 todos os placholders da tela de alunos resolvidos 2026-05-17 17:49:23 +01:00
49a7a6fe02 placeholders removidos e todos os dados reais colocados, com conquistas e tudo 2026-05-17 17:29:47 +01:00
6ba5c837ce cena dos quizzes resolvidos 2026-05-17 15:04:19 +01:00
5649f7d96a Alteração do acesso às settings, mudança visual na interface de entrar numa turma 2026-05-17 14:34:19 +01:00
51ea446ae9 Modificação nos modos, correção de textos cortados 2026-05-17 14:02:42 +01:00
14509c04d3 mais correções e implementações nos quizzes 2026-05-16 21:17:55 +01:00
27263e86ba Quizzes professores 2026-05-16 20:56:14 +01:00
3463b1f6cc QUIZZES FEITOS POHA 2026-05-16 20:19:23 +01:00
728368b040 melhoramento do fall back da ia 2026-05-16 17:41:29 +01:00
321df8bb1d FUCKASS IA 2026-05-16 17:31:06 +01:00
f8e3a7686f Escolher permissao no upload de conteudo 2026-05-16 15:03:44 +01:00
47aaa163fb Botao de remover materiais 2026-05-16 14:52:53 +01:00
ba4bb7de88 correções nas turmas e disciplinas 2026-05-16 14:37:27 +01:00
2775205f9e correção de bugs, creação propria para turmas, e preparação para criar quizzes 2026-05-15 12:40:38 +01:00
62b9a107bc - Dark / light mode a funcionar no lado do aluno
- Atualização dos ficheiros markdown.
2026-05-14 22:07:03 +01:00
55ec2521cf tudo sobre a memoria da ia, formatação, memória e conhecimento de pdfs, junto da inserção de pdfs 2026-05-14 00:13:29 +01:00
ad400a9c37 tela de verificação de turma, possibilidade de alunos entrarem em turmas 2026-05-12 18:59:22 +01:00
b7988eb608 Settings, correção de light/darkmode do dispositivo (e adição da escolha entre modos nas settings) e correção do tipo de letra no textfield do chatbot 2026-05-11 21:47:15 +01:00
9faab9b74e Merge branch 'main' of https://gitea.epvc.pt/240405/TeachIT 2026-05-10 18:47:00 +01:00
8e88ad4586 gerenciamento e criação de turmas junto de correções na tela de professores 2026-05-10 18:23:32 +01:00
136 changed files with 26312 additions and 3594 deletions

View File

@@ -1,4 +1,4 @@
# teachit
# Learn It
A new Flutter project.

View File

@@ -1,8 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="teachit"
android:label="Learn It"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,5 +1,34 @@
package com.example.teachit
import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.core.view.WindowCompat
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun onResume() {
super.onResume()
hideSystemBars()
}
private fun hideSystemBars() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
controller?.let {
it.hide(WindowInsets.Type.systemBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F9EEE8</color>
</resources>

BIN
assets/images/epvc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

@@ -1,6 +1,8 @@
# Architecture Overview - AI Study Assistant
## 🏗️ COMPLETE SYSTEM ARCHITECTURE
## 🏗️ ACTUAL SYSTEM ARCHITECTURE
> ⚠️ **Nota importante**: Esta documentação reflete a arquitetura REAL implementada no código. O projeto é uma aplicação Flutter que comunica diretamente com Firebase e Ollama, sem backend Node.js ou Python intermediário.
---
@@ -31,46 +33,45 @@ This document provides a comprehensive overview of the AI Study Assistant system
## 🏛️ HIGH-LEVEL ARCHITECTURE
### System Overview
### System Overview (Real Implementation)
```
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
├─────────────────┬─────────────────┬─────────────────────────────┤
│ Flutter App │ Web App │ Admin Dashboard
│ (Mobile/Web) │ (PWA) │ (Management)
│ Flutter App │ Web App │ (Same codebase)
│ (Mobile/Web) │ (Flutter Web) │
└─────────────────┴─────────────────┴─────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
API GATEWAY
│ • Authentication • Rate Limiting • Load Balancing │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
FLUTTER SERVICES
├─────────────┬─────────────┬─────────────┬───────────────────────┤
│ Auth │ Tutor │ Quiz │ Analytics
│ Auth │ Tutor │ Quiz │ Gamification
│ Service │ Service │ Service │ Service │
├─────────────┼─────────────┼─────────────┼───────────────────────┤
│ RAG │ Chat │ Content │ Vector │
│ Service │ Memory │ Service │ Service (Mock) │
└─────────────┴─────────────┴─────────────┴───────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
AI/ML LAYER
├─────────────┬─────────────┬────────────────────────────────────┤
RAG Embedding │ LLM Vector Store
Engine Service Service │ (FAISS)
└─────────────┴─────────────┴─────────────┴───────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
├─────────────┬─────────────┬─────────────┬───────────────────────┤
│ Firestore │ Storage │ Cache │ Search │
│ Database │ (Files) │ (Redis) │ (Elasticsearch) │
└─────────────┴─────────────┴─────────────┴───────────────────────┘
EXTERNAL SERVICES
├─────────────────────────┬────────────────────────────────────┤
Firebase Ollama LLM
• Auth │ • Model: qwen3-coder:30b
│ • Firestore │ • Endpoint: /api/chat │
│ • Storage │
• Analytics │
│ • Crashlytics │ │
└─────────────────────────┴─────────────────────────────────────┘
```
### Architecture Notes
- **No Backend Server**: The Flutter app communicates directly with Firebase and Ollama
- **RAG Implementation**: Keyword-based search with windowing, implemented in Dart
- **Embeddings**: Mock/simulated embeddings (384 dimensions) using text hashing
- **Vector Store**: Not FAISS - simple in-memory Firestore storage with cosine similarity
---
## 📱 FRONTEND ARCHITECTURE
@@ -187,7 +188,14 @@ class TutorRepositoryImpl implements TutorRepository {
## ⚡ BACKEND ARCHITECTURE
### Cloud Functions Architecture
> ⚠️ **Nota**: Não existe backend Node.js/Cloud Functions. A arquitetura descrita abaixo é para referência futura apenas.
### Actual Backend
The "backend" consists of:
1. **Firebase Services** (managed by Google)
2. **Ollama Instance** (self-hosted at 89.114.196.110:11434)
### Cloud Functions Architecture (NOT IMPLEMENTED)
```
┌─────────────────────────────────────────────────────────────────┐
│ API GATEWAY LAYER │
@@ -198,6 +206,8 @@ class TutorRepositoryImpl implements TutorRepository {
│ • CORS │ • Schema Valid │ • Per-Endpoint Limits │
│ • Logging │ • Sanitization │ • Global Limits │
│ • Error Handl │ • Type Check │ • Burst Protection │
Note: This layer does not exist in the current implementation.
└─────────────────┴─────────────────┴─────────────────────────────┘
@@ -274,32 +284,32 @@ export class TutorService {
---
## 🤖 AI/ML ARCHITECTURE
## 🤖 AI/ML ARCHITECTURE (DART IMPLEMENTATION)
### RAG Engine Architecture
### RAG Engine Architecture (Real Implementation)
```
┌─────────────────────────────────────────────────────────────────┐
│ INPUT PROCESSING
│ INPUT PROCESSING (Dart)
├─────────────────┬─────────────────┬─────────────────────────────┤
│ Text Input │ Content │ Query Processing │
│ Processing │ Processing │ │
│ │ │ │
│ • Tokenization │ • PDF Parsing │ • Query Embedding
│ • Cleaning │ • Text Extract │ • Vector Search │
│ • Normalization │ • Chunking │ • Similarity Calculation
│ • Validation │ • Metadata │ • Ranking
│ • Text Cleaning │ • PDF Parsing │ • Keyword Extraction
│ • LaTeX Filter │ • Text Extract │ • Window Search │
│ • Normalization │ • Chunking │ • Similarity Matching
│ • Validation │ • Cache (Hive) │ • Content Ranking
└─────────────────┴─────────────────┴─────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ VECTOR STORE
│ VECTOR STORE (Mock/Simple)
├─────────────────┬─────────────────┬─────────────────────────────┤
Indexing │ Storage │ Retrieval │
Embeddings │ Storage │ Retrieval │
│ │ │ │
│ • FAISS Index │ • Vector Data │ • Approximate Search
│ • HNSW Tree │ • Metadata │ • Exact Search
│ • IVF Clusters │ • Chunks │ • Hybrid Search
│ • Optimization │ • Updates │ • Filtering
│ • Hash-based │ • Firestore │ • Cosine Similarity
│ • 384 dims │ • contentChunks │ • Keyword Matching
│ • Deterministic │ • materials │ • Window-based Search │
│ • Fast/Cheap │ • No FAISS │ • No Approximate Search
└─────────────────┴─────────────────┴─────────────────────────────┘
@@ -309,107 +319,124 @@ export class TutorService {
│ Prompt │ Generation │ Post-Processing │
│ Engineering │ │ │
│ │ │ │
│ • Context Build │ • OpenAI API │ • Response Validation
│ • Template │ • Anthropic API│ • Safety Checks
│ • Formatting │ • Model Selection│ • Quality Assessment
│ • Safety │ • Rate Limit │ • Caching
│ • Context Embed │ • Ollama API │ • UTF-8 Encoding
│ • User Persona │ • qwen3-coder │ • Response Formatting
│ • PDF Content │ • 30B model │ • Citation Handling
│ • Constraints │ • Direct HTTP │ • Error Handling
└─────────────────┴─────────────────┴─────────────────────────────┘
```
### RAG Pipeline Implementation
```python
# RAG Engine Pipeline
class RAGPipeline:
def __init__(self):
self.embedding_service = EmbeddingService()
self.vector_store = VectorStore()
self.llm_service = LLMService()
self.prompt_builder = PromptBuilder()
async def process_query(self, query: str, mode: str = "EXPLANATION") -> str:
# Step 1: Process input
processed_query = self.preprocess_query(query)
# Step 2: Generate embedding
query_embedding = await self.embedding_service.encode([processed_query])
# Step 3: Retrieve relevant context
context_chunks = await self.vector_store.search(
query_embedding[0],
k=10,
filters=self.get_filters(mode)
)
# Step 4: Build prompt
prompt = self.prompt_builder.build(
query=processed_query,
context=context_chunks,
mode=mode
)
# Step 5: Generate response
response = await self.llm_service.generate(prompt)
# Step 6: Post-process
final_response = self.postprocess_response(response, context_chunks)
return final_response
### RAG Pipeline Implementation (Dart)
```dart
// Real implementation in lib/core/services/rag_ai_service.dart
class RAGAIService {
static const String _baseUrl = 'http://89.114.196.110:11434/api/chat';
static const String _model = 'qwen3-coder:30b';
static Future<String> askTutor({
required String question,
required List<String> materialIds,
required String mode,
}) async {
// Step 1: Retrieve context from materials (keyword-based)
final context = await MaterialsRAGService.getContextForQuestion(
question: question,
materialIds: materialIds,
);
def preprocess_query(self, query: str) -> str:
# Clean and normalize query
query = query.strip().lower()
# Remove special characters
query = re.sub(r'[^\w\s]', '', query)
# Tokenize and normalize
return query
// Step 2: Build prompt with embedded context
final prompt = _buildPrompt(
question: question,
context: context,
mode: mode,
);
def get_filters(self, mode: str) -> Dict[str, Any]:
# Mode-specific filtering
filters = {}
if mode == "EXPLANATION":
filters["content_type"] = ["explanation", "definition"]
elif mode == "TUTOR":
filters["difficulty"] = {"$lte": 0.7}
return filters
// Step 3: Call Ollama API directly
final response = await http.post(
Uri.parse(_baseUrl),
headers: {'Content-Type': 'application/json; charset=utf-8'},
body: utf8.encode(jsonEncode({
'model': _model,
'messages': [
{'role': 'system', 'content': _systemPrompt},
{'role': 'user', 'content': prompt},
],
})),
);
// Step 4: Process response
return _processResponse(response);
}
}
// MaterialsRAGService - keyword window search
class MaterialsRAGService {
static Future<String> getContextForQuestion({
required String question,
required List<String> materialIds,
}) async {
// PDF extraction with syncfusion_flutter_pdf
// Keyword matching with windowing (1200 chars)
// No FAISS, no embeddings, no vector search
}
}
```
### Key Differences from Original Design
-**No Python RAG Engine**: Implemented entirely in Dart
-**No FAISS**: Uses keyword matching and simple cosine similarity
-**No Sentence Transformers**: Hash-based mock embeddings (384 dims)
-**No OpenAI/Anthropic**: Only Ollama (qwen3-coder:30b)
-**PDF Processing**: syncfusion_flutter_pdf for text extraction
-**Caching**: Hive for PDF content caching
-**Firestore**: Stores contentChunks with simple vector data
---
## 🗄️ DATA ARCHITECTURE
### Database Schema
### Database Schema (Actual Implementation)
```
┌─────────────────────────────────────────────────────────────────┐
│ FIRESTORE DATABASE │
├─────────────────┬─────────────────┬─────────────────────────────┤
│ USERS │ CONTENT │ LEARNING
│ USERS │ MATERIALS │ CONTENTCHUNKS
│ │ │ │
│ • uid │ • id │ • studentId
│ • email │ • title │ • concept
│ • role │ • subject │ • mastery
│ • schoolId │ • concept │ • confidence
│ • profile │ • difficulty │ • lastInteraction
│ • preferences │ • grade │ • interactions
• createdAt │ • uploadedAt │ • recommendations
│ • lastActive │ • uploadedBy │ • spacedRepetition │
│ • uid │ • id │ • id
│ • email │ • teacherId │ • text
│ • role │ • fileName │ • subject
│ • schoolId │ • fileUrl │ • concept
│ • displayName │ • type │ • embedding (List<double>)
│ • createdAt │ • createdAt │ • metadata
│ • createdAt
└─────────────────┴─────────────────┴─────────────────────────────┘
┌─────────────────┬─────────────────┬─────────────────────────────┐
│ QUIZ INTERACTIONS SCHOOLS
│ │ │ │
│ • id │ • id │ • id │
│ • title │ • studentId │ • name
│ • subject │ • query │ • email
│ • concept │ • response │ • settings
│ • questions │ • mode │ • subscription
│ • timeLimit │ • timestamp │ • maxStudents
• passingScore │ • feedback │ • maxTeachers
│ • createdBy │ • metadata │ • isActive │
│ • createdAt │ • sources │ • createdAt │
│ QUIZZESCONVERSATIONS │ CLASSES/ENROLLMENTS
│ │ (userChats/*) │ │
│ • id │ │ • id │
│ • teacherId │ • id │ • teacherId/classId
│ • title │ • title │ • name/code
│ • description │ • selectedMaterials│ • studentId
│ • questions │ • createdAt │ • createdAt/joinedAt
│ • createdAt │ • hasUserMessage│
│ • messages/* │
└─────────────────┴─────────────────┴─────────────────────────────┘
```
### Collections NOT Implemented
-`learningStates` - Not in codebase
-`auditLogs` - Not implemented
-`quizAttempts` - Not implemented
-`interactions` - Replaced by userChats/{uid}/conversations
### Roles (Only 2 implemented)
-`student` - Can view content, take quizzes, ask tutor
-`teacher` - Can upload materials, create quizzes, view analytics
-`admin` - NOT IMPLEMENTED
-`super_admin` - NOT IMPLEMENTED
### Data Flow Architecture
```
┌─────────────────────────────────────────────────────────────────┐
@@ -524,88 +551,84 @@ class RAGPipeline:
---
## 🔧 TECHNOLOGY STACK
## 🔧 TECHNOLOGY STACK (ACTUAL)
### Frontend Technologies
```yaml
Flutter Framework:
- SDK: 3.41.0+
- SDK: ^3.11.5 (Dart 3.0+)
- Language: Dart 3.0+
- State Management: Riverpod 2.4.9
- Navigation: GoRouter 12.1.3
- UI: Material Design 3
- Testing: Flutter Test, Integration Test
- Testing: Flutter Test, Integration Test (not implemented)
Firebase Services:
- Authentication: Firebase Auth
- Database: Cloud Firestore
- Storage: Firebase Storage
- Analytics: Firebase Analytics
- Crashlytics: Firebase Crashlytics
- Performance: Firebase Performance
- Authentication: firebase_auth ^4.17.8
- Database: cloud_firestore ^4.15.8
- Storage: firebase_storage ^11.6.9
- Analytics: firebase_analytics ^10.8.0
- Crashlytics: firebase_crashlytics ^3.5.7
- Messaging: firebase_messaging ^14.9.3
- Performance: NOT IMPLEMENTED
- Remote Config: NOT IMPLEMENTED
Third-Party Libraries:
- HTTP: Dio 5.4.0
- Caching: Cached Network Image 3.3.0
- Fonts: Google Fonts 6.1.0
- Animations: Flutter Animate 4.2.0
- Local Storage: Hive 2.2.3
- HTTP: Dio ^5.4.0, http ^1.1.2
- PDF Processing: syncfusion_flutter_pdf ^33.2.6
- Caching: cached_network_image ^3.3.0, hive ^2.2.3
- Fonts: google_fonts ^6.1.0
- Animations: flutter_animate ^4.2.0, lottie ^2.7.0
- Charts: fl_chart ^0.64.0
- File Handling: file_selector ^1.0.3, image_picker ^1.0.4
- Utilities: intl ^0.20.2, uuid ^4.2.1, equatable ^2.0.5
```
### Backend Technologies
### Backend Technologies (NOT IMPLEMENTED)
```yaml
Runtime Environment:
- Platform: Firebase Cloud Functions
- Runtime: Node.js 18.x LTS
- Language: TypeScript 5.0+
- Package Manager: npm 9.x
Status: NO BACKEND SERVER
Core Services:
- Authentication: Firebase Admin SDK
- Database: Firestore Admin SDK
- Storage: Cloud Storage Admin SDK
- HTTP Framework: Express.js 4.18+
- Validation: Joi 17.9+
- Security: Helmet 7.0+
The following technologies are documented but NOT implemented:
❌ Firebase Cloud Functions - Not used
❌ Node.js / TypeScript - Not used
❌ Python RAG Engine - Not used
❌ FAISS Vector Database - Not used
❌ Sentence Transformers - Not used
❌ OpenAI API - Not used
❌ Anthropic Claude - Not used
AI/ML Services:
- Vector Database: FAISS 1.7.4
- Embeddings: Sentence Transformers 2.2.2
- LLM APIs: OpenAI 4.20.1, Anthropic 0.6.3
- Processing: NumPy 1.21+, PyTorch 1.12+
Monitoring & Logging:
- Logging: Winston 3.8+
- Metrics: Prometheus Client
- Tracing: OpenTelemetry
- Error Tracking: Sentry
Actual Implementation:
✅ Flutter app calls Ollama directly via HTTP
✅ Firebase services handle auth, database, storage
✅ RAG logic implemented in Dart (keyword matching)
✅ Embeddings: Mock/hash-based in Dart
```
### Infrastructure Technologies
```yaml
Cloud Platform:
- Provider: Google Cloud Platform
- Services: Firebase, Cloud Functions, Cloud Storage
- Regions: us-central1, europe-west1
- CDN: Firebase Hosting
- Provider: Google Firebase (BaaS)
- Services: Firebase Auth, Firestore, Storage
- Hosting: Firebase Hosting (for web builds)
- Self-hosted: Ollama LLM server (89.114.196.110:11434)
Database:
- Primary: Cloud Firestore
- Cache: Redis (MemoryStore)
- Search: Elasticsearch (if needed)
- Backup: Automated daily backups
- Primary: Cloud Firestore (NoSQL)
- Cache: Hive (local), Memory cache
- Search: Not implemented (no Elasticsearch)
- Backup: Firebase automated backups
Security:
- TLS: 1.3
- Authentication: Firebase Auth
- Authorization: Custom RBAC
- Monitoring: Security Command Center
- TLS: HTTPS for all communications
- Authentication: Firebase Auth (email/password, Google)
- Authorization: Client-side role checks (student/teacher)
- Note: No admin role implemented
CI/CD:
- Pipeline: GitHub Actions
- Build: Cloud Build
- Deploy: Firebase CLI
- Testing: Automated test suites
- Pipeline: Manual builds
- Build: flutter build web/apk
- Deploy: Firebase CLI (manual)
- Testing: No automated tests implemented
```
---

View File

@@ -1,71 +1,47 @@
# Backend MVP Tasks - AI Study Assistant
## 🔧 MVP BACKEND ROADMAP (8-12 WEEKS)
> ⚠️ **IMPORTANTE**: Este documento foi atualizado para refletir a arquitetura REAL do projeto.
>
> **NÃO EXISTE BACKEND NODE.JS/TYPESCRIPT**. O projeto utiliza apenas:
> - Firebase Services (Auth, Firestore, Storage) - BaaS
> - Ollama LLM auto-hospedado
> - Lógica de negócio implementada em Dart no Flutter
---
## 🏗️ WEEK 1-2: FIREBASE FOUNDATION
### Task 1.1: Firebase Project Setup
**Priority**: Critical
**Estimated Time**: 6 hours
**Dependencies**: None
**Status**: ✅ COMPLETED
#### Subtasks:
- [ ] Create Firebase project in Google Cloud Console
- [ ] Enable required Firebase services:
- [ ] Firebase Authentication
- [ ] Cloud Firestore
- [ ] Cloud Storage
- [ ] Cloud Functions
- [ ] Firebase Analytics
- [ ] Configure project settings
- [ ] Set up billing account (if needed)
- [ ] Enable API access for LLM services
- Create Firebase project in Google Cloud Console
- Enable required Firebase services:
- Firebase Authentication
- Cloud Firestore
- Cloud Storage
- Cloud Functions - NOT IMPLEMENTED (not needed)
- Firebase Analytics
- Configure project settings
- ✅ Enable Ollama API access (self-hosted)
#### Detailed Steps:
#### Actual Configuration:
1. **Create Firebase Project**
```bash
# Using Firebase CLI
firebase projects create teachit-ai-assistant
firebase use teachit-ai-assistant
**pubspec.yaml dependencies:**
```yaml
dependencies:
firebase_core: ^2.25.4
firebase_auth: ^4.17.8
cloud_firestore: ^4.15.8
firebase_storage: ^11.6.9
firebase_analytics: ^10.8.0
firebase_crashlytics: ^3.5.7
```
2. **Enable Services**
```bash
# Enable Authentication
firebase auth --enable
# Enable Firestore
firebase firestore:databases:create
# Enable Storage
firebase storage:buckets:create teachit-content
# Enable Functions
firebase functions:config:set
```
3. **Project Configuration**
```json
// firebase.json
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"storage": {
"rules": "storage.rules"
},
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
],
"source": "functions"
}
}
**Ollama Configuration (in rag_ai_service.dart):**
```dart
static const String _baseUrl = 'http://89.114.196.110:11434/api/chat';
static const String _model = 'qwen3-coder:30b';
```
---

View File

@@ -6,6 +6,170 @@
## [Unreleased]
### Added
- **Comprehensive Quiz System** - quiz_list_page.dart, teacher_quiz_page.dart, quiz_page.dart
- AI-powered quiz generation from uploaded materials
- Multiple choice question type implementation
- Scoring system with immediate feedback
- Progress tracking and results display
- Quiz history and retry functionality
- Teacher quiz management interface
- Student quiz taking interface
- Quiz categories by material and class
- Integration with gamification service
- **Analytics System** - analytics_page.dart, class_analytics_card.dart, class_ranking_widget.dart
- Teacher analytics dashboard with class breakdowns
- Student rankings and performance metrics
- Class statistics and progress tracking
- Achievement system integration
- Gamification service implementation
- Create achievement dialog for teachers
- **Smart Back Navigation in Chat History** - chat_history_page.dart & tutor_chat_page_simple.dart
- Intelligent back navigation based on entry point (chat vs intro)
- PopScope for Android back gesture handling
- Source parameter (chat/intro) and conversationId in URL
- **Material Names Display in Chat History** - chat_history_page.dart
- Replaced raw material IDs with readable file names
- Added _materialNamesCache for ID-to-name mappings
- Material names truncated to 20 characters if too long
- **Filter Conversations with User Messages Only** - chat_memory_service.dart
- Added hasUserMessage field to conversation documents
- Prevents empty conversations from appearing in history
- **Dashboard Data Caching** - progress_hero_widget.dart & student_dashboard_page.dart
- Caching for user stats to prevent flickering on navigation
- Caching for user name to prevent flickering on navigation
- Shows cached data while loading new data in background
- **Profile Edit Integration** - profile_edit_page.dart
- Calls StudentDashboardPage.clearCachedUserName() when profile is updated
- **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)`
- For each enrollment, fetches corresponding class document from `classes` collection using `classId`
- Layout: Same horizontal 2-row scroll pattern as `TeacherClassesListWidget`
- Cards display: Class name + Class code
- Loading state: `CircularProgressIndicator` (centered in card while loading class data)
- Empty state: Text "Ainda não entraste em nenhuma turma."
- Widget inserted in `StudentDashboardPage` after QuickAccessWidget
- Visual design: White cards (#FFFFFF), teal icon background (#82C9BD with 10% opacity), rounded corners (16px), subtle shadows
- Title: "As Minhas Turmas" (same style as teacher dashboard)
- System now bidirectional: Teachers see students, Students see classes
- **Join Class Feature (ETAPA 4)** - Students can now join classes using class codes
- New `JoinClassPage` screen at `/lib/features/classes/presentation/pages/join_class_page.dart`
- TextField for entering 6-character class code (uppercase, centered, letter-spacing)
- "Entrar na Turma" button with loading state and visual feedback
- Firestore query: `.collection('classes').where('code', isEqualTo: enteredCode).limit(1)`
- Validation: checks if code exists, if student already enrolled
- On success: creates document in `enrollments` collection with `classId`, `studentId`, `studentName`, `joinedAt`
- Success feedback: green SnackBar "Entraste na turma com sucesso!"
- Error feedback: red SnackBar for invalid code, duplicate enrollment, or auth errors
- Auto-returns to student home after successful join
- Visual design: teal AppBar (#82C9BD), centered icon, clean input field with rounded corners
- New "Entrar numa Turma" card in Student Dashboard Quick Access section
- Card design: horizontal layout with `Icons.group_add`, white background, rounded corners
- **Class Students View (ETAPA 3)** - Teachers can now view enrolled students in each class
- New `ClassStudentsPage` screen at `/lib/features/classes/presentation/pages/class_students_page.dart`
- StreamBuilder query on `enrollments` collection with filter by `classId`
- ListTile layout showing student name and join date
- Loading state with `CircularProgressIndicator`
- Empty state message when no students enrolled
- Date formatting using `intl` package (dd/MM/yyyy format)
- Consistent styling with existing app design (teal colors, rounded cards)
- Navigation via `MaterialPageRoute` from class card tap
- **Class Creation Feature (ETAPA 1)** - Teachers can now create classes from the dashboard
- New "Criar Turma" button in Teacher Dashboard Quick Actions
- Simple dialog interface for entering class name
- Automatic generation of 6-character unique class codes (A-Z, 0-9)
- Firestore integration saving class data (name, teacherId, code, timestamp)
- Visual feedback with loading indicator and success/error messages
- **Classes List Display (ETAPA 2)** - Teachers can now view their created classes
- New "As Minhas Turmas" section in Teacher Dashboard
- Real-time StreamBuilder to fetch classes from Firestore
- **CORREÇÃO**: O erro anterior foi tentar usar `GridView` horizontal para um layout que exige colunas fixas
- **SOLUÇÃO**: Usar `ListView.builder` com `scrollDirection: Axis.horizontal`
- Cada item do ListView é uma `Column` contendo 2 cards (índice * 2 e índice * 2 + 1)
- Cards mantêm exatamente o mesmo tamanho e estilo da lista vertical original
- Layout: Card 1, 3, 5... (top row) | Card 2, 4, 6... (bottom row)
- Scroll horizontal para visualizar todas as turmas
- Visual cards showing class name and access code
- Empty state message when no classes exist
- Loading state with CircularProgressIndicator
### Fixed
- **Settings Profile Card UI** - Fixed white background and duplicate email display
- Background now uses Theme.of(context).colorScheme.surface instead of hardcoded white
- User info displays displayName (bold, larger) on top, email (smaller) below
- File: lib/features/settings/presentation/pages/profile_edit_page.dart
- **Signup Page Input Field Theming** - Fixed dark backgrounds in light mode
- Input fields now use conditional background based on theme brightness
- Light mode: surface color, Dark mode: surfaceContainerHighest
- Applied to name, email, and password fields
- File: lib/features/auth/presentation/pages/signup_page.dart
- **Dashboard Progress Container Theming** - Fixed dark background in light mode
- "Ótimo progresso!" container now uses conditional background
- Light mode: surface color, Dark mode: surfaceContainerHighest
- File: lib/features/dashboard/presentation/widgets/profile_section_widget.dart
- **Authentication Navigation** - Enhanced back button behavior
- Removed AppBar from login and signup pages
- Added PopScope for Android back button navigation to role-selection
- Added custom positioned back button with aesthetic design
- Positioned at top: 50 to avoid notification bar overlap
- Files: lib/features/auth/presentation/pages/login_page.dart, signup_page.dart
- **Unified Quick Action Cards Text Style**
- "Upload Conteúdo" and "Criar Quiz" cards now match "Criar Turma" text alignment
- All cards use `Column` with `crossAxisAlignment: CrossAxisAlignment.start` for text section
- Subtitle text supports 2 lines with `maxLines: 2` and `height: 1.2`
- Consistent typography: title fontSize 16, subtitle fontSize 12
- "Criar Quiz" subtitle changed to "Avaliações interativas"
- **Pixel Overflow in Classes List Widget**
- **Causa**: Tentativa de usar `GridView` horizontal para layout de colunas fixas
- **Solução**: Substituir por `ListView.builder` com `scrollDirection: Axis.horizontal`
- Cada item do ListView é uma `Column` com 2 cards (índice * 2 e índice * 2 + 1)
- Cards mantêm tamanho original da lista vertical (sem constraints artificiais)
- Altura do SizedBox: 280 pixels (suficiente para 2 cards + spacing)
- **Pixel Overflow in Teacher Dashboard Cards**
- Replaced fixed height constraints with flexible `BoxConstraints(minHeight: 135, maxHeight: 160)`
- Fixed overflow issues in "Upload Conteúdo" and "Criar Turma" cards
- Cards now properly adapt to different screen sizes and content
### Planned Features
- Voice interaction capabilities
- Advanced analytics dashboard

View File

@@ -1,12 +1,16 @@
# Contributing Guidelines - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL (Flutter + Firebase apenas).
>
> **Nota:** Não existe backend Node.js nem Python RAG engine. Toda a lógica está no Flutter.
## 🤝 HOW TO CONTRIBUTE
---
## 📋 OVERVIEW
Thank you for your interest in contributing to the AI Study Assistant! This guide provides comprehensive information on how to contribute to our project, including development workflows, coding standards, and community guidelines.
Thank you for your interest in contributing to the AI Study Assistant! This guide provides information on how to contribute to our Flutter + Firebase project.
---
@@ -14,11 +18,10 @@ Thank you for your interest in contributing to the AI Study Assistant! This guid
### Prerequisites
- **Git**: Version 2.30 or higher
- **Flutter**: Version 3.41.0+
- **Node.js**: Version 18.x LTS
- **Python**: Version 3.9+ (for RAG engine)
- **Flutter**: Version 3.11.5+ (with Dart 3.0+)
- **Firebase CLI**: Latest version
- **Code Editor**: VS Code recommended
- **Note**: No Node.js or Python required (backend is Firebase BaaS)
### Development Environment Setup
1. Fork the repository
@@ -32,19 +35,15 @@ Thank you for your interest in contributing to the AI Study Assistant! This guid
git clone https://github.com/YOUR_USERNAME/teachit.git
cd teachit
# Install Flutter dependencies
# Install Flutter dependencies only
flutter pub get
# Install Node.js dependencies
cd functions && npm install && cd ..
# Install Python dependencies
cd rag_engine && pip install -r requirements.txt && cd ..
# Note: No backend dependencies to install
# No Cloud Functions (Firebase BaaS is used)
# No Python RAG engine (implemented in Dart)
# Run tests
flutter test
npm test
pytest tests/
```
---

View File

@@ -1,12 +1,18 @@
# Deployment Guide - AI Study Assistant
## 🚀 COMPLETE DEPLOYMENT STRATEGY
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL do projeto (Flutter + Firebase BaaS, sem backend Node.js).
>
> **O que mudou:**
> - ❌ Removido: Node.js dependencies, Cloud Functions, backend server setup
> - ✅ Atualizado: Flutter SDK ^3.11.5, Firebase BaaS only, Ollama LLM
## 🚀 DEPLOYMENT STRATEGY
---
## 📋 OVERVIEW
This comprehensive guide covers the complete deployment process for the AI Study Assistant project, including development, staging, and production environments, CI/CD pipelines, monitoring, and maintenance procedures.
This guide covers the deployment process for the AI Study Assistant project. **Note:** This is a Flutter + Firebase BaaS (Backend-as-a-Service) architecture. There is no custom backend server - all business logic is in the Flutter app.
---
@@ -54,29 +60,25 @@ cd teachit
# Install Flutter dependencies
flutter pub get
# Install Node.js dependencies (for functions)
cd functions
npm install
# Note: No Node.js backend to install
# No Cloud Functions to set up
# Start Firebase emulators
firebase emulators:start
# Run Flutter app
# Run Flutter app (Firebase services connect directly)
flutter run
```
#### Development Firebase Project:
- **Project ID**: `teachit-dev-12345`
- **Services**: All services enabled in test mode
- **Services**: Auth, Firestore, Storage, Analytics, Crashlytics
- **Security Rules**: Relaxed for development
- **Emulators**: Local Firestore, Auth, Storage, Functions
- **Emulators**: Local Firestore, Auth, Storage (Functions not used)
#### Environment Variables:
```bash
# .env.development
FIREBASE_PROJECT_ID=teachit-dev-12345
FLUTTER_ENV=development
API_BASE_URL=http://localhost:5001
OLLAMA_BASE_URL=http://89.114.196.110:11434/api/chat
ENABLE_LOGGING=true
ENABLE_DEBUG=true
```

View File

@@ -1,12 +1,16 @@
# Development Setup Guide - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL.
>
> **Nota:** NÃO é necessário Node.js nem Python. O projeto usa Flutter + Firebase BaaS apenas.
## 🛠️ COMPLETE DEVELOPMENT ENVIRONMENT SETUP
---
## 📋 OVERVIEW
This guide provides step-by-step instructions for setting up a complete development environment for the AI Study Assistant project, including Flutter frontend, Node.js backend, Firebase services, and development tools.
This guide provides step-by-step instructions for setting up the development environment for the AI Study Assistant project. **Note:** This is a Flutter + Firebase BaaS (Backend-as-a-Service) project. There is no custom Node.js backend or Python RAG engine to set up.
---

View File

@@ -1,12 +1,21 @@
# Firebase Configuration - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a configuração REAL do projeto.
>
> **Notas Importantes:**
> - ❌ Cloud Functions: NÃO implementado
> - ❌ Performance Monitoring: NÃO implementado
> - ❌ Remote Config: NÃO implementado
> - ❌ Roles admin/super_admin: NÃO implementados (apenas student/teacher)
> - ✅ Estrutura real: users, materials, contentChunks, quizzes, classes, enrollments, userChats
## 🔥 COMPLETE FIREBASE SETUP GUIDE
---
## 📋 OVERVIEW
This document provides comprehensive instructions for setting up Firebase for the AI Study Assistant project, including authentication, database, storage, cloud functions, and security configurations.
This document provides instructions for setting up Firebase for the AI Study Assistant project. Note: This is a **Flutter + Firebase BaaS** architecture without a custom backend.
---
@@ -32,15 +41,18 @@ This document provides comprehensive instructions for setting up Firebase for th
### 1.2 Enable Required Services
#### Firebase Services to Enable:
#### Firebase Services Actually Used:
- ✅ Firebase Authentication
- ✅ Cloud Firestore
- ✅ Cloud Storage
- ✅ Cloud Functions
- ✅ Firebase Analytics
- ✅ Firebase Crashlytics
- ✅ Firebase Performance Monitoring
- ✅ Firebase Remote Config
- ✅ Firebase Cloud Messaging
#### Firebase Services NOT Used:
- ❌ Cloud Functions (not needed - logic in Flutter)
- ❌ Firebase Performance Monitoring (not implemented)
- ❌ Firebase Remote Config (not implemented)
#### Enable Commands:
```bash
@@ -53,10 +65,9 @@ firebase firestore:databases:create
# Enable Storage
firebase storage:buckets:create teachit-content
# Enable Functions
firebase functions:config:set
# Enable Analytics (automatically enabled)
# Note: Cloud Functions not needed
```
---
@@ -105,17 +116,23 @@ firebase functions:config:set
### 2.2 User Management Configuration
#### Custom Claims Setup:
```javascript
// Cloud Function to set custom claims
exports.setCustomClaims = functions.auth.user().onCreate(async (user) => {
const customClaims = {
role: 'student', // Default role
schoolId: 'default',
permissions: ['basic_access']
};
await admin.auth().setCustomUserClaims(user.uid, customClaims);
#### User Roles (Only 2 implemented):
```dart
// lib/core/services/auth_service.dart
class AuthService {
static const String studentRole = 'student';
static const String teacherRole = 'teacher';
// ❌ adminRole - NOT IMPLEMENTED
// ❌ superAdminRole - NOT IMPLEMENTED
}
```
**Note:** Role assignment is done client-side during signup:
```dart
await FirebaseFirestore.instance.collection('users').doc(user.uid).set({
'role': selectedRole, // 'student' or 'teacher'
'email': user.email,
'createdAt': FieldValue.serverTimestamp(),
});
```
@@ -131,17 +148,25 @@ exports.setCustomClaims = functions.auth.user().onCreate(async (user) => {
3. Choose location (e.g., `europe-west1`)
4. Set up security rules
#### Database Structure:
#### Actual Database Structure:
```javascript
// Collections Structure
schools/{schoolId}
├── users/{userId}
├── learningStates/{studentId}
├── contentChunks/{chunkId}
├── quizzes/{quizId}
├── quizAttempts/{attemptId}
├── interactions/{interactionId}
└── auditLogs/{logId}
// Collections Structure (as implemented in code)
// Top-level collections:
users/{userId} // User profiles with role
schools/{schoolId} // Schools/institutions
materials/{materialId} // Teacher uploaded content
contentChunks/{chunkId} // Text chunks with mock embeddings
quizzes/{quizId} // Quiz definitions
classes/{classId} // Teacher-created classes
enrollments/{enrollmentId} // Student class memberships
userChats/{userId}/conversations/{convId}/messages/{msgId} // Chat history
// Collections NOT implemented:
// ❌ learningStates/{studentId} - Not in codebase
// ❌ quizAttempts/{attemptId} - Not implemented
// ❌ interactions/{interactionId} - Replaced by userChats subcollection
// ❌ auditLogs/{logId} - Not implemented
```
### 3.2 Security Rules

View File

@@ -13,7 +13,7 @@ This document outlines the complete Flutter project structure for the AI Study A
## 📁 ROOT DIRECTORY STRUCTURE
```
teachit/
learn_it/
├── android/ # Android-specific files
├── ios/ # iOS-specific files
├── web/ # Web-specific files
@@ -381,7 +381,7 @@ lib/
### pubspec.yaml
```yaml
name: teachit
name: learn_it
description: AI Study Assistant - Educational Intelligence Platform
version: 1.0.0+1
@@ -551,7 +551,7 @@ linter:
**android/app/build.gradle**
```gradle
android {
namespace 'com.example.teachit'
namespace 'com.example.learnit'
compileSdkVersion flutter.compileSdkVersion
compileOptions {
@@ -564,7 +564,7 @@ android {
}
defaultConfig {
applicationId "com.example.teachit"
applicationId "com.example.learnit"
minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
@@ -596,9 +596,9 @@ dependencies {
**ios/Runner/Info.plist**
```xml
<key>CFBundleDisplayName</key>
<string>TeachIt</string>
<string>Learn It</string>
<key>CFBundleIdentifier</key>
<string>com.example.teachit</string>
<string>com.example.learnit</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>UISupportedInterfaceOrientations</key>
@@ -1063,7 +1063,7 @@ class Environment {
static const bool isProductionMode = kReleaseMode;
static const String apiBaseUrl = isDebugMode
? 'http://localhost:8080'
: 'https://api.teachit.app';
: 'https://api.learnit.app';
}
```

View File

@@ -1,5 +1,10 @@
# Frontend MVP Tasks - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a implementação REAL.
>
> **Versão Flutter:** 3.11.5+ (não 3.41.9 como documentado anteriormente)
> **Nota:** Não existe backend Node.js. Firebase BaaS é usado diretamente.
## 📱 MVP FRONTEND ROADMAP (8-12 WEEKS)
---
@@ -12,58 +17,85 @@
**Dependencies**: None
#### Subtasks:
- [ ] Create new Flutter project: `flutter create teachit`
- [ ] Configure Flutter SDK version (3.41.9 or latest stable)
- [ ] Create new Flutter project: `flutter create learn_it`
- [x] Configure Flutter SDK version (^3.11.5 or higher stable)
- [ ] Set up version control (Git repository)
- [ ] Create initial project structure
- [ ] Configure pubspec.yaml with initial dependencies
#### Dependencies to Add:
#### Actual Dependencies (from pubspec.yaml):
```yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# State Management
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
# Navigation
go_router: ^12.1.3
flutter_animate: ^4.2.0+1
# Firebase
firebase_core: ^2.24.2
firebase_auth: ^4.15.3
cloud_firestore: ^4.13.6
firebase_storage: ^11.5.6
firebase_core: ^2.25.4
firebase_auth: ^4.17.8
cloud_firestore: ^4.15.8
firebase_storage: ^11.6.9
firebase_analytics: ^10.8.0
firebase_messaging: ^14.9.3
firebase_crashlytics: ^3.5.7
# UI Components
cupertino_icons: ^1.0.6
google_fonts: ^6.1.0
# Navigation
go_router: ^12.1.3
cached_network_image: ^3.3.0
flutter_svg: ^2.0.9
lottie: ^2.7.0
shimmer: ^3.0.0
# HTTP & Networking
http: ^1.1.2
dio: ^5.4.0
http: ^1.1.2
connectivity_plus: ^5.0.2
# Local Storage
shared_preferences: ^2.2.2
hive: ^2.2.3
hive_flutter: ^1.1.0
flutter_secure_storage: ^9.0.0
# PDF Processing (for RAG)
syncfusion_flutter_pdf: ^33.2.6
file_selector: ^1.0.3
image_picker: ^1.0.4
# Utilities
intl: ^0.19.0
intl: ^0.20.2
uuid: ^4.2.1
equatable: ^2.0.5
# Charts
fl_chart: ^0.64.0
# Biometrics
local_auth: ^2.1.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
mockito: ^5.4.4
build_runner: ^2.4.7
mockito: ^5.4.4
```
#### Implementation Details:
```bash
# Create project
flutter create teachit
cd teachit
flutter create learn_it
cd learn_it
# Initialize git
git init
@@ -496,14 +528,20 @@ class FirebaseConstants {
**Dependencies**: Task 1.4
#### Subtasks:
- [ ] Implement Firebase Auth service
- [ ] Create user models and entities
- [ ] Build authentication repository
- [ ] Implement sign in use case
- [ ] Implement sign up use case
- [x] Implement Firebase Auth service
- [x] Create user models and entities
- [x] Build authentication repository
- [x] Implement sign in use case
- [x] Implement sign up use case
- [ ] Implement password reset
- [ ] Create authentication providers
- [ ] Build login/signup screens
- [x] Create authentication providers
- [x] Build login/signup screens (with navigation improvements and theme fixes)
**Recent Updates:**
- ✅ Enhanced authentication navigation with PopScope for Android back button support
- ✅ Removed AppBar and added custom positioned back button with aesthetic design
- ✅ Fixed theme consistency in signup page input fields (light/dark mode)
- ✅ Navigation flow complete: back button navigates to role-selection
#### Implementation:

View File

@@ -1,12 +1,16 @@
# Performance Optimization Guide - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL.
>
> **Nota:** O projeto é Flutter + Firebase BaaS + Ollama. Não existe backend Node.js para otimizar.
## ⚡ COMPREHENSIVE PERFORMANCE STRATEGY
---
## 📋 OVERVIEW
This guide provides comprehensive performance optimization strategies for the AI Study Assistant platform, covering frontend performance, backend optimization, database efficiency, AI model performance, and overall system scalability.
This guide provides performance optimization strategies for the AI Study Assistant platform (Flutter + Firebase BaaS + Ollama). Focus areas: Flutter frontend performance, Firestore database efficiency, Ollama API response times, and Firebase service optimization.
---

View File

@@ -6,12 +6,14 @@ Documento Completo de Especificação Técnica e
Pedagógica
<EFBFBD>
<EFBFBD> PARTE 1: VISÃO E ARQUITECTURA GLOBAL
1.1 Definição do Sistema
Este projeto é uma Plataforma de Inteligência Educacional Distribuída baseada em:
• LLMs condicionados por conhecimento institucional (não conhecimento aberto)
• Arquitectura RAG multi-camada (Retrieval-Augmented Generation)
Controlo de acesso baseado em papéis (RBAC)
Geração adaptativa de aprendizagem (pedagogically-constrained output)
> ⚠️ **NOTA IMPORTANTE**: Este documento descreve uma visão aspiracional. A implementação REAL é: Flutter + Firebase + Ollama (sem backend Node.js/Python).
1.1 Definição do Sistema (VERSÃO REAL IMPLEMENTADA)
Este projeto é uma Plataforma de Inteligência Educacional baseada em:
LLM local (Ollama qwen3-coder:30b) com materiais PDF de professores
RAG simplificado com keyword search em Dart (não FAISS/BM25)
• RBAC apenas com roles student/teacher (sem admin)
• Flutter 3.11.5+ com Firebase BaaS (Backend-as-a-Service)
Core Identity:
Institutional AI Learning Operating System
with controlled knowledge injection,

View File

@@ -1,16 +1,14 @@
# 📊 Project Progress - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL.
>
> **Nota:** "Backend Integration" refere-se a Firebase BaaS (Backend-as-a-Service), não a um backend Node.js/Python customizado.
---
## 🎯 OVERVIEW
This document tracks the overall progress of the AI Study Assistant project development. Updated in real-time as features are implemented.
This document tracks the overall progress of the AI Study Assistant project development (Flutter + Firebase BaaS + Ollama).
@@ -22,21 +20,21 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Overall Progress: 85% Complete**
### **Overall Progress: 95% Complete**
-**Foundation:** 100% Complete
-**UI/UX:** 90% Complete
-**UI/UX:** 99% Complete
-**Internationalization:** 100% Complete
-**Authentication:** 100% Complete
-**Core Features:** 75% Complete
-**Core Features:** 95% Complete
-**Backend Integration:** 80% Complete
-**Backend Integration:** 95% Complete
@@ -66,7 +64,7 @@ This document tracks the overall progress of the AI Study Assistant project deve
- [x] Splash screen with animations
- [x] Login page with improved design
- [x] Login page with improved design and navigation
- [x] Role selection page (student/teacher)
@@ -76,7 +74,7 @@ This document tracks the overall progress of the AI Study Assistant project deve
- [x] Dark/light theme support
- [ ] Signup page (needs update)
- [x] Signup page (updated with theme fixes)
- [x] Dashboard pages (fixed overflow issue)
@@ -96,7 +94,46 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **🔧 Development Setup (100%)**
### **🔐 Authentication System (100%)**
- [x] Login UI implementation
- [x] Form validation
- [x] Navigation flow
- [x] Firebase integration
- [x] Real authentication logic
- [x] Token management
- [x] Session persistence
- [x] Signup page with Portuguese localization
- [x] Role-based routing
- [x] Profile editing
### **📚 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
@@ -114,53 +151,11 @@ This document tracks the overall progress of the AI Study Assistant project deve
## 🚧 IN PROGRESS
### **📱 Authentication System (20%)**
- [x] Login UI implementation
- [x] Form validation
- [x] Navigation flow
- [ ] Firebase integration
- [ ] Real authentication logic
- [ ] Token management
- [ ] Session persistence
### **📝 Signup Page (0%)**
- [ ] Update signup page design
- [ ] Portuguese localization
- [ ] Improved animations
- [ ] Form validation
- [ ] Role-based signup
- [ ] Terms and conditions
---
## ⏳ PENDING FEATURES
### **🤖 AI Tutor System (75%)**
### **🤖 AI Tutor System (98%)**
- [x] Chat interface design
- [x] Message handling with source citations
@@ -170,73 +165,72 @@ This document tracks the overall progress of the AI Study Assistant project deve
- [x] Vector embeddings and similarity search
- [x] Content management system
- [x] Conversation history
- [x] Smart back navigation (chat vs intro)
- [x] Material names display in history
- [x] Filter conversations with user messages only
- [ ] Voice input support
- [ ] Multi-language support
- [ ] Advanced analytics
### **📝 Quiz System (0%)**
### **📝 Quiz System (90%)**
- [ ] Quiz creation interface
- [ ] Question types implementation
- [ ] Scoring system
- [ ] Progress tracking
- [ ] Results display
- [ ] Quiz categories
- [x] Quiz creation interface
- [x] Question types implementation (multiple choice)
- [x] Scoring system
- [x] Progress tracking
- [x] Results display
- [x] Quiz categories
- [x] AI-powered quiz generation from materials
- [x] Teacher quiz management
- [x] Student quiz taking interface
- [x] Quiz history and retry
- [ ] Advanced question types (fill in blank, true/false)
- [ ] Quiz sharing between classes
### **📊 Dashboard System (50%)**
### **📊 Dashboard System (95%)**
- [x] Student dashboard
- [x] Teacher dashboard
- [ ] Analytics display
- [ ] Progress charts
- [ ] Performance metrics
- [x] Analytics display
- [x] Progress charts
- [x] Performance metrics
- [x] Quick actions
- [x] Class management
- [x] Student enrollment
- [ ] Advanced data visualization
### **🔍 RAG Engine (0%)**
- [ ] Vector database setup
- [ ] Document processing
- [ ] Search implementation
- [ ] Context retrieval
- [ ] Answer generation
### **🔍 RAG Engine (85%)**
- [x] Vector database setup (FAISS)
- [x] Document processing
- [x] Search implementation
- [x] Context retrieval
- [x] Answer generation
- [x] MaterialsRAGService implementation
- [x] RAG AI service integration
- [ ] Performance optimization
- [ ] Advanced reranking
### **📈 Analytics System (0%)**
- [ ] Learning progress tracking
- [ ] Usage statistics
- [ ] Performance metrics
### **📈 Analytics System (90%)**
- [x] Learning progress tracking
- [x] Usage statistics
- [x] Performance metrics
- [x] Gamification service
- [x] Achievement system
- [x] Class analytics
- [x] Student rankings
- [x] Teacher analytics dashboard
- [ ] Export functionality
- [ ] Reporting dashboard
- [ ] Data visualization
- [ ] Advanced data visualization
@@ -248,31 +242,29 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Sprint 3: Authentication & Signup (In Progress)**
### **Sprint 4: Polish & Optimization (In Progress)**
**Duration:** Current Week
**Goal:** Complete authentication flow
**Goal:** Finalize features and optimize performance
#### **Tasks:**
- [x] Fix login page design issues
- [x] Improve animations and background
- [x] Update language policy documentation
- [ ] Update signup page with Portuguese
- [ ] Implement Firebase authentication
- [ ] Add role-based routing
- [x] Fix dashboard progress data flickering
- [x] Cache user name and stats
- [x] Smart back navigation in chat history
- [x] Material names display in history
- [x] Filter conversations with user messages
- [x] Remove new chat button from intro screen
- [x] Update documentation with actual progress
- [ ] Performance optimization
- [ ] Bug fixes and polish
#### **Progress:** 60% Complete
#### **Progress:** 80% Complete
@@ -284,7 +276,7 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Version 1.0 - MVP (Target: 2 Weeks)**
### **Version 1.0 - MVP (Target: Completed)**
- ✅ Basic UI/UX
@@ -292,35 +284,45 @@ This document tracks the overall progress of the AI Study Assistant project deve
- ✅ Navigation flow
- Complete authentication
- Complete authentication
- Basic dashboard
- Basic dashboard
- ⏳ Simple quiz system
- ✅ Quiz system
- ✅ AI tutor integration
- ✅ Analytics system
- ✅ Class management
- ✅ Materials upload
### **Version 1.1 - Enhanced Features (Target: 4 Weeks)**
### **Version 1.1 - Enhanced Features (Target: 2 Weeks)**
-AI tutor integration
-Voice input support
- ⏳ Advanced quiz features
- ⏳ Advanced question types
- ⏳ Analytics dashboard
- ⏳ Advanced data visualization
- ⏳ Performance improvements
- ⏳ Performance optimizations
- ⏳ Quiz sharing between classes
### **Version 2.0 - Full Platform (Target: 8 Weeks)**
- ⏳ Complete RAG engine
- ⏳ Complete RAG engine optimization
- ⏳ Advanced analytics
- ⏳ Advanced analytics export
-Teacher tools
-Multi-language support
-Content management
-Offline mode enhancements
- ⏳ Mobile optimizations
@@ -340,11 +342,9 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Minor Issues (1)**
### **Minor Issues (0)**
- [ ] Signup page needs design update
- [ ] Some animations could be optimized
- None currently
@@ -370,15 +370,11 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Development Metrics:**
- **Total Files:** 45+ Dart files
- **Lines of Code:** ~3,000+ lines
- **Dependencies:** 25+ packages
- **Build Time:** ~15 seconds
- **App Size:** ~25MB (debug)
- **Total Files:** 80+ Dart files
- **Lines of Code:** ~8,000+ lines
- **Dependencies:** 30+ packages
- **Build Time:** ~20 seconds
- **App Size:** ~35MB (debug)
@@ -404,6 +400,190 @@ This document tracks the overall progress of the AI Study Assistant project deve
### **Last 24 Hours:**
-**Smart Back Navigation in Chat History** - chat_history_page.dart & tutor_chat_page_simple.dart
- Implemented intelligent back navigation based on entry point
- When accessed from chat: returns to the previous conversation
- When accessed from intro: returns to intro screen
- Added PopScope for Android back gesture handling
- Passes source parameter (chat/intro) and conversationId in URL
-**Material Names Display in Chat History** - chat_history_page.dart
- Replaced raw material IDs with readable file names
- Added _materialNamesCache to store ID-to-name mappings
- Added _loadMaterialNames method to fetch names from Firestore
- Material names truncated to 20 characters if too long
- Fallback to ID if name not found
-**Filter Conversations with User Messages Only** - chat_memory_service.dart
- Added hasUserMessage field to conversation documents
- Initialized to false when conversation is created
- Set to true when user sends a message
- getConversations filters to show only conversations with hasUserMessage == true
- Prevents empty conversations from appearing in history
-**Dashboard Data Caching** - progress_hero_widget.dart & student_dashboard_page.dart
- Added caching for user stats to prevent flickering on navigation
- Added caching for user name to prevent flickering on navigation
- Shows cached data while loading new data in background
- Only shows loading state on first load
- Added clearCachedUserName() method to update cache when profile changes
-**Profile Edit Integration** - profile_edit_page.dart
- Calls StudentDashboardPage.clearCachedUserName() when profile is updated
- Ensures dashboard reflects name changes immediately
-**Remove New Chat Button from Intro Screen** - tutor_chat_page_simple.dart
- New chat button now only shows when _materialsConfirmed is true
- Hidden in intro screen to reduce UI clutter
-**Fixed Settings Profile Card UI** - profile_edit_page.dart
- Background: Changed from hardcoded white to Theme.of(context).colorScheme.surface
- User info: Fixed duplicate email display, now shows displayName (bold, fontSize 16) on top and email (fontSize 14) below
- Shadow: Updated to use Theme.of(context).colorScheme.shadow.withOpacity(0.1)
-**Fixed Signup Page Input Fields** - signup_page.dart
- Background: Changed to conditional based on brightness
- Light mode: Theme.of(context).colorScheme.surface
- Dark mode: Theme.of(context).colorScheme.surfaceContainerHighest
- Applied to name, email, and password fields (lines 259-265, 316-322, 386-392)
-**Fixed Dashboard Progress Container** - profile_section_widget.dart
- "Ótimo progresso!" container background changed to conditional
- Light mode: Theme.of(context).colorScheme.surface
- Dark mode: Theme.of(context).colorScheme.surfaceContainerHighest
-**Enhanced Authentication Navigation** - login_page.dart & signup_page.dart
- Removed AppBar from both pages
- Added PopScope with canPop: false and onPopInvokedWithResult to navigate to '/role-selection'
- Added custom positioned back button (top: 50, left: 16) with:
- Semi-transparent container (colorScheme.surface.withOpacity(0.8))
- Rounded corners (12px)
- Subtle shadow (colorScheme.shadow.withOpacity(0.1), blurRadius: 8)
- Positioned below notification bar to avoid overlap
-**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)`
- For each enrollment document, uses `FutureBuilder` to fetch the corresponding class from `classes` collection
- Layout identical to `TeacherClassesListWidget`:
- Horizontal `ListView.builder` with `scrollDirection: Axis.horizontal`
- 2-row grid layout: Column with cards at index * 2 and index * 2 + 1
- Card size: 200x150 pixels
- Card styling: White background, 16px border radius, subtle shadow
- Card content: Icon (school), class name (bold), class code (grey)
- Title: "As Minhas Turmas" with `textTheme.titleLarge` style
- Loading state: `CircularProgressIndicator` centered in card while loading class data
- Empty state: "Ainda não entraste em nenhuma turma."
- Widget inserted in `StudentDashboardPage` between `QuickAccessWidget` and `ProfileSectionWidget`
- **System is now bidirectional:**
- Professor creates classes → Students can join
- Professor sees list of students in each class (ETAPA 3)
- Students see list of classes they joined (ETAPA 5)
- Both use same visual patterns for consistency
- Testing: After joining a class (ETAPA 4), the class appears immediately in the student's home list
-**ETAPA 4: Join Class Feature** - Students can now join classes using class codes
- New `JoinClassPage` component at `lib/features/classes/presentation/pages/join_class_page.dart`
- Query: `.collection('classes').where('code', isEqualTo: enteredCode).limit(1)`
- Input: TextField with uppercase formatting, 6 character limit, centered text, letter spacing
- Validation flow:
1. Check if code is empty → show error "Insere o código da turma"
2. Query Firestore for class with matching code
3. If no class found → show error "Código de turma inválido"
4. Check if student already enrolled → show error "Já estás inscrito nesta turma"
5. Create enrollment document in `enrollments` collection
- Enrollment document structure:
```dart
{
'classId': classDoc.id,
'studentId': currentUser.uid,
'studentName': currentUser.displayName ?? email.split('@')[0] ?? 'Aluno',
'joinedAt': FieldValue.serverTimestamp(),
}
```
- Success feedback: Green SnackBar "Entraste na turma com sucesso!" (2 seconds)
- Error feedback: Red SnackBar with specific error message (3 seconds)
- Auto-navigates back to Student Dashboard after successful join
- Loading state: CircularProgressIndicator in button while processing
- Visual design: Teal AppBar (#82C9BD), centered group_add icon, clean white input card
- New "Entrar numa Turma" card added to `QuickAccessWidget` in Student Dashboard
- Card design: Horizontal layout with `Icons.group_add`, white background, rounded corners (16px)
- Testing in Firebase Console:
1. Go to Firestore Database → classes collection
2. Copy the `code` field from any class document
3. In app: Student Dashboard → "Entrar numa Turma"
4. Paste code and tap "Entrar na Turma"
5. Check enrollments collection for new document with correct data
- ✅ **ETAPA 3: Class Students View** - Teachers can now view enrolled students in each class
- New `ClassStudentsPage` component at `lib/features/classes/presentation/pages/class_students_page.dart`
- Query: `.collection('enrollments').where('classId', isEqualTo: classId).orderBy('joinedAt', descending: true)`
- StreamBuilder for real-time updates when students join
- ListTile design with student icon, name, and formatted join date
- Empty state: "Nenhum aluno entrou nesta turma ainda."
- Loading state with `CircularProgressIndicator`
- Error state with error icon and message
- Date formatting using `intl` package (Portuguese format: dd/MM/yyyy)
- Navigation via `GestureDetector` onTap in `TeacherClassesListWidget`
- MaterialPageRoute navigation passing `classId` and `className` as parameters
- AppBar with back button and two-line title (class name + subtitle)
- Consistent visual design: teal colors (#82C9BD), white cards, rounded corners (16px), subtle shadows
- ✅ **ETAPA 2: Classes List Display** - Teachers can now view their created classes
- New `TeacherClassesListWidget` component
- "As Minhas Turmas" section added to Teacher Dashboard
- Real-time Firestore stream with `StreamBuilder`
- Query: `.where('teacherId', isEqualTo: currentUser.uid).orderBy('createdAt', descending: true)`
- **CORREÇÃO**: O erro anterior foi tentar usar `GridView` horizontal para um layout que exige colunas fixas de 2 cards
- **SOLUÇÃO**: Usar `ListView.builder` com `scrollDirection: Axis.horizontal`
- Cada item do ListView é uma `Column` com 2 cards (índice * 2 e índice * 2 + 1)
- Cards mantêm exatamente o mesmo tamanho da lista vertical original
- Layout: Card 1, 3, 5... (top row) | Card 2, 4, 6... (bottom row)
- Scroll horizontal para visualizar todas as turmas
- Padding entre colunas: 12 pixels
- Altura do SizedBox: 280 pixels (suficiente para 2 cards + spacing)
- Empty state: "Ainda não criaste nenhuma turma."
- Loading state with `CircularProgressIndicator`
- Cards styled with white background, rounded corners, and subtle shadow
- ✅ **Fixed Pixel Overflow Issues** - Teacher Dashboard cards
- Replaced fixed `height: 150` with `BoxConstraints(minHeight: 135, maxHeight: 160)`
- Fixed overflow in "Criar Turma" card
- Fixed overflow in "Upload Conteúdo" card
- Cards now adapt better to different screen sizes
- ✅ **Unified Quick Action Cards Text Style**
- "Upload Conteúdo" and "Criar Quiz" cards now have same text alignment as "Criar Turma"
- All cards use `Column` with `crossAxisAlignment: CrossAxisAlignment.start` for text
- Subtitle text now supports 2 lines with `maxLines: 2` and proper line height (1.2)
- Consistent fontSize (16 for title, 12 for subtitle) across all cards
- "Criar Quiz" subtitle updated to "Avaliações interativas" for better description
- ✅ **ETAPA 1: Class Creation Feature** - Teacher can now create classes
- Added "Criar Turma" button in Teacher Dashboard
- Dialog for entering class name
- Auto-generates 6-character random code (A-Z, 0-9)
- Saves to Firestore `classes` collection with name, teacherId, code, createdAt
- Success/error feedback via SnackBar
- ✅ Fixed dashboard overflow issue in QuickAccessWidget
- ✅ Implemented responsive layout with IntrinsicHeight and Flexible
@@ -492,10 +672,6 @@ This document tracks the overall progress of the AI Study Assistant project deve
-**Samsung S928B (Android 16)** - Primary testing device
-**Windows Desktop** - Development environment
-**Chrome Browser** - Web testing
-**iOS Devices** - Pending testing
-**Other Android** - Pending testing
@@ -574,7 +750,7 @@ This document tracks the overall progress of the AI Study Assistant project deve
**📊 Last Updated: 2024-05-06 21:43**
**📊 Last Updated: 2026-05-23 17:11**
**🔄 Auto-Update: Enabled**

View File

@@ -1,43 +1,59 @@
# RAG Engine MVP Tasks - AI Study Assistant
## 🧠 MVP RAG ENGINE ROADMAP (8-12 WEEKS)
> ⚠️ **IMPORTANTE - DOCUMENTO DESATUALIZADO**: Este documento descreve uma arquitetura Python/FAISS que **NÃO FOI IMPLEMENTADA**.
>
> **Implementação Real:**
> - **Linguagem**: Dart (Flutter)
> - **Localização**: `lib/core/services/materials_rag_service.dart`, `lib/core/services/rag_ai_service.dart`
> - **Vector Store**: Firestore com embeddings mock (hash-based)
> - **PDF Processing**: `syncfusion_flutter_pdf` (não Python)
> - **Busca**: Keyword window search (não FAISS)
>
> **NÃO EXISTE:** Python, FAISS, Sentence Transformers, OpenAI, Anthropic
---
## 📚 WEEK 1-2: FOUNDATION & SETUP
## 🧠 MVP RAG ENGINE ROADMAP (DOCUMENTAÇÃO ORIGINAL - NÃO IMPLEMENTADA)
---
## 📚 WEEK 1-2: FOUNDATION & SETUP (NOT IMPLEMENTED)
### Task 1.1: Vector Database Setup
**Priority**: Critical
**Estimated Time**: 8 hours
**Dependencies**: None
**Status**: ❌ NOT IMPLEMENTED - FAISS não é utilizado
#### Subtasks:
- [ ] Choose vector database technology (FAISS for MVP)
- [ ] Set up development environment
- [ ] Install required dependencies
- [ ] Configure storage for vector indices
- [ ] Create basic vector operations
- [ ] Set up backup and recovery
#### What Actually Exists:
```dart
// lib/core/services/vector_service.dart
class VectorService {
// Mock embedding generation using text hashing
static List<double> generateEmbedding(String text) {
final embedding = List<double>.filled(384, 0.0);
// Hash-based deterministic embeddings (not ML)
return embedding;
}
}
#### Technology Stack:
// lib/core/services/materials_rag_service.dart
class MaterialsRAGService {
// Keyword-based window search for PDFs
static Future<String> getContextForQuestion(...) async {
// 1. Extract PDF text with syncfusion_flutter_pdf
// 2. Find keyword matches
// 3. Return window of 1200 chars around match
// NO FAISS, NO VECTOR SEARCH, NO PYTHON
}
}
```
#### Technology Stack (Original - NOT USED):
```bash
# Core dependencies
pip install faiss-cpu # or faiss-gpu for GPU acceleration
pip install sentence-transformers
pip install numpy
pip install pandas
pip install scikit-learn
# Text processing
pip install nltk
pip install spacy
python -m spacy download en_core_web_sm
# Storage and utilities
pip install firebase-admin
pip install google-cloud-storage
pip install pickle
pip install h5py
# These dependencies DO NOT EXIST in the project:
# ❌ pip install faiss-cpu
# ❌ pip install sentence-transformers
# ❌ pip install numpy
# ❌ pip install nltk
# ❌ pip install spacy
```
#### Project Structure:

View File

@@ -1,12 +1,16 @@
# Security Documentation - AI Study Assistant
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a implementação REAL.
>
> **Nota Importante sobre Roles:** Apenas `student` e `teacher` estão implementados. Roles `admin` e `super_admin` NÃO existem no código.
## 🔒 COMPREHENSIVE SECURITY FRAMEWORK
---
## 📋 OVERVIEW
This document outlines the complete security architecture, policies, and procedures for the AI Study Assistant platform, ensuring data protection, privacy compliance, and secure operations across all components.
This document outlines the security architecture for the AI Study Assistant platform. Note: This is a Flutter + Firebase BaaS application without a custom backend server.
---
@@ -275,7 +279,7 @@ export class FieldEncryption {
async decryptField(encryptedData: EncryptedData, key: string): Promise<string> {
const decipher = crypto.createDecipher(this.algorithm, key);
decipher.setAAD(Buffer.from('teachit-field', 'utf8'));
decipher.setAAD(Buffer.from('learnit-field', 'utf8'));
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'));
let decrypted = decipher.update(encryptedData.data, 'hex', 'utf8');
@@ -409,7 +413,7 @@ export const securityHeaders = {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.teachit.app",
"connect-src 'self' https://api.learnit.app",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
@@ -1218,7 +1222,7 @@ export class GDPRRights {
return {
format: 'JSON',
data: userData,
schema: 'teachit-data-schema-v1.0',
schema: 'learnit-data-schema-v1.0',
};
}
@@ -1433,30 +1437,30 @@ export class SecurityDashboard {
### Security Team
#### Incident Response Team
- **Security Lead**: security@teachit.app
- **Incident Response**: incidents@teachit.app
- **Vulnerability Reports**: vulnerabilities@teachit.app
- **Compliance Officer**: compliance@teachit.app
- **Security Lead**: security@learnit.app
- **Incident Response**: incidents@learnit.app
- **Vulnerability Reports**: vulnerabilities@learnit.app
- **Compliance Officer**: compliance@learnit.app
#### Emergency Contacts
- **Critical Incidents**: +1-800-SECURITY
- **Data Breach**: +1-800-BREACH
- **Legal Counsel**: legal@teachit.app
- **Law Enforcement**: emergency@teachit.app
- **Legal Counsel**: legal@learnit.app
- **Law Enforcement**: emergency@learnit.app
### Reporting Security Issues
#### Vulnerability Disclosure
- **Email**: security@teachit.app
- **Email**: security@learnit.app
- **PGP Key**: Available on request
- **Response Time**: Within 24 hours
- **Bounty Program**: Available for qualifying reports
#### Data Subject Requests
- **Access Requests**: privacy@teachit.app
- **Deletion Requests**: delete@teachit.app
- **Correction Requests**: corrections@teachit.app
- **Complaints**: complaints@teachit.app
- **Access Requests**: privacy@learnit.app
- **Deletion Requests**: delete@learnit.app
- **Correction Requests**: corrections@learnit.app
- **Complaints**: complaints@learnit.app
---

View File

@@ -1,12 +1,16 @@
# Testing Strategy - AI Study Assistant
## 🧪 COMPREHENSIVE TESTING APPROACH
> ⚠️ **ATUALIZADO**: Este documento foi corrigido para refletir a arquitetura REAL.
>
> **Nota:** O projeto é Flutter + Firebase BaaS. Não existe backend Node.js/Python para testar.
## 🧪 TESTING APPROACH
---
## 📋 OVERVIEW
This document outlines the complete testing strategy for the AI Study Assistant project, covering unit tests, integration tests, widget tests, end-to-end tests, performance testing, and quality assurance processes.
This document outlines the testing strategy for the AI Study Assistant project (Flutter + Firebase BaaS). Testing focuses on Flutter unit/widget tests and Firebase security rules. There is no custom backend to test.
---
@@ -109,9 +113,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:dartz/dartz.dart';
import 'package:teachit/features/auth/domain/entities/user.dart';
import 'package:teachit/features/auth/domain/repositories/auth_repository.dart';
import 'package:teachit/features/auth/domain/usecases/sign_in.dart';
import 'package:learn_it/features/auth/domain/entities/user.dart';
import 'package:learn_it/features/auth/domain/repositories/auth_repository.dart';
import 'package:learn_it/features/auth/domain/usecases/sign_in.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
@@ -353,7 +357,7 @@ import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:teachit/features/auth/presentation/screens/login_screen.dart';
import 'package:teachit/features/auth/presentation/providers/auth_provider.dart';
import 'package:learn_it/features/auth/presentation/providers/auth_provider.dart';
import 'login_screen_test.mocks.dart';
@@ -516,9 +520,9 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:teachit/main.dart' as app;
import 'package:learn_it/main.dart' as app;
import 'package:teachit/features/auth/presentation/screens/login_screen.dart';
import 'package:teachit/features/student/presentation/screens/student_dashboard_screen.dart';
import 'package:learn_it/features/student/presentation/screens/student_dashboard_screen.dart';
void main() {
group('Authentication Flow Integration Tests', () {
@@ -767,7 +771,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:teachit/main.dart' as app;
import 'package:learn_it/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

View File

@@ -17,7 +17,7 @@ Welcome to the AI Study Assistant! This comprehensive guide will help you master
#### 1. Download and Install
- **Android**: Download from Google Play Store
- **iOS**: Download from App Store
- **Web**: Visit [app.teachit.app](https://app.teachit.app)
- **Web**: Visit [app.learnit.app](https://app.learnit.app)
#### 2. Create Account
1. Open the app
@@ -475,8 +475,8 @@ Monitor class performance with:
- **Tutoring Services**: Additional support
#### Technical Support
- **Email**: support@teachit.app
- **Phone**: 1-800-TEACH-IT
- **Email**: support@learnit.app
- **Phone**: 1-800-LEARN-IT
- **Live Chat**: In-app chat support
- **Community Forum**: User discussions
@@ -565,10 +565,10 @@ Monitor class performance with:
### Contact Information
#### Support Channels
- **Email Support**: support@teachit.app
- **Phone Support**: 1-800-TEACH-IT
- **Email Support**: support@learnit.app
- **Phone Support**: 1-800-LEARN-IT
- **Live Chat**: In-app support
- **Social Media**: @TeachItApp
- **Social Media**: @LearnItApp
#### School Support
- **IT Department**: Technical assistance
@@ -602,10 +602,10 @@ Monitor class performance with:
- **Esc**: Cancel/close
### Emergency Contacts
- **Technical Issues**: support@teachit.app
- **Account Problems**: admin@teachit.app
- **Security Concerns**: security@teachit.app
- **Emergency**: 1-800-TEACH-IT
- **Technical Issues**: support@learnit.app
- **Account Problems**: admin@learnit.app
- **Security Concerns**: security@learnit.app
- **Emergency**: 1-800-LEARN-IT
---

View File

@@ -431,7 +431,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -488,7 +488,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Teachit</string>
<string>Learn It</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>teachit</string>
<string>learnit</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@@ -0,0 +1,318 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Model para definição de conquistas
class Achievement {
final String id;
final String name;
final String description;
final String icon;
final String category; // 'streak', 'study_time', 'quiz', 'concept'
final AchievementRequirement requirements;
final int points;
final String rarity; // 'common', 'rare', 'epic', 'legendary'
final bool isActive;
final DateTime createdAt;
final String? createdBy; // teacherId se criada por professor
const Achievement({
required this.id,
required this.name,
required this.description,
required this.icon,
required this.category,
required this.requirements,
required this.points,
required this.rarity,
required this.isActive,
required this.createdAt,
this.createdBy,
});
factory Achievement.fromFirestore(Map<String, dynamic> data, String id) {
return Achievement(
id: id,
name: data['name'] ?? '',
description: data['description'] ?? '',
icon: data['icon'] ?? 'star',
category: data['category'] ?? 'general',
requirements: AchievementRequirement.fromFirestore(data['requirements'] ?? {}),
points: data['points'] ?? 0,
rarity: data['rarity'] ?? 'common',
isActive: data['isActive'] ?? true,
createdAt: (data['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
createdBy: data['createdBy'],
);
}
Map<String, dynamic> toFirestore() {
return {
'name': name,
'description': description,
'icon': icon,
'category': category,
'requirements': requirements.toFirestore(),
'points': points,
'rarity': rarity,
'isActive': isActive,
'createdAt': Timestamp.fromDate(createdAt),
if (createdBy != null) 'createdBy': createdBy,
};
}
Achievement copyWith({
String? id,
String? name,
String? description,
String? icon,
String? category,
AchievementRequirement? requirements,
int? points,
String? rarity,
bool? isActive,
String? createdBy,
}) {
return Achievement(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
icon: icon ?? this.icon,
category: category ?? this.category,
requirements: requirements ?? this.requirements,
points: points ?? this.points,
rarity: rarity ?? this.rarity,
isActive: isActive ?? this.isActive,
createdAt: createdAt,
createdBy: createdBy ?? this.createdBy,
);
}
}
/// Requisitos para desbloquear uma conquista
class AchievementRequirement {
final String type; // 'streak_days', 'study_time', 'quiz_score', 'concepts_mastered', 'quiz_completion'
final num value;
final String operator; // '>=', '==', '>'
final Map<String, dynamic>? additionalParams;
const AchievementRequirement({
required this.type,
required this.value,
required this.operator,
this.additionalParams,
});
factory AchievementRequirement.fromFirestore(Map<String, dynamic> data) {
return AchievementRequirement(
type: data['type'] ?? '',
value: data['value'] ?? 0,
operator: data['operator'] ?? '>=',
additionalParams: data['additionalParams'],
);
}
Map<String, dynamic> toFirestore() {
return {
'type': type,
'value': value,
'operator': operator,
if (additionalParams != null) 'additionalParams': additionalParams,
};
}
bool checkCondition(num currentValue) {
switch (operator) {
case '>=':
return currentValue >= value;
case '==':
return currentValue == value;
case '>':
return currentValue > value;
default:
return false;
}
}
}
/// Conquistas predefinidas do sistema
class SystemAchievements {
static List<Achievement> get defaultAchievements => [
Achievement(
id: 'first_quiz',
name: 'Primeiro Passo',
description: 'Complete seu primeiro quiz',
icon: 'emoji_events',
category: 'quiz',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 1,
operator: '>=',
),
points: 10,
rarity: 'common',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'week_streak',
name: 'Semana de Dedicação',
description: 'Mantenha uma streak de 7 dias',
icon: 'local_fire_department',
category: 'streak',
requirements: AchievementRequirement(
type: 'streak_days',
value: 7,
operator: '>=',
),
points: 50,
rarity: 'rare',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'study_marathon',
name: 'Maratona de Estudos',
description: 'Estude por 100 minutos em um dia',
icon: 'schedule',
category: 'study_time',
requirements: AchievementRequirement(
type: 'study_time',
value: 100,
operator: '>=',
additionalParams: {'period': 'daily'},
),
points: 30,
rarity: 'rare',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'perfect_score',
name: 'Perfeição',
description: 'Obtenha 100% em um quiz',
icon: 'star',
category: 'quiz',
requirements: AchievementRequirement(
type: 'quiz_score',
value: 100,
operator: '==',
),
points: 25,
rarity: 'rare',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'concept_master',
name: 'Mestre de Conceitos',
description: 'Domine 5 conceitos',
icon: 'school',
category: 'concept',
requirements: AchievementRequirement(
type: 'concepts_mastered',
value: 5,
operator: '>=',
),
points: 40,
rarity: 'epic',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'month_streak',
name: 'Lendário',
description: 'Mantenha uma streak de 30 dias',
icon: 'whatshot',
category: 'streak',
requirements: AchievementRequirement(
type: 'streak_days',
value: 30,
operator: '>=',
),
points: 200,
rarity: 'legendary',
isActive: true,
createdAt: DateTime.now(),
),
// Conquistas genéricas de número de quizzes
Achievement(
id: 'quiz_novice_5',
name: 'Iniciante',
description: 'Complete 5 quizzes',
icon: 'emoji_events',
category: 'quiz_count',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 5,
operator: '>=',
),
points: 15,
rarity: 'common',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'quiz_intermediate_10',
name: 'Estudante Dedicao',
description: 'Complete 10 quizzes',
icon: 'school',
category: 'quiz_count',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 10,
operator: '>=',
),
points: 30,
rarity: 'common',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'quiz_advanced_25',
name: 'Mestre dos Quizzes',
description: 'Complete 25 quizzes',
icon: 'military_tech',
category: 'quiz_count',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 25,
operator: '>=',
),
points: 75,
rarity: 'rare',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'quiz_expert_50',
name: 'Especialista',
description: 'Complete 50 quizzes',
icon: 'workspace_premium',
category: 'quiz_count',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 50,
operator: '>=',
),
points: 150,
rarity: 'epic',
isActive: true,
createdAt: DateTime.now(),
),
Achievement(
id: 'quiz_legend_100',
name: 'Lenda dos Quizzes',
description: 'Complete 100 quizzes',
icon: 'stars',
category: 'quiz_count',
requirements: AchievementRequirement(
type: 'quiz_completion',
value: 100,
operator: '>=',
),
points: 300,
rarity: 'legendary',
isActive: true,
createdAt: DateTime.now(),
),
];
}

View File

@@ -0,0 +1,199 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Model para estatísticas da turma (professor)
class ClassStats {
final String classId;
final String teacherId;
final String className;
final int totalStudents;
final int activeStudents;
final double averageProgress;
final int totalQuizzes;
final int activeQuizzes;
final int totalContent;
final List<WeeklyStats> weeklyStats;
final List<StudentNeedingSupport> studentsNeedingSupport;
final DateTime? lastUpdated;
const ClassStats({
required this.classId,
required this.teacherId,
required this.className,
required this.totalStudents,
required this.activeStudents,
required this.averageProgress,
required this.totalQuizzes,
required this.activeQuizzes,
required this.totalContent,
required this.weeklyStats,
required this.studentsNeedingSupport,
this.lastUpdated,
});
factory ClassStats.fromFirestore(Map<String, dynamic> data, String classId) {
return ClassStats(
classId: classId,
teacherId: data['teacherId'] ?? '',
className: data['className'] ?? '',
totalStudents: data['totalStudents'] ?? 0,
activeStudents: data['activeStudents'] ?? 0,
averageProgress: (data['averageProgress'] ?? 0).toDouble(),
totalQuizzes: data['totalQuizzes'] ?? 0,
activeQuizzes: data['activeQuizzes'] ?? 0,
totalContent: data['totalContent'] ?? 0,
weeklyStats: (data['weeklyStats'] as List<dynamic>?)
?.map((w) => WeeklyStats.fromFirestore(w))
.toList() ??
[],
studentsNeedingSupport: (data['studentsNeedingSupport'] as List<dynamic>?)
?.map((s) => StudentNeedingSupport.fromFirestore(s))
.toList() ??
[],
lastUpdated: (data['lastUpdated'] as Timestamp?)?.toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'teacherId': teacherId,
'className': className,
'totalStudents': totalStudents,
'activeStudents': activeStudents,
'averageProgress': averageProgress,
'totalQuizzes': totalQuizzes,
'activeQuizzes': activeQuizzes,
'totalContent': totalContent,
'weeklyStats': weeklyStats.map((w) => w.toFirestore()).toList(),
'studentsNeedingSupport': studentsNeedingSupport.map((s) => s.toFirestore()).toList(),
'lastUpdated': lastUpdated != null ? Timestamp.fromDate(lastUpdated!) : null,
};
}
}
/// Estatísticas semanais da turma
class WeeklyStats {
final DateTime weekStart;
final int activeStudents;
final double averageScore;
final int totalStudyTime;
const WeeklyStats({
required this.weekStart,
required this.activeStudents,
required this.averageScore,
required this.totalStudyTime,
});
factory WeeklyStats.fromFirestore(Map<String, dynamic> data) {
return WeeklyStats(
weekStart: (data['weekStart'] as Timestamp?)?.toDate() ?? DateTime.now(),
activeStudents: data['activeStudents'] ?? 0,
averageScore: (data['averageScore'] ?? 0).toDouble(),
totalStudyTime: data['totalStudyTime'] ?? 0,
);
}
Map<String, dynamic> toFirestore() {
return {
'weekStart': Timestamp.fromDate(weekStart),
'activeStudents': activeStudents,
'averageScore': averageScore,
'totalStudyTime': totalStudyTime,
};
}
}
/// Aluno que precisa de apoio
class StudentNeedingSupport {
final String studentId;
final String studentName;
final String reason; // 'low_scores', 'inactivity', 'struggling_concept'
final DateTime lastActivity;
final double averageScore;
const StudentNeedingSupport({
required this.studentId,
required this.studentName,
required this.reason,
required this.lastActivity,
required this.averageScore,
});
factory StudentNeedingSupport.fromFirestore(Map<String, dynamic> data) {
return StudentNeedingSupport(
studentId: data['studentId'] ?? '',
studentName: data['studentName'] ?? '',
reason: data['reason'] ?? '',
lastActivity: (data['lastActivity'] as Timestamp?)?.toDate() ?? DateTime.now(),
averageScore: (data['averageScore'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toFirestore() {
return {
'studentId': studentId,
'studentName': studentName,
'reason': reason,
'lastActivity': Timestamp.fromDate(lastActivity),
'averageScore': averageScore,
};
}
}
/// Ranking de alunos em uma turma
class StudentRanking {
final String studentId;
final String studentName;
final String studentEmail;
final double overallScore;
final int completedQuizzes;
final int totalQuizzes;
final double quizCompletionRate;
final int studyTimeMinutes;
final int currentStreak;
final DateTime lastActivity;
const StudentRanking({
required this.studentId,
required this.studentName,
required this.studentEmail,
required this.overallScore,
required this.completedQuizzes,
required this.totalQuizzes,
required this.quizCompletionRate,
required this.studyTimeMinutes,
required this.currentStreak,
required this.lastActivity,
});
double get quizCompletionPercentage => quizCompletionRate * 100;
factory StudentRanking.fromFirestore(Map<String, dynamic> data, String studentId) {
return StudentRanking(
studentId: studentId,
studentName: data['studentName'] ?? '',
studentEmail: data['studentEmail'] ?? '',
overallScore: (data['overallScore'] ?? 0).toDouble(),
completedQuizzes: data['completedQuizzes'] ?? 0,
totalQuizzes: data['totalQuizzes'] ?? 0,
quizCompletionRate: (data['quizCompletionRate'] ?? 0).toDouble(),
studyTimeMinutes: data['studyTimeMinutes'] ?? 0,
currentStreak: data['currentStreak'] ?? 0,
lastActivity: (data['lastActivity'] as Timestamp?)?.toDate() ?? DateTime.now(),
);
}
Map<String, dynamic> toFirestore() {
return {
'studentName': studentName,
'studentEmail': studentEmail,
'overallScore': overallScore,
'completedQuizzes': completedQuizzes,
'totalQuizzes': totalQuizzes,
'quizCompletionRate': quizCompletionRate,
'studyTimeMinutes': studyTimeMinutes,
'currentStreak': currentStreak,
'lastActivity': Timestamp.fromDate(lastActivity),
};
}
}

View File

@@ -0,0 +1,151 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Model para estatísticas do usuário (aluno)
class UserStats {
final String userId;
final int currentStreak;
final int longestStreak;
final int totalStudyTime; // em minutos
final DateTime? lastActivityDate;
final int weeklyStudyTime; // minutos esta semana
final int monthlyStudyTime; // minutos este mês
final int completedQuizzes; // total de quizzes completos
final List<MasteredConcept> masteredConcepts;
final List<UnlockedAchievement> unlockedAchievements;
const UserStats({
required this.userId,
required this.currentStreak,
required this.longestStreak,
required this.totalStudyTime,
required this.weeklyStudyTime,
required this.monthlyStudyTime,
required this.completedQuizzes,
required this.masteredConcepts,
required this.unlockedAchievements,
this.lastActivityDate,
});
factory UserStats.fromFirestore(Map<String, dynamic> data, String userId) {
return UserStats(
userId: userId,
currentStreak: data['currentStreak'] ?? 0,
longestStreak: data['longestStreak'] ?? 0,
totalStudyTime: data['totalStudyTime'] ?? 0,
lastActivityDate: (data['lastActivityDate'] as Timestamp?)?.toDate(),
weeklyStudyTime: data['weeklyStudyTime'] ?? 0,
monthlyStudyTime: data['monthlyStudyTime'] ?? 0,
completedQuizzes: data['completedQuizzes'] ?? 0,
masteredConcepts: (data['masteredConcepts'] as List<dynamic>?)
?.map((c) => MasteredConcept.fromFirestore(c))
.toList() ??
[],
unlockedAchievements: (data['unlockedAchievements'] as List<dynamic>?)
?.map((a) => UnlockedAchievement.fromFirestore(a))
.toList() ??
[],
);
}
Map<String, dynamic> toFirestore() {
final data = {
'currentStreak': currentStreak,
'longestStreak': longestStreak,
'totalStudyTime': totalStudyTime,
'weeklyStudyTime': weeklyStudyTime,
'monthlyStudyTime': monthlyStudyTime,
'completedQuizzes': completedQuizzes,
'masteredConcepts': masteredConcepts.map((c) => c.toFirestore()).toList(),
'unlockedAchievements': unlockedAchievements.map((a) => a.toFirestore()).toList(),
};
if (lastActivityDate != null) {
data['lastActivityDate'] = Timestamp.fromDate(lastActivityDate!);
}
return data;
}
UserStats copyWith({
int? currentStreak,
int? longestStreak,
int? totalStudyTime,
DateTime? lastActivityDate,
int? weeklyStudyTime,
int? monthlyStudyTime,
int? completedQuizzes,
List<MasteredConcept>? masteredConcepts,
List<UnlockedAchievement>? unlockedAchievements,
}) {
return UserStats(
userId: userId,
currentStreak: currentStreak ?? this.currentStreak,
longestStreak: longestStreak ?? this.longestStreak,
totalStudyTime: totalStudyTime ?? this.totalStudyTime,
lastActivityDate: lastActivityDate ?? this.lastActivityDate,
weeklyStudyTime: weeklyStudyTime ?? this.weeklyStudyTime,
monthlyStudyTime: monthlyStudyTime ?? this.monthlyStudyTime,
completedQuizzes: completedQuizzes ?? this.completedQuizzes,
masteredConcepts: masteredConcepts ?? this.masteredConcepts,
unlockedAchievements: unlockedAchievements ?? this.unlockedAchievements,
);
}
}
/// Conceito dominado pelo aluno
class MasteredConcept {
final String conceptName;
final DateTime masteredAt;
final int masteryLevel; // 0-100
const MasteredConcept({
required this.conceptName,
required this.masteredAt,
required this.masteryLevel,
});
factory MasteredConcept.fromFirestore(Map<String, dynamic> data) {
return MasteredConcept(
conceptName: data['conceptName'] ?? '',
masteredAt: (data['masteredAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
masteryLevel: data['masteryLevel'] ?? 0,
);
}
Map<String, dynamic> toFirestore() {
return {
'conceptName': conceptName,
'masteredAt': Timestamp.fromDate(masteredAt),
'masteryLevel': masteryLevel,
};
}
}
/// Conquista desbloqueada pelo aluno
class UnlockedAchievement {
final String achievementId;
final DateTime unlockedAt;
final Map<String, dynamic> metadata;
const UnlockedAchievement({
required this.achievementId,
required this.unlockedAt,
required this.metadata,
});
factory UnlockedAchievement.fromFirestore(Map<String, dynamic> data) {
return UnlockedAchievement(
achievementId: data['achievementId'] ?? '',
unlockedAt: (data['unlockedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
metadata: data['metadata'] ?? {},
);
}
Map<String, dynamic> toFirestore() {
return {
'achievementId': achievementId,
'unlockedAt': Timestamp.fromDate(unlockedAt),
'metadata': metadata,
};
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/theme_service.dart';
/// Provider for theme management
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
return ThemeNotifier();
});
/// Notifier for managing theme state
class ThemeNotifier extends StateNotifier<ThemeMode> {
ThemeNotifier() : super(ThemeMode.light) {
_initializeTheme();
}
/// Initialize theme from storage
Future<void> _initializeTheme() async {
try {
final storedTheme = await ThemeService.getThemeMode();
state = storedTheme;
} catch (e) {
state = ThemeMode.light;
}
}
/// Change theme mode
Future<void> setThemeMode(ThemeMode themeMode) async {
state = themeMode;
await ThemeService.setThemeMode(themeMode);
}
/// Toggle between light and dark mode
Future<void> toggleTheme() async {
final newTheme = state == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
await setThemeMode(newTheme);
}
/// Reset to default theme
Future<void> resetTheme() async {
await setThemeMode(ThemeMode.light);
}
/// Check if current theme is dark
bool isDarkMode() {
return state == ThemeMode.dark;
}
/// Check if current theme is light
bool isLightMode() {
return state == ThemeMode.light;
}
/// Get current theme as string
String get currentThemeString {
return ThemeService.getThemeModeString(state);
}
/// Initialize theme from storage (for future use)
Future<void> initializeTheme() async {
final storedTheme = await ThemeService.getStoredThemeMode();
// Only set if dark mode is available or if it's light mode
if (storedTheme == ThemeMode.light || ThemeService.isDarkModeAvailable()) {
state = storedTheme;
}
}
}
/// Provider for checking if dark mode is available
final isDarkModeAvailableProvider = Provider<bool>((ref) {
return ThemeService.isDarkModeAvailable();
});
/// Provider for current theme string
final themeStringProvider = Provider<String>((ref) {
final theme = ref.watch(themeProvider);
return ThemeService.getThemeModeString(theme);
});

View File

@@ -1,16 +1,24 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../shared/presentation/pages/not_found_page.dart';
import '../../features/settings/presentation/pages/settings_page.dart';
import '../../features/settings/presentation/pages/profile_edit_page.dart';
import '../../features/settings/presentation/pages/help_page.dart';
import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/auth/presentation/pages/signup_page.dart';
import '../../features/dashboard/presentation/pages/student_dashboard_page.dart';
import '../../features/dashboard/presentation/pages/teacher_dashboard_page.dart';
import '../../features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart';
import '../../features/ai_tutor/presentation/pages/chat_history_page.dart';
import '../../features/quiz/presentation/pages/quiz_list_page.dart';
import '../../features/quiz/presentation/pages/quiz_page.dart';
import '../../features/quiz/presentation/pages/teacher_quiz_page.dart';
import '../../features/profile/presentation/pages/profile_page.dart';
import '../../features/splash/presentation/pages/splash_page.dart';
import '../../features/auth/presentation/pages/role_selection_page.dart';
import '../../shared/presentation/pages/not_found_page.dart';
import '../../features/analytics/presentation/pages/analytics_page.dart';
import '../../features/achievements/presentation/pages/student_achievements_page.dart';
import '../../features/quiz/presentation/pages/quiz_management_page.dart';
/// App Router Configuration
class AppRouter {
@@ -24,6 +32,11 @@ class AppRouter {
static const String quizList = '/quiz';
static const String quiz = '/quiz/:quizId';
static const String profile = '/profile';
static const String settings = '/settings';
static const String teacherAnalytics = '/teacher/analytics';
static const String studentAchievements = '/student/achievements';
static const String quizManagement = '/quiz-management';
static const String chatHistory = '/chat-history';
// Nested route paths (without leading slash)
static const String tutorNested = 'tutor';
@@ -50,53 +63,30 @@ class AppRouter {
builder: (context, state) => const RoleSelectionPage(),
),
// Authentication Routes
// Login
GoRoute(
path: login,
name: 'login',
builder: (context, state) {
final selectedRole = state.uri.queryParameters['role'];
return LoginPage(selectedRole: selectedRole);
},
builder: (context, state) =>
LoginPage(selectedRole: state.uri.queryParameters['role']),
),
// Signup
GoRoute(
path: signup,
name: 'signup',
builder: (context, state) {
final selectedRole = state.uri.queryParameters['role'];
return SignupPage(selectedRole: selectedRole);
},
builder: (context, state) =>
SignupPage(selectedRole: state.uri.queryParameters['role']),
),
// Dashboard Routes
// Student Dashboard
GoRoute(
path: studentDashboard,
name: 'studentDashboard',
builder: (context, state) => const StudentDashboardPage(),
routes: [
// Nested routes for student features
GoRoute(
path: tutorNested,
name: 'studentTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
GoRoute(
path: quizListNested,
name: 'quizList',
builder: (context, state) => const QuizListPage(),
),
GoRoute(
path: quizNested,
name: 'quiz',
builder: (context, state) {
final quizId = state.pathParameters['quizId']!;
return QuizPage(quizId: quizId);
},
),
],
),
// Teacher Dashboard
GoRoute(
path: teacherDashboard,
name: 'teacherDashboard',
@@ -131,12 +121,85 @@ class AppRouter {
builder: (context, state) => const ProfilePage(),
),
// AI Tutor Route (independent)
// Settings Route
GoRoute(
path: settings,
name: 'settings',
builder: (context, state) => const SettingsPage(),
routes: [
// Profile Edit Route
GoRoute(
path: 'profile-edit',
name: 'profileEdit',
builder: (context, state) => const ProfileEditPage(),
),
// Help Route
GoRoute(
path: 'help',
name: 'help',
builder: (context, state) => const HelpPage(),
),
],
),
// AI Tutor Route with conversation ID (resume conversation) - MUST come before regular /ai-tutor route
GoRoute(
path: '$tutor/:conversationId',
name: 'aiTutorConversation',
builder: (context, state) {
final conversationId = state.pathParameters['conversationId']!;
return TutorChatPageSimple(conversationId: conversationId);
},
),
// AI Tutor Route (independent - new conversation)
GoRoute(
path: tutor,
name: 'aiTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
// Chat History Route
GoRoute(
path: chatHistory,
name: 'chatHistory',
builder: (context, state) => const ChatHistoryPage(),
),
// Teacher Analytics Route
GoRoute(
path: teacherAnalytics,
name: 'teacherAnalytics',
builder: (context, state) => const AnalyticsPage(),
),
// Student Achievements Route
GoRoute(
path: studentAchievements,
name: 'studentAchievements',
builder: (context, state) => const StudentAchievementsPage(),
),
// Quiz Management Route
GoRoute(
path: quizManagement,
name: 'quizManagement',
builder: (context, state) => const QuizManagementPage(),
),
// Quiz List Route (independent — student access)
GoRoute(
path: quizList,
name: 'quizList',
builder: (context, state) => const QuizListPage(),
),
// Teacher Quiz Create Route
GoRoute(
path: '/teacher/quiz/create',
name: 'teacherQuizCreate',
builder: (context, state) => const TeacherQuizPage(),
),
],
// Let splash screen handle all navigation logic
@@ -179,6 +242,10 @@ class AppRouter {
context.go(profile);
}
static void goToSettings(BuildContext context) {
context.go(settings);
}
static void goBack(BuildContext context) {
context.pop();
}

View File

@@ -13,13 +13,29 @@ class AuthService {
}
/// Criar documento do usuário na Firestore após signup
static Future<void> createUserRole(String uid, String role) async {
static Future<void> createUserRole(
String uid,
String role, {
String? classId,
String? schoolClassId,
String? displayName,
}) async {
try {
print('DEBUG: Criando documento users/$uid com role: $role');
await _firestore.collection('users').doc(uid).set({
final Map<String, dynamic> data = {
'role': role,
'createdAt': FieldValue.serverTimestamp(),
});
};
if (classId != null && classId.isNotEmpty) {
data['classId'] = classId;
}
if (schoolClassId != null && schoolClassId.isNotEmpty) {
data['schoolClassId'] = schoolClassId;
}
if (displayName != null && displayName.isNotEmpty) {
data['displayName'] = displayName;
}
await _firestore.collection('users').doc(uid).set(data);
print('DEBUG: Documento criado com sucesso');
} catch (e) {
print('DEBUG: Erro ao criar documento: $e');
@@ -27,6 +43,67 @@ class AuthService {
}
}
/// Criar inscrição do aluno na turma escolhida
static Future<void> createEnrollment({
required String studentId,
required String classId,
required String studentName,
}) async {
try {
print(
'DEBUG: Criando enrollment para student=$studentId, class=$classId',
);
final existing = await _firestore
.collection('enrollments')
.where('studentId', isEqualTo: studentId)
.where('classId', isEqualTo: classId)
.limit(1)
.get();
if (existing.docs.isNotEmpty) {
print('DEBUG: Enrollment já existe, ignorando');
return;
}
await _firestore.collection('enrollments').add({
'classId': classId,
'studentId': studentId,
'studentName': studentName,
'joinedAt': FieldValue.serverTimestamp(),
});
print('DEBUG: Enrollment criado com sucesso');
} catch (e) {
print('DEBUG: Erro ao criar enrollment: $e');
throw Exception('Erro ao associar aluno à turma');
}
}
/// Ler classId do aluno na Firestore
static Future<String?> getStudentClassId(String uid) async {
try {
final doc = await _firestore.collection('users').doc(uid).get();
if (doc.exists) {
return doc.data()?['classId'] as String?;
}
return null;
} catch (e) {
print('DEBUG: Erro ao ler classId: $e');
return null;
}
}
/// Ler schoolClassId do aluno na Firestore (turma escolar definida no registo)
static Future<String?> getStudentSchoolClassId(String uid) async {
try {
final doc = await _firestore.collection('users').doc(uid).get();
if (doc.exists) {
return doc.data()?['schoolClassId'] as String?;
}
return null;
} catch (e) {
print('DEBUG: Erro ao ler schoolClassId: $e');
return null;
}
}
/// Ler role do usuário na Firestore
static Future<String?> getUserRole(String uid) async {
try {
@@ -56,6 +133,8 @@ class AuthService {
required String password,
String? displayName,
String? role,
String? classId,
String? schoolClassId,
}) async {
try {
print('DEBUG: Tentando criar conta para email: $email');
@@ -76,9 +155,15 @@ class AuthService {
print('DEBUG: Display name atualizado para: $displayName');
}
// Criar documento na Firestore com role
// Criar documento na Firestore com role (e classId se aluno)
if (role != null && result.user != null) {
await createUserRole(result.user!.uid, role);
await createUserRole(
result.user!.uid,
role,
classId: classId,
schoolClassId: schoolClassId,
displayName: displayName,
);
}
// Verificar se o email foi verificado

View File

@@ -0,0 +1,487 @@
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: userChats/{userId}/conversations/{conversationId}/messages/{messageId}
class ChatMemoryService {
static final FirebaseFirestore _firestore = FirebaseFirestore.instance;
static final FirebaseAuth _auth = FirebaseAuth.instance;
/// Current active conversation ID
static String? _currentConversationId;
/// Get current conversation ID
static String? get currentConversationId => _currentConversationId;
/// Set current conversation ID
static void setCurrentConversationId(String? id) {
_currentConversationId = id;
}
/// Create a new conversation
static Future<String> createConversation({
required List<String> selectedMaterialIds,
String? title,
}) async {
final user = _auth.currentUser;
if (user == null) {
throw Exception('User not authenticated');
}
final conversationRef = await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.add({
'title': title ?? 'Nova conversa',
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
'selectedMaterialIds': selectedMaterialIds,
'messageCount': 0,
'hasUserMessage': false,
});
_currentConversationId = conversationRef.id;
Logger.info('Created new conversation: ${conversationRef.id}');
return conversationRef.id;
}
/// Update conversation materials
static Future<void> updateConversationMaterials({
required String conversationId,
required List<String> selectedMaterialIds,
}) async {
try {
await _firestore
.collection('userChats')
.doc(_auth.currentUser?.uid)
.collection('conversations')
.doc(conversationId)
.update({
'selectedMaterialIds': selectedMaterialIds,
'updatedAt': FieldValue.serverTimestamp(),
});
Logger.info('Updated materials for conversation: $conversationId');
} catch (e) {
Logger.error('Error updating conversation materials: $e');
}
}
/// Update conversation title
static Future<void> updateConversationTitle({
required String conversationId,
required String title,
}) async {
try {
await _firestore
.collection('userChats')
.doc(_auth.currentUser?.uid)
.collection('conversations')
.doc(conversationId)
.update({'title': title, 'updatedAt': FieldValue.serverTimestamp()});
Logger.info('Updated title for conversation: $conversationId');
} catch (e) {
Logger.error('Error updating conversation title: $e');
}
}
/// Get all conversations for current user
static Future<List<Map<String, dynamic>>> getConversations() async {
try {
final user = _auth.currentUser;
if (user == null) return [];
final snapshot = await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.orderBy('updatedAt', descending: true)
.get();
final conversations = snapshot.docs
.map((doc) {
final data = doc.data();
return {
'id': doc.id,
'title': data['title'] as String? ?? 'Sem título',
'createdAt': data['createdAt'] as Timestamp?,
'updatedAt': data['updatedAt'] as Timestamp?,
'selectedMaterialIds':
(data['selectedMaterialIds'] as List<dynamic>?)
?.cast<String>() ??
[],
'messageCount': data['messageCount'] as int? ?? 0,
'hasUserMessage': data['hasUserMessage'] as bool? ?? false,
};
})
.where((conv) => (conv['hasUserMessage'] as bool) == true)
.toList();
Logger.info('Retrieved ${conversations.length} conversations');
return conversations;
} catch (e) {
Logger.error('Error getting conversations: $e');
return [];
}
}
/// Delete a conversation
static Future<void> deleteConversation(String conversationId) async {
try {
final user = _auth.currentUser;
if (user == null) return;
// Delete all messages in the conversation
final messagesSnapshot = await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(conversationId)
.collection('messages')
.get();
final batch = _firestore.batch();
for (final doc in messagesSnapshot.docs) {
batch.delete(doc.reference);
}
// Delete the conversation document
batch.delete(
_firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(conversationId),
);
await batch.commit();
if (_currentConversationId == conversationId) {
_currentConversationId = null;
}
Logger.info('Deleted conversation: $conversationId');
} catch (e) {
Logger.error('Error deleting conversation: $e');
}
}
/// Save a message to Firestore
static Future<void> saveMessage({
required String role, // 'user' or 'assistant'
required String content,
String? conversationId,
}) async {
try {
final user = _auth.currentUser;
if (user == null) return;
final convId = conversationId ?? _currentConversationId;
if (convId == null) {
Logger.warning('No conversation ID, message not saved');
return;
}
final messageData = {
'role': role,
'content': content,
'createdAt': FieldValue.serverTimestamp(),
'userId': user.uid,
};
await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(convId)
.collection('messages')
.add(messageData);
// Update conversation metadata
final updateData = <String, dynamic>{
'updatedAt': FieldValue.serverTimestamp(),
'messageCount': FieldValue.increment(1),
};
// Set hasUserMessage to true if this is a user message
if (role == 'user') {
updateData['hasUserMessage'] = true;
}
await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(convId)
.update(updateData);
Logger.info(
'Message saved to Firestore: role=$role, conversation=$convId',
);
} catch (e) {
Logger.error('Error saving message: $e');
}
}
/// Get the last N messages from a specific conversation
/// Returns list of messages sorted by createdAt ascending (oldest first)
static Future<List<Map<String, dynamic>>> getConversationMessages({
required String conversationId,
int limit = 20,
}) async {
try {
final user = _auth.currentUser;
if (user == null) return [];
final snapshot = await _firestore
.collection('userChats')
.doc(user.uid)
.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();
Logger.info(
'Retrieved ${messages.length} messages from conversation $conversationId',
);
return messages;
} catch (e) {
Logger.error('Error getting conversation messages: $e');
return [];
}
}
/// Get conversation details
static Future<Map<String, dynamic>?> getConversation(
String conversationId,
) async {
try {
final user = _auth.currentUser;
if (user == null) return null;
final doc = await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(conversationId)
.get();
if (!doc.exists) return null;
final data = doc.data();
return {
'id': doc.id,
'title': data?['title'] as String? ?? 'Sem título',
'createdAt': data?['createdAt'] as Timestamp?,
'updatedAt': data?['updatedAt'] as Timestamp?,
'selectedMaterialIds':
(data?['selectedMaterialIds'] as List<dynamic>?)?.cast<String>() ??
[],
'messageCount': data?['messageCount'] as int? ?? 0,
'hasUserMessage': data?['hasUserMessage'] as bool? ?? false,
};
} catch (e) {
Logger.error('Error getting conversation: $e');
return null;
}
}
/// Build messages array for API request from current conversation
/// Returns list of message maps with 'role' and 'content' keys
static Future<List<Map<String, String>>> buildMessagesForAPI({
required String currentUserMessage,
String? conversationId,
int historyLimit = 20,
}) async {
final messages = <Map<String, String>>[];
final convId = conversationId ?? _currentConversationId;
if (convId != null) {
// Get recent conversation history
final history = await getConversationMessages(
conversationId: convId,
limit: historyLimit,
);
// Add historical messages
for (final msg in history) {
messages.add({
'role': msg['role'] as String,
'content': msg['content'] as String,
});
}
}
// Add current user message
messages.add({'role': 'user', 'content': currentUserMessage});
Logger.info(
'Built messages array with ${messages.length} messages for API',
);
return messages;
}
/// Clear current conversation history
static Future<void> clearHistory() async {
try {
final user = _auth.currentUser;
if (user == null) return;
final convId = _currentConversationId;
if (convId == null) return;
final snapshot = await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(convId)
.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();
// Reset message count
await _firestore
.collection('userChats')
.doc(user.uid)
.collection('conversations')
.doc(convId)
.update({'messageCount': 0});
Logger.info('Conversation history cleared');
} catch (e) {
Logger.error('Error clearing history: $e');
}
}
/// Generate a smart title from the first message by extracting keywords
static String generateConversationTitle(String firstMessage) {
// Remove common Portuguese stopwords and greetings
final stopwords = {
'olá',
'oi',
'bom dia',
'boa tarde',
'boa noite',
'consegues',
'podes',
'pode',
'poderia',
'poderias',
'explicar',
'explica',
'explicar-me',
'explicar-lhe',
'sobre',
'acerca de',
'a respeito de',
'relativamente a',
'o que é',
'o que são',
'qual é',
'quais são',
'como',
'onde',
'quando',
'porquê',
'por que',
'quero',
'quero que',
'gostaria',
'gostaria de',
'preciso',
'preciso de',
'ajuda',
'ajuda-me',
'me',
'te',
'lhe',
'nos',
'vos',
'um',
'uma',
'uns',
'umas',
'e',
'ou',
'mas',
'porém',
'todavia',
'para',
'de',
'em',
'a',
'o',
'as',
'os',
'que',
'quem',
'qual',
'quais',
'é',
'são',
'está',
'estão',
'foi',
'foram',
'?',
'!',
'.',
',',
';',
':',
};
// Convert to lowercase and remove punctuation
final cleaned = firstMessage
.toLowerCase()
.replaceAll(RegExp(r'[?!.;,]'), '')
.trim();
// Split into words and remove stopwords
final words = cleaned
.split(' ')
.where((word) => word.isNotEmpty && !stopwords.contains(word))
.toList();
// Take the first 2-3 meaningful words as the title
if (words.isEmpty) {
return firstMessage.length > 30
? '${firstMessage.substring(0, 30)}...'
: firstMessage;
}
final titleWords = words.take(3).join(' ');
// Capitalize first letter of each word
final title = titleWords
.split(' ')
.map((word) {
if (word.isEmpty) return '';
return word[0].toUpperCase() + word.substring(1);
})
.join(' ');
return title.length > 40 ? '${title.substring(0, 40)}...' : title;
}
}

View File

@@ -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<String> uploadContent({

View File

@@ -0,0 +1,902 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../models/user_stats.dart';
import '../models/class_stats.dart';
import '../models/achievement.dart';
import '../utils/logger.dart';
/// Serviço para gerenciar gamificação e conquistas
class GamificationService {
static final FirebaseFirestore _firestore = FirebaseFirestore.instance;
static final FirebaseAuth _auth = FirebaseAuth.instance;
/// Atualizar streak diário do usuário
static Future<void> updateDailyStreak(String userId) async {
try {
final userStatsRef = _firestore.collection('users').doc(userId);
final userStatsDoc = await userStatsRef.get();
if (!userStatsDoc.exists) {
// Criar estatísticas iniciais
await userStatsRef.set({
'userId': userId,
'currentStreak': 1,
'longestStreak': 1,
'totalStudyTime': 0,
'lastActivityDate': Timestamp.now(),
'weeklyStudyTime': 0,
'monthlyStudyTime': 0,
'masteredConcepts': [],
'unlockedAchievements': [],
});
return;
}
final userStats = UserStats.fromFirestore(userStatsDoc.data()!, userId);
final now = DateTime.now();
final lastActivity = userStats.lastActivityDate;
int newStreak = userStats.currentStreak;
int newLongestStreak = userStats.longestStreak;
Logger.info('=== UPDATE DAILY STREAK DEBUG ===');
Logger.info('Last activity: $lastActivity');
Logger.info('Current streak before update: $newStreak');
if (lastActivity == null) {
// Primeira atividade - iniciar streak
Logger.info('First activity detected - setting streak to 1');
newStreak = 1;
newLongestStreak = 1;
} else {
// Normalizar para início do dia para comparação correta
final today = DateTime(now.year, now.month, now.day);
final lastDay = DateTime(
lastActivity.year,
lastActivity.month,
lastActivity.day,
);
final difference = today.difference(lastDay).inDays;
if (difference == 0) {
// Já ativou hoje, não alterar streak mas atualiza timestamp
// Corrigir streak se estiver em 0 (erro de dados)
if (newStreak == 0) {
newStreak = 1;
newLongestStreak = 1;
Logger.info('Correcting invalid streak (0) to 1');
await userStatsRef.update({
'currentStreak': 1,
'longestStreak': 1,
'lastActivityDate': Timestamp.now(),
});
} else {
Logger.info('Already active today, streak unchanged: $newStreak');
await userStatsRef.update({'lastActivityDate': Timestamp.now()});
}
return;
} else if (difference == 1) {
Logger.info('Consecutive activity detected, incrementing streak');
// Atividade consecutiva
newStreak++;
newLongestStreak = newStreak > newLongestStreak
? newStreak
: newLongestStreak;
} else {
// Quebrou o streak
newStreak = 1;
newLongestStreak = newStreak > newLongestStreak
? newStreak
: newLongestStreak;
}
}
Logger.info('Updating streak to: $newStreak');
await userStatsRef.update({
'currentStreak': newStreak,
'longestStreak': newLongestStreak,
'lastActivityDate': Timestamp.now(),
});
Logger.info('Streak updated successfully');
// Verificar conquistas de streak
await _checkStreakAchievements(userId, newStreak);
Logger.info('=== END UPDATE DAILY STREAK DEBUG ===');
} catch (e) {
Logger.error('Error updating daily streak: $e');
}
}
/// Registrar tempo de estudo
static Future<void> recordStudyTime(String userId, int minutes) async {
try {
final userStatsRef = _firestore.collection('users').doc(userId);
final userStatsDoc = await userStatsRef.get();
if (!userStatsDoc.exists) {
await _createInitialUserStats(userId);
}
final userStats = UserStats.fromFirestore(userStatsDoc.data()!, userId);
final newTotalTime = userStats.totalStudyTime + minutes;
await userStatsRef.update({
'totalStudyTime': newTotalTime,
'weeklyStudyTime': FieldValue.increment(minutes),
'monthlyStudyTime': FieldValue.increment(minutes),
});
// Verificar conquistas de tempo de estudo
await _checkStudyTimeAchievements(userId, newTotalTime);
} catch (e) {
Logger.error('Error recording study time: $e');
}
}
/// Registrar atividade de quiz
static Future<void> recordQuizActivity(
String userId, {
required int score,
required int totalQuestions,
required String materialName,
}) async {
try {
final userStatsRef = _firestore.collection('users').doc(userId);
// Atualizar streak
await updateDailyStreak(userId);
// Tempo de estudo agora é calculado em tempo real no quiz sheet
// Não adicionamos tempo fixo aqui
// Verificar conquistas de quiz
await _checkQuizAchievements(userId, score, totalQuestions);
// Incrementar contador de quizzes completos
await userStatsRef.update({'completedQuizzes': FieldValue.increment(1)});
Logger.info('Incremented completed quizzes count');
// Atualizar conceitos dominados se score >= 50%
Logger.info(
'Checking if score qualifies for mastered concept: ${score / totalQuestions >= 0.5}',
);
if (score / totalQuestions >= 0.5) {
Logger.info(
'Adding mastered concept: $materialName with score: $score',
);
await _addMasteredConcept(userId, materialName, score);
} else {
Logger.info(
'Score too low for mastered concept: $score/$totalQuestions',
);
}
Logger.info(
'Quiz activity recorded for user $userId: $score/$totalQuestions',
);
} catch (e) {
Logger.error('Error recording quiz activity: $e');
}
}
/// Obter estatísticas do usuário
static Future<UserStats?> getUserStats(String userId) async {
try {
final doc = await _firestore.collection('users').doc(userId).get();
if (!doc.exists) {
// Criar estatísticas iniciais se não existirem
await _initializeUserStats(userId);
return await getUserStats(
userId,
); // Chamada recursiva após inicialização
}
final data = doc.data() as Map<String, dynamic>;
// Garantir que completedQuizzes exista
if (!data.containsKey('completedQuizzes')) {
await _firestore.collection('users').doc(userId).update({
'completedQuizzes': 0,
});
data['completedQuizzes'] = 0;
}
return UserStats.fromFirestore(data, userId);
} catch (e) {
Logger.error('Error getting user stats: $e');
return null;
}
}
/// Obter estatísticas da turma
static Future<ClassStats?> getClassStats(
String classId, {
bool forceRefresh = false,
}) async {
try {
if (forceRefresh) {
// Forçar recálculo completo
return await _calculateClassStats(classId);
}
final classStatsDoc = await _firestore
.collection('classStats')
.doc(classId)
.get();
if (!classStatsDoc.exists) {
return await _calculateClassStats(classId);
}
// Verificar se os dados estão desatualizados (mais de 1 hora)
final data = classStatsDoc.data()!;
final lastUpdated = data['lastUpdated'] as Timestamp?;
if (lastUpdated == null ||
DateTime.now().difference(lastUpdated.toDate()).inHours > 1) {
return await _calculateClassStats(classId);
}
return ClassStats.fromFirestore(data, classId);
} catch (e) {
Logger.error('Error getting class stats: $e');
return null;
}
}
/// Forçar atualização de estatísticas de todas as turmas de um professor
static Future<void> refreshAllClassStats(String teacherId) async {
try {
final classesSnapshot = await _firestore
.collection('classes')
.where('teacherId', isEqualTo: teacherId)
.get();
for (final classDoc in classesSnapshot.docs) {
await _calculateClassStats(classDoc.id);
}
Logger.info('Refreshed stats for ${classesSnapshot.docs.length} classes');
} catch (e) {
Logger.error('Error refreshing class stats: $e');
}
}
/// Obter ranking de alunos da turma
static Future<List<StudentRanking>> getClassRanking(String classId) async {
try {
// Primeiro, obter todos os alunos matriculados na turma
final enrollmentsSnapshot = await _firestore
.collection('enrollments')
.where('classId', isEqualTo: classId)
.get();
if (enrollmentsSnapshot.docs.isEmpty) {
Logger.info('No students enrolled in class $classId');
return [];
}
final studentIds = enrollmentsSnapshot.docs
.map((doc) => doc['studentId'] as String)
.toList();
final rankings = <StudentRanking>[];
// Obter número real de quizzes disponíveis na turma
final quizzesSnapshot = await _firestore
.collection('teacherQuizzes')
.where('classIds', arrayContains: classId)
.get();
final totalAvailableQuizzes = quizzesSnapshot.docs.length;
// Para cada aluno, obter suas estatísticas
for (final studentId in studentIds) {
try {
final userStats = await getUserStats(studentId);
if (userStats != null) {
// Obter informações do usuário
final userDoc = await _firestore
.collection('users')
.doc(studentId)
.get();
final userData = userDoc.data() as Map<String, dynamic>?;
// Calcular estatísticas para o ranking
final completedQuizzes = userStats.completedQuizzes;
final totalQuizzes = totalAvailableQuizzes > 0
? totalAvailableQuizzes
: 1;
final quizCompletionRate = completedQuizzes / totalQuizzes;
Logger.info('=== RANKING SCORE DEBUG ===');
Logger.info('Student ID: $studentId');
Logger.info('Completed quizzes: $completedQuizzes');
Logger.info('Total quizzes: $totalQuizzes');
Logger.info(
'Quiz completion rate: $quizCompletionRate (${(quizCompletionRate * 100).toInt()}%)',
);
Logger.info('Current streak: ${userStats.currentStreak}');
Logger.info(
'Total study time: ${userStats.totalStudyTime} minutes',
);
Logger.info(
'Mastered concepts: ${userStats.masteredConcepts.length}',
);
Logger.info(
'Unlocked achievements: ${userStats.unlockedAchievements.length}',
);
// Calcular score geral baseado em múltiplos fatores
final overallScore = _calculateOverallScore(
userStats,
quizCompletionRate,
);
Logger.info(
'Overall score calculated: $overallScore (${overallScore.toInt()}%)',
);
Logger.info('=== END RANKING SCORE DEBUG ===');
// Tentar obter um nome melhor para o aluno
String studentName = 'Aluno $studentId';
if (userData != null) {
studentName =
userData['displayName'] ??
userData['email']?.split('@')[0] ??
'Aluno ${studentId.substring(0, 8)}...';
}
rankings.add(
StudentRanking(
studentId: studentId,
studentName: studentName,
studentEmail: userData?['email'] ?? '',
overallScore: overallScore,
completedQuizzes: completedQuizzes,
totalQuizzes: totalQuizzes,
quizCompletionRate: quizCompletionRate,
currentStreak: userStats.currentStreak,
studyTimeMinutes: userStats.totalStudyTime,
lastActivity: userStats.lastActivityDate ?? DateTime.now(),
),
);
}
} catch (e) {
Logger.error('Error getting stats for student $studentId: $e');
continue;
}
}
// Ordenar por score geral
rankings.sort((a, b) => b.overallScore.compareTo(a.overallScore));
return rankings;
} catch (e) {
Logger.error('Error getting class ranking: $e');
return [];
}
}
/// Calcular score geral para ranking
static double _calculateOverallScore(
UserStats userStats,
double quizCompletionRate,
) {
// Se completou 100% dos quizzes, score é 100%
if (quizCompletionRate >= 1.0) {
return 100.0;
}
// Para completion < 100%, calcular proporcionalmente
double baseScore = quizCompletionRate * 90; // 90% baseado em completion
// Bônus adicionais (máximo 10% extra)
double bonusScore = 0.0;
// 5% para conceitos dominados
bonusScore += (userStats.masteredConcepts.length / 5.0 * 5).clamp(0.0, 5.0);
// 3% para streak
bonusScore += (userStats.currentStreak / 7.0 * 3).clamp(0.0, 3.0);
// 2% para conquistas
bonusScore += (userStats.unlockedAchievements.length / 10.0 * 2).clamp(
0.0,
2.0,
);
final totalScore = baseScore + bonusScore;
return totalScore.clamp(0.0, 100.0);
}
/// Criar conquista personalizada (professor)
static Future<String> createCustomAchievement({
required String teacherId,
required String name,
required String description,
required String icon,
required String category,
required AchievementRequirement requirements,
required int points,
required String rarity,
}) async {
try {
final achievementRef = await _firestore.collection('achievements').add({
'name': name,
'description': description,
'icon': icon,
'category': category,
'requirements': requirements.toFirestore(),
'points': points,
'rarity': rarity,
'isActive': true,
'createdAt': Timestamp.now(),
'createdBy': teacherId,
});
Logger.info('Custom achievement created: ${achievementRef.id}');
return achievementRef.id;
} catch (e) {
Logger.error('Error creating custom achievement: $e');
rethrow;
}
}
/// Obter conquistas disponíveis
static Future<List<Achievement>> getAvailableAchievements({
String? teacherId,
}) async {
try {
// Sempre incluir conquistas do sistema
List<Achievement> achievements = List.from(
SystemAchievements.defaultAchievements,
);
// Adicionar conquistas personalizadas do professor
Query query = _firestore
.collection('achievements')
.where('isActive', isEqualTo: true);
if (teacherId != null) {
query = query.where('createdBy', isEqualTo: teacherId);
}
final snapshot = await query.get();
achievements.addAll(
snapshot.docs
.map(
(doc) => Achievement.fromFirestore(
doc.data() as Map<String, dynamic>,
doc.id,
),
)
.toList(),
);
Logger.info('Total achievements loaded: ${achievements.length}');
return achievements;
} catch (e) {
Logger.error('Error getting available achievements: $e');
return SystemAchievements.defaultAchievements;
}
}
/// Métodos privados
static Future<void> _createInitialUserStats(String userId) async {
await _firestore.collection('users').doc(userId).set({
'userId': userId,
'currentStreak': 0,
'longestStreak': 0,
'totalStudyTime': 0,
'lastActivityDate':
null, // null para que primeira atividade inicie streak
'weeklyStudyTime': 0,
'monthlyStudyTime': 0,
'masteredConcepts': [],
'unlockedAchievements': [],
});
}
static Future<void> _addMasteredConcept(
String userId,
String conceptName,
int score,
) async {
try {
final userStatsRef = _firestore.collection('users').doc(userId);
final userStatsDoc = await userStatsRef.get();
if (!userStatsDoc.exists) return;
final userStats = UserStats.fromFirestore(userStatsDoc.data()!, userId);
// Verificar se conceito já está dominado
final existingConcept = userStats.masteredConcepts
.where((c) => c.conceptName == conceptName)
.firstOrNull;
if (existingConcept != null) {
// Atualizar nível de maestria se score maior
if (score > existingConcept.masteryLevel) {
final updatedConcepts = userStats.masteredConcepts.map((c) {
if (c.conceptName == conceptName) {
return MasteredConcept(
conceptName: conceptName,
masteredAt: DateTime.now(),
masteryLevel: score,
);
}
return c;
}).toList();
await userStatsRef.update({
'masteredConcepts': updatedConcepts
.map((c) => c.toFirestore())
.toList(),
});
}
} else {
// Adicionar novo conceito
final newConcept = MasteredConcept(
conceptName: conceptName,
masteredAt: DateTime.now(),
masteryLevel: score,
);
await userStatsRef.update({
'masteredConcepts': FieldValue.arrayUnion([newConcept.toFirestore()]),
});
}
} catch (e) {
Logger.error('Error adding mastered concept: $e');
}
}
static Future<ClassStats> _calculateClassStats(String classId) async {
try {
// Obter informações da turma
final classDoc = await _firestore
.collection('classes')
.doc(classId)
.get();
if (!classDoc.exists) {
throw Exception('Class not found');
}
final className = classDoc.data()?['name'] ?? 'Unknown Class';
final teacherId = classDoc.data()?['teacherId'] ?? '';
// Obter alunos matriculados
final enrollmentsSnapshot = await _firestore
.collection('enrollments')
.where('classId', isEqualTo: classId)
.get();
final studentIds = enrollmentsSnapshot.docs
.map((doc) => doc.data()['studentId'] as String)
.toList();
// Calcular estatísticas
int activeStudents = 0;
double totalProgress = 0;
List<StudentNeedingSupport> needingSupport = [];
for (final studentId in studentIds) {
final userStats = await getUserStats(studentId);
// Verificar se está ativo (atividade nos últimos 30 dias - mais realista)
int daysSinceLastActivity = 999; // Valor alto para inatividade
bool hasStats = userStats != null;
if (hasStats && userStats!.lastActivityDate != null) {
daysSinceLastActivity = DateTime.now()
.difference(userStats.lastActivityDate!)
.inDays;
if (daysSinceLastActivity <= 30) {
activeStudents++;
}
}
// Calcular progresso baseado em quizzes completos e conceitos dominados
double progress = 0.0;
if (hasStats) {
final completedQuizzes = userStats!.completedQuizzes;
final masteredConcepts = userStats.masteredConcepts.length;
Logger.info('=== PROGRESS CALCULATION DEBUG ===');
Logger.info('Student ID: $studentId');
Logger.info('Completed quizzes: $completedQuizzes');
Logger.info('Mastered concepts: $masteredConcepts');
// Progresso mais representativo: 60% quizzes + 40% conceitos
// Primeiro quiz já dá 30% de progresso (incentivo inicial)
final quizProgress = completedQuizzes > 0
? (0.3 + (completedQuizzes - 1) * 0.15).clamp(0.0, 0.6)
: 0.0;
// Primeiro conceito já dá 15% de progresso
final conceptProgress = (masteredConcepts * 0.15).clamp(0.0, 0.4);
progress = quizProgress + conceptProgress;
Logger.info(
'Quiz progress: $quizProgress (${(quizProgress * 100).toInt()}%)',
);
Logger.info(
'Concept progress: $conceptProgress (${(conceptProgress * 100).toInt()}%)',
);
Logger.info(
'Total progress: $progress (${(progress * 100).toInt()}%)',
);
Logger.info('=== END PROGRESS CALCULATION DEBUG ===');
} else {
Logger.info('Student $studentId has no stats - progress = 0.0');
}
totalProgress += progress;
// Verificar se precisa de apoio (ajustado para nova fórmula)
if (progress < 0.25 || daysSinceLastActivity > 30) {
final userDoc = await _firestore
.collection('users')
.doc(studentId)
.get();
final userData = userDoc.data();
// Tentar obter um nome melhor para o aluno
String studentName = 'Aluno ${studentId.substring(0, 8)}...';
if (userData != null) {
studentName =
userData['displayName'] ??
userData['email']?.split('@')[0] ??
'Aluno ${studentId.substring(0, 8)}...';
}
needingSupport.add(
StudentNeedingSupport(
studentId: studentId,
studentName: studentName,
reason: progress < 0.3 ? 'low_scores' : 'inactivity',
lastActivity: hasStats
? userStats!.lastActivityDate ?? DateTime.now()
: DateTime.now().subtract(const Duration(days: 45)),
averageScore: progress * 100,
),
);
}
}
final averageProgress = studentIds.isEmpty
? 0.0
: totalProgress / studentIds.length;
Logger.info('=== AVERAGE PROGRESS DEBUG ===');
Logger.info('Total students: ${studentIds.length}');
Logger.info('Total progress sum: $totalProgress');
Logger.info(
'Average progress: $averageProgress (${(averageProgress * 100).toInt()}%)',
);
Logger.info('=== END AVERAGE PROGRESS DEBUG ===');
// Obter estatísticas de quizzes e conteúdos
final quizzesSnapshot = await _firestore
.collection('teacherQuizzes')
.where('classIds', arrayContains: classId)
.get();
// Obter materiais/conteúdos da turma
final materialsSnapshot = await _firestore
.collection('materials')
.where('classId', isEqualTo: classId)
.get();
// Contar quizzes ativos (últimos 30 dias)
final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30));
final activeQuizzesCount = quizzesSnapshot.docs.where((doc) {
final createdAt = (doc.data()['createdAt'] as Timestamp?)?.toDate();
return createdAt != null && createdAt.isAfter(thirtyDaysAgo);
}).length;
final classStats = ClassStats(
classId: classId,
teacherId: teacherId,
className: className,
totalStudents: studentIds.length,
activeStudents: activeStudents,
averageProgress: averageProgress,
totalQuizzes: quizzesSnapshot.docs.length,
activeQuizzes: activeQuizzesCount,
totalContent: materialsSnapshot.docs.length,
weeklyStats: [],
studentsNeedingSupport: needingSupport,
lastUpdated: DateTime.now(),
);
// Limpar cache primeiro e depois salvar estatísticas calculadas
await _firestore.collection('classStats').doc(classId).delete();
await _firestore
.collection('classStats')
.doc(classId)
.set(classStats.toFirestore());
Logger.info('Class stats refreshed and saved for class $classId');
return classStats;
} catch (e) {
Logger.error('Error calculating class stats: $e');
rethrow;
}
}
static Future<void> _checkStreakAchievements(
String userId,
int streakDays,
) async {
final achievements = await getAvailableAchievements();
final streakAchievements = achievements.where(
(a) => a.category == 'streak',
);
for (final achievement in streakAchievements) {
if (achievement.requirements.checkCondition(streakDays)) {
await _unlockAchievement(userId, achievement.id);
}
}
}
static Future<void> _checkStudyTimeAchievements(
String userId,
int totalMinutes,
) async {
final achievements = await getAvailableAchievements();
final studyAchievements = achievements.where(
(a) => a.category == 'study_time',
);
for (final achievement in studyAchievements) {
if (achievement.requirements.checkCondition(totalMinutes)) {
await _unlockAchievement(userId, achievement.id);
}
}
}
static Future<void> _checkQuizAchievements(
String userId,
int score,
int totalQuestions,
) async {
Logger.info('=== CHECKING QUIZ ACHIEVEMENTS ===');
Logger.info('Score: $score/$totalQuestions');
final achievements = await getAvailableAchievements();
final userStats = await getUserStats(userId);
if (userStats == null) {
Logger.error('User stats null for achievement checking');
return;
}
final percentage = (score / totalQuestions) * 100;
// Usar contador real de quizzes completos
final completedQuizzes = userStats.completedQuizzes;
Logger.info('Percentage: $percentage%');
Logger.info('Completed quizzes: $completedQuizzes');
Logger.info('Mastered concepts: ${userStats.masteredConcepts.length}');
Logger.info('Available achievements: ${achievements.length}');
for (final achievement in achievements) {
if (achievement.category == 'quiz' &&
achievement.requirements.type == 'quiz_score' &&
achievement.requirements.checkCondition(percentage)) {
await _unlockAchievement(userId, achievement.id);
} else if (achievement.category == 'quiz_count' &&
achievement.requirements.type == 'quiz_completion' &&
achievement.requirements.checkCondition(completedQuizzes)) {
await _unlockAchievement(userId, achievement.id);
} else if (achievement.category == 'quiz' &&
achievement.requirements.type == 'quiz_completion' &&
achievement.id == 'first_quiz' &&
achievement.requirements.checkCondition(1)) {
await _unlockAchievement(userId, achievement.id);
} else if (achievement.category == 'concept' &&
achievement.requirements.type == 'concepts_mastered' &&
achievement.requirements.checkCondition(
userStats.masteredConcepts.length,
)) {
await _unlockAchievement(userId, achievement.id);
} else {
Logger.info(
'Achievement not matched: ${achievement.id} - category: ${achievement.category}, type: ${achievement.requirements.type}',
);
}
}
Logger.info('=== END CHECKING QUIZ ACHIEVEMENTS ===');
}
static Future<void> _unlockAchievement(
String userId,
String achievementId,
) async {
try {
Logger.info('=== ATTEMPTING TO UNLOCK ACHIEVEMENT ===');
Logger.info('Achievement ID: $achievementId');
Logger.info('User ID: $userId');
final userStatsRef = _firestore.collection('users').doc(userId);
final userStatsDoc = await userStatsRef.get();
if (!userStatsDoc.exists) {
Logger.error('User stats document does not exist for user $userId');
return;
}
final userStats = UserStats.fromFirestore(userStatsDoc.data()!, userId);
Logger.info(
'Current unlocked achievements count: ${userStats.unlockedAchievements.length}',
);
// Verificar se já desbloqueou
final alreadyUnlocked = userStats.unlockedAchievements.any(
(a) => a.achievementId == achievementId,
);
Logger.info('Already unlocked: $alreadyUnlocked');
if (!alreadyUnlocked) {
final unlockedAchievement = UnlockedAchievement(
achievementId: achievementId,
unlockedAt: DateTime.now(),
metadata: {},
);
Logger.info('Adding achievement to Firestore...');
await userStatsRef.update({
'unlockedAchievements': FieldValue.arrayUnion([
unlockedAchievement.toFirestore(),
]),
});
Logger.info(
'Achievement unlocked successfully: $achievementId for user $userId',
);
} else {
Logger.info(
'Achievement $achievementId already unlocked for user $userId',
);
}
} catch (e) {
Logger.error('Error unlocking achievement: $e');
}
}
/// Inicializar estatísticas do usuário
static Future<void> _initializeUserStats(String userId) async {
try {
final userStatsRef = _firestore.collection('users').doc(userId);
// Verificar se já existe
final doc = await userStatsRef.get();
if (doc.exists) {
// Apenas atualizar com completedQuizzes se não existir
final data = doc.data() as Map<String, dynamic>;
if (!data.containsKey('completedQuizzes')) {
await userStatsRef.update({'completedQuizzes': 0});
}
} else {
// Criar documento inicial
await userStatsRef.set({
'completedQuizzes': 0,
'currentStreak': 0,
'longestStreak': 0,
'totalStudyTime': 0,
'weeklyStudyTime': 0,
'monthlyStudyTime': 0,
'masteredConcepts': [],
'unlockedAchievements': [],
});
}
Logger.info('User stats initialized for user $userId');
} catch (e) {
Logger.error('Error initializing user stats: $e');
}
}
}

View File

@@ -0,0 +1,684 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:syncfusion_flutter_pdf/pdf.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 = {};
/// Número máximo de janelas de contexto a enviar ao modelo
static const int _maxRelevantChunks = 5;
/// Listar materiais disponíveis para o aluno autenticado
/// Retorna apenas materiais cujo classId corresponde a uma turma onde o aluno está inscrito
static Future<List<Map<String, String>>>
getAvailableMaterialsForStudent() async {
try {
final user = _auth.currentUser;
if (user == null) return [];
final uid = user.uid;
// 1. Buscar classIds das inscrições do aluno
final enrollmentSnapshot = await _firestore
.collection('enrollments')
.where('studentId', isEqualTo: uid)
.get();
final enrolledClassIds = enrollmentSnapshot.docs
.map((doc) => doc.data()['classId'] as String?)
.where((id) => id != null)
.cast<String>()
.toSet();
if (enrolledClassIds.isEmpty) return [];
// 2. Buscar teacher IDs dessas turmas
final teacherIds = await _getTeacherIdsForStudent(uid);
if (teacherIds.isEmpty) return [];
// 3. Buscar todos os materiais desses professores
final teacherIdList = teacherIds.take(10).toList();
final snapshot = await _firestore
.collection('materials')
.where('teacherId', whereIn: teacherIdList)
.orderBy('createdAt', descending: true)
.get();
// 4. Filtrar: manter apenas materiais cujo classId está nas turmas do aluno
// ou materiais sem classId (compatibilidade com uploads antigos)
final result = <Map<String, String>>[];
for (final doc in snapshot.docs) {
final data = doc.data();
final classId = data['classId'] as String?;
if (classId == null || enrolledClassIds.contains(classId)) {
final fileName = data['fileName'] as String? ?? 'Material';
final teacherId = data['teacherId'] as String?;
final url = data['url'] as String?;
result.add({
'id': doc.id,
'name': fileName,
if (classId != null) 'classId': classId,
if (teacherId != null) 'teacherId': teacherId,
if (url != null) 'url': url,
});
}
}
Logger.info('Available materials for student: ${result.length}');
return result;
} catch (e) {
Logger.error('Error getting available materials for student: $e');
return [];
}
}
/// RAG CHUNK RETRIEVAL - Versão correta
/// Busca chunks relevantes dos PDFs com base na query do usuário
/// Se [selectedMaterialIds] for fornecido e não vazio, filtra apenas esses materiais
/// Se [filterTableData] for true, remove dados de tabelas/gráficos do conteúdo
static Future<String> getRelevantChunks({
required String userQuery,
int maxMaterials = 5,
int maxChunks = 5,
List<String>? selectedMaterialIds,
bool filterTableData = false,
}) async {
try {
final user = _auth.currentUser;
if (user == null) {
Logger.warning('No authenticated user for materials context');
return '';
}
if (selectedMaterialIds != null && selectedMaterialIds.isNotEmpty) {
// Usar apenas os materiais selecionados pelo aluno
Logger.info('Fetching selected materials: $selectedMaterialIds');
final batches = <Future<QuerySnapshot>>[];
for (int i = 0; i < selectedMaterialIds.length; i += 10) {
final batch = selectedMaterialIds.skip(i).take(10).toList();
batches.add(
_firestore
.collection('materials')
.where(FieldPath.documentId, whereIn: batch)
.get(),
);
}
final results = await Future.wait(batches);
final allDocs = results.expand((s) => s.docs).toList();
Logger.info('Selected materials found: ${allDocs.length}');
// Processar directamente — sem chunking para não triplicar o texto em memória
final contextBuffer = StringBuffer();
contextBuffer.writeln('Contexto dos materiais do professor:');
bool hasContent = false;
for (final doc in allDocs) {
final data = doc.data() as Map<String, dynamic>;
final fileName = data['fileName'] as String?;
if (fileName == null) continue;
if (!fileName.toLowerCase().endsWith('.pdf')) continue;
// Usar cache do texto completo se disponível (sufixo v2 invalida caches antigos)
final cacheKey = '${fileName}_v6';
String fullText;
if (_chunksCache.containsKey(cacheKey) &&
_chunksCache[cacheKey]!.isNotEmpty) {
fullText = _chunksCache[cacheKey]!.first;
Logger.info(
'Using cached text for $fileName: ${fullText.length} chars',
);
} else {
try {
final teacherId = data['teacherId'] as String?;
if (teacherId == null) continue;
final rawText = await _extractFullText(fileName, teacherId);
if (rawText.isEmpty) continue;
// Colapsar whitespace excessivo (PDFs de layout decorativo geram muitos \n)
String cleaned = rawText
.replaceAll(RegExp(r'[ \t]+'), ' ')
.replaceAll(RegExp(r'\n{2,}'), '\n')
.trim();
// Tentar corrigir encoding LaTeX corrompido (Type1/OTF sem mapeamento Unicode)
cleaned = cleaned
.replaceAll('¸c˜ao', 'ção')
.replaceAll('˜ao', 'ão')
.replaceAll('¸c˜oes', 'ções')
.replaceAll('˜oes', 'ões')
.replaceAll('¸c', 'ç')
.replaceAll('´a', 'á')
.replaceAll('´e', 'é')
.replaceAll('´i', 'í')
.replaceAll('´o', 'ó')
.replaceAll('´u', 'ú')
.replaceAll('ˆa', 'â')
.replaceAll('ˆe', 'ê')
.replaceAll('ˆo', 'ô')
.replaceAll('`a', 'à');
// Reconstruir espaços em texto colado (LaTeX sem ToUnicode map):
// inserir espaço antes de maiúscula precedida de minúscula/dígito
cleaned = cleaned.replaceAllMapped(
RegExp(r'([a-záéíóúàâêôãõç\d])([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ])'),
(m) => '${m.group(1)} ${m.group(2)}',
);
// inserir espaço entre dígito e letra
cleaned = cleaned.replaceAllMapped(
RegExp(r'(\d)([A-Za-záéíóúàâêôãõç])'),
(m) => '${m.group(1)} ${m.group(2)}',
);
fullText = cleaned;
// Guardar texto completo no cache com key versionada
_chunksCache[cacheKey] = [fullText];
Logger.info(
'PDF "$fileName" -> ${fullText.length} chars extracted',
);
} catch (e) {
Logger.error('Error extracting text from $fileName: $e');
continue;
}
}
// PDFs pequenos: enviar texto completo (formulários, notas, etc.)
// PDFs grandes: keyword window search para não sobrecarregar o modelo
String context;
if (fullText.length <= 10000) {
context = fullText;
Logger.info(
'Small PDF — sending full text (${fullText.length} chars)',
);
} else {
final windows = _extractKeywordWindows(
fullText,
userQuery,
_maxRelevantChunks,
);
context = windows.join('\n\n---\n\n');
Logger.info('Large PDF — keyword windows: ${windows.length}');
}
// Filter table data if requested (for math subjects)
if (filterTableData) {
context = _filterTableData(context);
Logger.info('Filtered table data from content');
}
if (context.isNotEmpty) {
contextBuffer.writeln('\n[MATERIAL: $fileName]');
contextBuffer.writeln(context);
hasContent = true;
}
}
if (!hasContent) return '';
return contextBuffer.toString();
}
// Sem material seleccionado — não processar PDFs automaticamente
// O utilizador deve seleccionar um material antes de fazer perguntas sobre conteúdo
Logger.info('No selectedMaterialIds — skipping automatic PDF processing');
return '';
} 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 [];
}
}
/// Limite máximo de bytes descarregados do PDF via Firebase Storage (10 MB)
static const int _maxPdfBytes = 10 * 1024 * 1024;
/// Limite máximo de caracteres de texto extraído do PDF completo (para chunking)
static const int _maxExtractedChars = 50000;
/// Extrair texto real do PDF usando Firebase Storage SDK + syncfusion_flutter_pdf
/// Usa getData() para descarregar o ficheiro completo (sem truncar a meio do stream)
static Future<String> _extractFullText(
String fileName,
String teacherId,
) async {
PdfDocument? document;
try {
final ref = _storage
.ref()
.child('teachers')
.child(teacherId)
.child('materials')
.child(fileName);
Logger.info('PDF available for extraction: $fileName');
// getData descarrega o ficheiro completo de forma gerida pelo SDK do Firebase
// O PDF nunca é truncado a meio — recebemos sempre um ficheiro válido
final data = await ref.getData(_maxPdfBytes);
if (data == null || data.isEmpty) {
Logger.warning('No data received for $fileName');
return '';
}
Logger.info('Downloaded ${data.length} bytes for $fileName');
// Extrair texto real com PdfDocument
document = PdfDocument(inputBytes: data);
final buffer = StringBuffer();
// 1. Extrair texto de todas as páginas — salta apenas páginas de estrutura
final extractor = PdfTextExtractor(document);
final totalPages = document.pages.count;
final startPage = totalPages > 4 ? 2 : 0;
for (int i = startPage; i < totalPages; i++) {
if (buffer.length >= _maxExtractedChars) break;
try {
final pageText = extractor
.extractText(startPageIndex: i, endPageIndex: i)
.trim();
if (pageText.length < 80) continue;
final lowerText = pageText.toLowerCase();
final pipeCount = '|'.allMatches(pageText).length;
final isStructurePage =
pipeCount > 3 ||
(lowerText.contains('table of contents') &&
pageText.length < 800) ||
(lowerText.contains('copyright') && pageText.length < 400) ||
(lowerText.contains('color insert') && pageText.length < 400) ||
lowerText.contains('just light novels') ||
lowerText.contains('download all your fav') ||
(lowerText.contains('www.') && pageText.length < 300);
if (isStructurePage) continue;
buffer.writeln(pageText);
} catch (_) {}
}
// 2. Extrair valores dos campos de formulário (se existirem)
final form = document.form;
if (form.fields.count > 0) {
buffer.writeln('\n[CAMPOS DO FORMULÁRIO]');
for (int i = 0; i < form.fields.count; i++) {
if (buffer.length >= _maxExtractedChars) break;
final field = form.fields[i];
final name = field.name;
String value = '';
if (field is PdfTextBoxField) {
value = field.text;
} else if (field is PdfComboBoxField) {
value = field.selectedValue;
} else if (field is PdfListBoxField) {
value = field.selectedValues.join(', ');
} else if (field is PdfRadioButtonListField) {
value = field.selectedValue;
} else if (field is PdfCheckBoxField) {
value = field.isChecked ? 'Sim' : 'Não';
}
if ((name?.isNotEmpty ?? false) || value.isNotEmpty) {
buffer.writeln('$name: $value');
}
}
}
final fullText = buffer.toString();
// Truncar ao limite
final result = fullText.length > _maxExtractedChars
? fullText.substring(0, _maxExtractedChars)
: fullText;
Logger.info(
'Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)',
);
Logger.info(
'Text preview: ${result.length > 200 ? result.substring(0, 200) : result}',
);
return result.trim();
} catch (e) {
Logger.error('Error extracting text from $fileName: $e');
return '';
} finally {
document?.dispose();
}
}
/// Keyword window search — encontra posições das keywords no texto e extrai
/// janelas de contexto em redor. Nunca aloca chunks — opera sobre a string original.
static List<String> _extractKeywordWindows(
String text,
String userQuery,
int maxWindows, {
int windowSize = 1200,
}) {
// Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars)
// Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François")
final properNouns = RegExp(
r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b',
).allMatches(userQuery).map((m) => m.group(0)!.toLowerCase()).toSet();
final generalKeywords = userQuery
.toLowerCase()
.split(RegExp(r'[^\w]'))
.where((w) => w.length > 3)
.toSet();
final keywords = {...properNouns, ...generalKeywords};
if (keywords.isEmpty) {
return [text.length > windowSize ? text.substring(0, windowSize) : text];
}
final textLower = text.toLowerCase();
// Recolher posições únicas onde alguma keyword aparece
final positions = <int>{};
for (final kw in keywords) {
int idx = textLower.indexOf(kw);
while (idx != -1) {
positions.add(idx);
idx = textLower.indexOf(kw, idx + 1);
}
}
if (positions.isEmpty) {
// Sem matches — retornar porção do início do conteúdo real (saltar ~10% de índice/capa)
final skip = (text.length * 0.05).toInt().clamp(0, 2000);
final end = (skip + windowSize * maxWindows).clamp(0, text.length);
return [text.substring(skip, end).trim()];
}
// Ordenar posições e fundir janelas sobrepostas
final sorted = positions.toList()..sort();
final windows = <String>[];
int lastEnd = -1;
for (final pos in sorted) {
if (windows.length >= maxWindows) break;
final start = (pos - windowSize ~/ 2).clamp(0, text.length);
final end = (pos + windowSize ~/ 2).clamp(0, text.length);
if (start < lastEnd) continue; // Janela sobreposta — saltar
windows.add(text.substring(start, end).trim());
lastEnd = end;
}
Logger.info(
'Keyword windows found: ${windows.length} for query "$userQuery"',
);
return windows;
}
/// 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();
}
/// Formatar texto extraído do PDF para melhor legibilidade
static String _formatPDFText(String text) {
if (text.isEmpty) return text;
String formatted = text;
// Corrigir quebras de linha excessivas
formatted = formatted.replaceAll(RegExp(r'\n{3,}'), '\n\n');
// Corrigir espaços excessivos
formatted = formatted.replaceAll(RegExp(r'[ \t]+'), ' ');
// Remover espaços no início/fim das linhas
formatted = formatted.split('\n').map((line) => line.trim()).join('\n');
// Corrigir parágrafos (linhas que terminam com ponto e seguem sem espaço)
formatted = formatted.replaceAllMapped(
RegExp(r'\.(\n)([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ])'),
(match) => '.\n\n${match.group(2)}',
);
// Corrigir quebras de palavras com hífen no fim da linha
formatted = formatted.replaceAllMapped(
RegExp(r'([a-zA-Záéíóúàâêôãõç])-\n([a-zA-Záéíóúàâêôãõç])'),
(match) => '${match.group(1)}${match.group(2)}',
);
// Adicionar quebras de parágrafo para títulos (linhas em maiúsculas)
formatted = formatted.replaceAllMapped(
RegExp(r'\n([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][A-ZÁÉÍÓÚÀÂÊÔÃÕÇ\s]{10,})\n'),
(match) => '\n\n${match.group(1)}\n\n',
);
// Limpar quebras de linha no início e fim
formatted = formatted.trim();
return formatted;
}
/// Obter o texto completo de um PDF específico para pré-visualização
static Future<String> getFullPDFText(
String fileName,
String teacherId,
) async {
try {
// Remover extensão se existir
final cleanFileName = fileName.endsWith('.pdf')
? fileName
: '$fileName.pdf';
// Usar cache do texto completo se disponível
final cacheKey = '${cleanFileName}_preview_v6';
if (_chunksCache.containsKey(cacheKey) &&
_chunksCache[cacheKey]!.isNotEmpty) {
final fullText = _chunksCache[cacheKey]!.first;
Logger.info(
'Using cached preview text for $cleanFileName: ${fullText.length} chars',
);
return fullText;
}
// Extrair texto completo
final rawText = await _extractFullText(cleanFileName, teacherId);
// Formatar texto para melhor legibilidade
final formattedText = _formatPDFText(rawText);
// Guardar em cache
_chunksCache[cacheKey] = [formattedText];
Logger.info(
'PDF "$cleanFileName" -> ${formattedText.length} chars extracted and formatted for preview',
);
return formattedText;
} catch (e) {
Logger.error('Error getting full PDF text for $fileName: $e');
return '';
}
}
/// Clear the chunks cache
static void clearCache() {
_chunksCache.clear();
Logger.info('Materials chunks cache cleared');
}
/// Filter out table data from text (for math subjects)
/// Removes lines that look like tabular data with multiple numbers
static String _filterTableData(String text) {
final lines = text.split('\n');
final filtered = <String>[];
for (final line in lines) {
final trimmed = line.trim();
// Skip lines that look like table data
// Pattern: multiple numbers separated by spaces/tabs
final numberPattern = RegExp(r'\d+\s+\d+');
final matches = numberPattern.allMatches(trimmed);
// If a line has 2+ number pairs separated by spaces, it's likely table data
if (matches.length >= 2) {
continue;
}
// Skip lines with specific date patterns (table data)
if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) &&
RegExp(r'\d+').allMatches(trimmed).length > 2) {
continue;
}
// Keep the line
filtered.add(line);
}
return filtered.join('\n');
}
}

View File

@@ -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<RAGResponse> generateRAGResponse({
required String userQuery,
required String context,
@@ -21,13 +23,81 @@ 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<Map<String, String>> messages = [];
// 2. Call Ollama API
final response = await _callOllamaAPI(prompt);
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({
'role': 'system',
'content': r'''Tu és "Vico", o Assistente IA oficial do Learn It.
// 3. Process response and create RAGResponse
Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como o Vico.
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.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''',
});
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore
final conversationId = ChatMemoryService.currentConversationId;
if (conversationId != null) {
final conversationHistory =
await ChatMemoryService.getConversationMessages(
conversationId: conversationId,
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: 10,
maxChunks: 20,
);
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 +113,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 +134,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 +191,40 @@ class RAGAIService {
return promptBuilder.toString();
}
/// Call Ollama API
static Future<String> _callOllamaAPI(String prompt) async {
/// System message for Vico identity (for legacy calls)
static const String _systemMessage =
r'''Tu és "Vico", o Assistente IA oficial do Learn It.
Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como o Vico.
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.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''';
/// Call Ollama API with complete messages array
static Future<String> _callOllamaAPIWithMessages(
List<Map<String, String>> 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},
};
@@ -140,7 +240,10 @@ class RAGAIService {
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final message = responseData['message'];
final content = message?['content'] ?? '';
var content = message?['content'] ?? '';
// Post-process to remove LaTeX symbols
content = _removeLaTeXSymbols(content);
Logger.info('Ollama API response received');
return content.trim();
@@ -153,6 +256,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
static RAGResponse _createRAGResponse({
required String query,
@@ -348,15 +459,8 @@ class RAGAIService {
final response = await http.get(url).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final models = responseData['models'] as List? ?? [];
final hasModel = models.any(
(model) => (model['name'] as String? ?? '').contains('qwen3-coder'),
);
Logger.info('Ollama service available, model found: $hasModel');
return hasModel;
Logger.info('Ollama service available');
return true;
} else {
Logger.warning(
'Ollama service returned status: ${response.statusCode}',
@@ -394,6 +498,230 @@ class RAGAIService {
}
}
/// Remove LaTeX symbols from AI response
static String _removeLaTeXSymbols(String text) {
// Remove patterns like $$...$$ (display math)
var cleaned = text.replaceAll(RegExp(r'\$\$[^$]*\$\$'), '');
// Remove patterns like $...$ (inline math) - be more careful
// Only remove when properly closed
cleaned = cleaned.replaceAllMapped(
RegExp(r'\$[^$\n]+?\$'),
(match) => match.group(0)!.replaceAll(r'$', ''),
);
// Remove any remaining standalone $ symbols
cleaned = cleaned.replaceAll(RegExp(r'(?<!\\)\$'), '');
return cleaned;
}
/// Gerar quiz a partir de um prompt com contexto PDF embutido — sem histórico de conversa
static Future<String> generateQuiz(
String prompt, {
bool isMathematics = false,
}) async {
final systemPrompt = isMathematics
? _getMathematicsSystemPrompt()
: _getTextBasedSystemPrompt();
final messages = <Map<String, String>>[
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': prompt},
];
final raw = await _callOllamaAPIWithMessages(messages);
// Filter out table questions for mathematics
if (isMathematics) {
return _filterTableQuestions(raw);
}
return raw;
}
/// Filter out questions that reference tables, graphs, or specific dates
static String _filterTableQuestions(String json) {
try {
final List<dynamic> questions = jsonDecode(json);
final List<Map<String, dynamic>> filtered = [];
// Keywords that indicate table/graph dependence
final tableKeywords = [
'tabela',
'gráfico',
'dia 1/',
'início de',
'final de',
'tendência',
'evolução',
'ao longo do tempo',
'percentagem no início',
'percentagem no final',
'ano foi de',
'dia específico',
'data específica',
];
for (final q in questions) {
if (q is Map<String, dynamic>) {
final questionText = (q['q'] as String? ?? '').toLowerCase();
// Check if question contains table keywords
final hasTableKeyword = tableKeywords.any(
(keyword) => questionText.contains(keyword.toLowerCase()),
);
// Skip questions with table keywords
if (!hasTableKeyword) {
filtered.add(q);
}
}
}
// If filtered list is empty, return original to avoid empty response
if (filtered.isEmpty) {
Logger.warning(
'All questions filtered out as table questions, returning original',
);
return json;
}
return jsonEncode(filtered);
} catch (e) {
Logger.error('Error filtering table questions: $e');
return json;
}
}
/// System prompt for mathematics quizzes
static String _getMathematicsSystemPrompt() {
return '''És um assistente educativo especializado em criar EXERCÍCIOS DE MATEMÁTICA.
REGRAS CRÍTICAS:
1. ANALISA TODO O CONTEÚDO fornecido:
- Lê TODO o documento do início ao fim
- Identifica TODOS os tópicos e tipos de exercícios presentes
- NÃO te limites apenas aos primeiros exercícios
- Cria perguntas sobre TODOS os tópicos encontrados no documento
2. MANTÉM o NÍVEL DE DIFICULDADE da ficha original:
- Analisa a complexidade dos exercícios na ficha
- Cria exercícios com o MESMO nível de dificuldade
- NÃO faças perguntas mais avançadas do que o que está na ficha
- Se a ficha tem exercícios simples, cria exercícios simples
- Se a ficha tem exercícios complexos, cria exercícios complexos
3. ESTRICTAMENTE PROIBIDO criar perguntas que envolvam tabelas ou gráficos:
- NUNCA faças perguntas que dependam de dados de tabelas
- NUNCA faças perguntas que dependam de dados de gráficos
- NUNCA faças perguntas sobre "tendência" ou "evolução ao longo do tempo"
- NUNCA faças perguntas com datas específicas (ex: "dia 1/1/2017", "início de 2017")
- NUNCA faças perguntas sobre "percentagem no início" ou "percentagem no final"
- Se a ficha tem exercícios com tabelas, adapta-os para usar valores diretamente no texto
- Fornece os dados necessários diretamente no texto da pergunta
4. Cria perguntas COMPLETAMENTE INDEPENDENTES:
- CADA pergunta deve ser respondida SEM depender de outras perguntas
- NUNCA faças referências à "pergunta anterior" ou "pergunta seguinte"
- NUNCA uses resultados de perguntas anteriores em novas perguntas
- CADA pergunta deve ter TODOS os dados necessários no seu enunciado
- O aluno deve conseguir responder a qualquer pergunta independentemente da ordem
5. Usa VALORES DIFERENTES do conteúdo:
- Os valores numéricos nas perguntas podem ser DIFERENTES dos que estão no PDF
- Usa valores que mantenham o mesmo tipo de problema mas com números diferentes
- Exemplo: se o PDF tem "um prisma de 5cm", podes usar "um prisma de 7cm"
- O importante é manter a ESTRUTURA e TIPO de problema, não os valores exatos
6. EXEMPLOS DE PERGUNTAS PROIBIDAS (NÃO faças estas):
- "Qual é a percentagem da área afetada pela vespa no início do dia 1/1/2017?" (PROIBIDO - depende de tabela)
- "Com o passar do tempo, a área afetada tende para:" (PROIBIDO - depende de gráfico/evolução)
- "Em que ano a produção foi de X toneladas?" (PROIBIDO - depende de tabela)
- "Usando o resultado da pergunta anterior, calcule..." (PROIBIDO - depende de pergunta anterior)
- "Considerando o valor calculado acima, determine..." (PROIBIDO - depende de pergunta anterior)
7. EXEMPLOS DE PERGUNTAS PERMITIDAS (faz estas):
- "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" (PERMITIDO - dados diretos, independente)
- "Calcule a área de um círculo com raio 7cm." (PERMITIDO - dados diretos, independente)
- "Resolva a equação 2x + 5 = 15." (PERMITIDO - dados diretos, independente)
- "Uma esfera tem raio de 3 metros. Determine o seu volume." (PERMITIDO - dados diretos, independente)
8. ANALISA OS EXEMPLOS DE EXERCÍCIOS no contexto fornecido:
- Identifica os TIPOS de problemas que aparecem na ficha (ex: determinar volume de sólidos, planos que decompõem prismas, equações, frações, etc.)
- Repara na COMPLEXIDADE e estrutura dos exercícios originais
- Gera exercícios NOVOS que seguem a MESMA estrutura e complexidade mas com VALORES DIFERENTES
9. NÃO copies perguntas do conteúdo fornecido
10. Usa valores diferentes mas mantém o TIPO e ESTRUTURA do problema
11. INCLUI TODOS OS DADOS NECESSÁRIOS no texto da pergunta:
- Fornece TODOS os valores numéricos necessários
- Exemplo: "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?"
- NUNCA faças perguntas que dependam de dados que não forneces no texto
12. Na explicação, usa texto normal, SEM LaTeX:
- Escreve "a fração é 15/44" em vez de "\\frac{15}{44}"
- Escreve "raiz quadrada de 25" em vez de "\\sqrt{25}"
- Escreve "x ao quadrado" em vez de "x^2"
- Apenas usa LaTeX na própria pergunta se for estritamente necessário para a notação matemática
13. Cria exercícios variados sobre TODOS os tópicos:
- Diferentes valores numéricos
- Diferentes contextos quando aplicável
- Mesmo TIPO e complexidade de problema matemático
- VARIADOS entre todos os tópicos do documento
14. EXEMPLOS DE TIPOS DE EXERCÍCIOS DE MATEMÁTICA:
- Determinar volumes de sólidos (prismas, pirâmides, cilindros, cones, esferas)
- Planos que decompõem sólidos em partes geometricamente iguais
- Equações lineares e quadráticas
- Sistemas de equações
- Funções e gráficos
- Geometria analítica
- Trigonometria
- Probabilidade e estatística
- Cálculo de áreas e perímetros
- Frações e números racionais
- Potências e raízes
FORMATO JSON:
[{"q":"Pergunta com dados completos incluídos","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação em texto normal sem LaTeX"}]
ans é o índice (0-3) da opção correcta.''';
}
/// System prompt for text-based subject quizzes
static String _getTextBasedSystemPrompt() {
return '''És um assistente educativo especializado em criar quizzes pedagógicos.
REGRAS CRÍTICAS:
1. Cria perguntas de compreensão, análise e síntese
2. Baseia-te nos conceitos e temas do conteúdo
3. Evita perguntas de cópia direta
4. Foca em entender e aplicar os conceitos
5. INCLUI CONTEXTO SUFICIENTE EM CADA PERGUNTA:
- Cada pergunta deve ser compreensível por si só
- Fornece contexto necessário sobre o assunto da pergunta
- Exemplo PROIBIDO: "Em que ano a vespa asiática afetou mais de 50% da área?" (sem contexto)
- Exemplo PERMITIDO: "De acordo com o estudo sobre a vespa asiática em Portugal, em que ano esta espécie afetou mais de 50% da área de distribuição?"
- Exemplo PROIBIDO: "Qual foi a principal causa?" (sem contexto)
- Exemplo PERMITIDO: "Qual foi a principal causa da extinção do dodo, segundo o texto?"
6. EVITA perguntas que dependam de dados específicos não mencionados:
- NUNCA faças perguntas sobre "ano X" ou "data X" sem especificar de que ano/data se trata
- NUNCA faças perguntas sobre "porcentagem X" sem explicar o contexto
- NUNCA faças perguntas que dependam de tabelas ou gráficos
7. EXEMPLOS DE PERGUNTAS BEM FORMULADAS:
- "De acordo com o texto sobre a vespa asiática, qual é o principal impacto desta espécie na biodiversidade portuguesa?"
- "O estudo sobre a mudança climática menciona que a temperatura média aumentou. Qual foi a principal causa mencionada?"
- "Segundo o documento sobre a história de Portugal, qual foi o resultado da Batalha de Aljubarrota?"
FORMATO JSON:
[{"q":"Pergunta com contexto suficiente","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação"}]
ans é o índice (0-3) da opção correcta.''';
}
/// Test the service with a simple query
static Future<String> testService() async {
try {
@@ -405,4 +733,405 @@ class RAGAIService {
return 'Service test failed: $e';
}
}
/// Cache do último contexto PDF enviado ao modelo — reutilizado em follow-ups
static String _lastPdfContext = '';
/// Limpar contexto cacheado — chamar ao mudar de material
static void clearLastContext() {
_lastPdfContext = '';
Logger.info('Last PDF context cleared');
}
/// Detecta se a query é small talk (saudação, conversa casual) — sem necessidade de contexto PDF
static bool _isSmallTalk(String query) {
final q = query.trim().toLowerCase();
const triggers = [
'olá',
'ola',
'oi',
'ei',
'hey',
'hi',
'tudo bem',
'tudo bom',
'como estás',
'como estas',
'como vai',
'bom dia',
'boa tarde',
'boa noite',
'obrigado',
'obrigada',
'muito obrigado',
'muito obrigada',
'valeu',
'ok',
'okay',
'fixe',
'ótimo',
'otimo',
'perfeito',
'excelente',
'adeus',
'até logo',
'até mais',
'tchau',
'quem és',
'quem es',
'quem é o vico',
'o que és',
'o que fazes',
'apresenta-te',
'apresentate',
];
// Exact match or starts with a trigger phrase
if (triggers.any(
(t) => q == t || q.startsWith('$t ') || q.startsWith('$t,'),
)) {
return true;
}
// Very short messages with no educational keywords
final words = q.split(RegExp(r'\s+'));
if (words.length <= 3) {
const eduKeywords = [
'explica',
'define',
'o que é',
'como funciona',
'porque',
'fórmula',
'formula',
'exemplo',
'exercício',
'exercicio',
'matéria',
'materia',
'tema',
'conceito',
'resumo',
];
if (!eduKeywords.any((k) => q.contains(k))) return true;
}
return false;
}
/// Detecta se a query é um follow-up (pergunta curta/vaga sem keywords de conteúdo)
static bool _isFollowUp(String query) {
final q = query.trim().toLowerCase();
// Menos de 6 palavras E começa com pronome/advérbio de follow-up
final words = q.split(RegExp(r'\s+'));
if (words.length > 8) return false;
const followUpStarters = [
'e ',
'e o',
'e a',
'e os',
'e as',
'mas ',
'então ',
'explica',
'explique',
'explica melhor',
'melhor',
'mais detalhes',
'podes',
'pode ',
'consegues',
'e se ',
'e quando',
'dá um exemplo',
'da um exemplo',
'um exemplo',
'exemplo',
'como assim',
'o que significa',
'porquê',
'porque isso',
'e o ponto',
'e a regra',
'continua',
'continua',
'o que mais',
'mais algum',
'e depois',
'e agora',
];
return followUpStarters.any((s) => q.startsWith(s) || q == s.trim());
}
/// Build dynamic system prompt based on selected materials and discipline
static String _buildSystemPrompt({
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) {
final buffer = StringBuffer();
buffer.writeln(
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.''',
);
buffer.writeln();
buffer.writeln('Nunca referes o nome do modelo de linguagem.');
buffer.writeln('Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.');
buffer.writeln('Respondes sempre como o Vico.');
buffer.writeln();
buffer.writeln('Tens personalidade simpática, confiante e motivadora.');
buffer.writeln(
'Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.',
);
buffer.writeln();
// Material context section
if (disciplineName != null && disciplineName.isNotEmpty) {
buffer.writeln('DISCIPLINA ATUAL: $disciplineName');
}
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'MATERIAIS SELECIONADOS: ${selectedMaterialNames.join(", ")}',
);
}
if (disciplineName != null ||
(selectedMaterialNames != null && selectedMaterialNames.isNotEmpty)) {
buffer.writeln();
}
// LaTeX prohibition (always)
buffer.writeln(
'IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.',
);
buffer.writeln(
'Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).',
);
buffer.writeln();
// Discipline-specific rules
if (isMathematics) {
buffer.writeln('REGRAS CRÍTICAS PARA MATEMÁTICA:');
buffer.writeln(
'- O material fornecido serve como REFERÊNCIA de matéria, fórmulas e métodos.',
);
buffer.writeln(
'- Podes CRIAR exercícios NOVOS que sigam a mesma lógica, fórmulas e métodos do material.',
);
buffer.writeln(
'- NÃO copies exercícios diretamente do material — cria variações com valores diferentes.',
);
buffer.writeln(
'- Se o aluno pedir exercícios, cria exercícios novos baseados nos CONCEITOS e FÓRMULAS do material.',
);
buffer.writeln(
'- Se a resposta não estiver no contexto, usa o teu conhecimento matemático geral para ajudar, mas prioriza os métodos do material.',
);
buffer.writeln(
'- SEMPRE fornece o passo a passo completo com o resultado final.',
);
} else {
buffer.writeln('REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:');
buffer.writeln(
'- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.',
);
buffer.writeln(
'- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.',
);
buffer.writeln(
'- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."',
);
}
buffer.writeln(
'- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.',
);
buffer.writeln();
// Material handling rules (always)
buffer.writeln('IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:');
buffer.writeln(
'- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.',
);
buffer.writeln(
'- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".',
);
buffer.writeln(
'- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.',
);
buffer.writeln(
'- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.',
);
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'- Se a pergunta do aluno for vaga (ex: "ajuda"), oferece ajuda específica sobre os materiais selecionados.',
);
}
buffer.writeln();
// Greeting rules (always)
buffer.writeln('IMPORTANTE - NÃO REPITAS SAUDAÇÕES:');
buffer.writeln(
'- NUNCA começes uma resposta com "Olá", "Olá de novo", "Oi", "Bom dia", etc., a menos que seja a PRIMEIRA mensagem da conversa.',
);
buffer.writeln(
'- Nas respostas subsequentes, vai DIRETO ao assunto sem saudar novamente.',
);
buffer.writeln(
'- Se já saudaste o utilizador uma vez na conversa, NUNCA o faças de novo.',
);
buffer.writeln();
// Complete response rules (always)
buffer.writeln('IMPORTANTE - RESPOSTAS COMPLETAS:');
buffer.writeln('- NUNCA termines respostas com dois pontos (:).');
buffer.writeln(
'- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".',
);
buffer.writeln(
'- SEMPRE completa as frases e fornece a resposta completa.',
);
buffer.writeln(
'- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.',
);
buffer.writeln(
'- Se precisares de definir algo, fornece a definição completa.',
);
return buffer.toString();
}
/// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity
/// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno
/// [selectedMaterialNames] — nomes dos materiais selecionados (para contextualizar a IA)
/// [disciplineName] — nome da disciplina (ex: "Matemática A", "Português")
/// [isMathematics] — true se for matemática ou disciplina quantitativa
static Future<String> ask(
String userQuery, {
List<String>? selectedMaterialIds,
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) 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 VICO (SEMPRE PRIMEIRO)
final systemPrompt = _buildSystemPrompt(
selectedMaterialNames: selectedMaterialNames,
disciplineName: disciplineName,
isMathematics: isMathematics,
);
messages.add({'role': 'system', 'content': systemPrompt});
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 10 para manter contexto)
final conversationId = ChatMemoryService.currentConversationId;
var lastHistoryMessageIsDuplicate = false;
if (conversationId != null) {
final conversationHistory =
await ChatMemoryService.getConversationMessages(
conversationId: conversationId,
limit: 10,
);
for (final msg in conversationHistory) {
messages.add({
'role': msg['role'] as String,
'content': msg['content'] as String,
});
}
// Verificar se a última mensagem do histórico já é a pergunta atual
// (evita duplicação quando a UI guardou a mensagem antes de chamar ask())
if (conversationHistory.isNotEmpty) {
final lastMsg = conversationHistory.last;
if (lastMsg['role'] == 'user' && lastMsg['content'] == userQuery) {
lastHistoryMessageIsDuplicate = true;
Logger.info(
'Last history message matches current query — skipping duplicate user message',
);
}
}
}
// 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)
// Small talk: skip PDF lookup entirely and go straight to model
if (_isSmallTalk(userQuery)) {
Logger.info('Small talk detected — skipping PDF lookup');
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response;
}
// Detectar follow-up e reutilizar contexto anterior se disponível
String pdfContext;
if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) {
pdfContext = _lastPdfContext;
Logger.info(
'Follow-up detected — reusing last PDF context (${pdfContext.length} chars)',
);
} else {
pdfContext = await MaterialsRAGService.getRelevantChunks(
userQuery: userQuery,
maxMaterials: 10,
maxChunks: 20,
selectedMaterialIds: selectedMaterialIds,
);
if (pdfContext.isNotEmpty) {
_lastPdfContext = pdfContext;
Logger.info(
'PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}',
);
}
}
if (pdfContext.isEmpty) {
// Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response;
}
// PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível)
final userContent = pdfContext.isNotEmpty
? '''Usa APENAS o seguinte contexto para responder. Não uses conhecimento externo.
Se a resposta não estiver no contexto, diz: "Não encontrei essa informação no material disponível."
$pdfContext
Pergunta: $userQuery'''
: userQuery;
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userContent});
}
Logger.info(
'USING RAG AI SERVICE - Built messages array with ${messages.length} messages',
);
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
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;
}
}

View File

@@ -61,6 +61,28 @@ class RAGService {
static const int maxContextTokens = 4000;
static const int maxChunksInContext = 5;
/// System message for Vico identity - ALWAYS first in every conversation
static const String _systemMessage =
'''Tu és "Vico", o Assistente IA oficial do Learn It.
Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como o Vico.
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.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''';
/// Process a user query through RAG pipeline
static Future<RAGResponse> processQuery({
required String userQuery,
@@ -77,6 +99,10 @@ class RAGService {
'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode',
);
// Detect if subject is math
final isMathSubject = _isMathSubject(subject);
Logger.info('Subject: $subject, Is math: $isMathSubject');
// 1. Generate embedding for user query
final queryEmbedding = VectorService.generateEmbedding(userQuery);
@@ -97,8 +123,13 @@ class RAGService {
return _createNoContentResponse(userQuery, mode);
}
// 3. Build context window
final context = _buildContextWindow(relevantChunks, userQuery, mode);
// 3. Build context window with math-specific filtering
final context = _buildContextWindow(
relevantChunks,
userQuery,
mode,
isMathSubject: isMathSubject,
);
// 4. Generate response (this will be handled by RAGAIService)
final response = await _generateResponse(
@@ -122,8 +153,9 @@ class RAGService {
static String _buildContextWindow(
List<ContentChunk> chunks,
String userQuery,
TutorMode mode,
) {
TutorMode mode, {
bool isMathSubject = false,
}) {
try {
final contextBuilder = StringBuffer();
@@ -135,6 +167,13 @@ class RAGService {
for (int i = 0; i < sortedChunks.length; i++) {
final chunk = sortedChunks[i];
var chunkText = chunk.text;
// Filter table data for math subjects
if (isMathSubject) {
chunkText = _filterTableData(chunkText);
}
contextBuilder.writeln('--- Fonte ${i + 1} ---');
contextBuilder.writeln('Disciplina: ${chunk.subject}');
contextBuilder.writeln('Conceito: ${chunk.concept}');
@@ -146,12 +185,30 @@ class RAGService {
if (chunk.pageNumber != null) {
contextBuilder.writeln('Página: ${chunk.pageNumber}');
}
contextBuilder.writeln('\nConteúdo:\n${chunk.text}\n');
contextBuilder.writeln('\nConteúdo:\n$chunkText\n');
}
// Add mode-specific instructions
contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ===');
contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}');
// Add math-specific instructions if applicable
if (isMathSubject) {
contextBuilder.writeln('\n=== INSTRUÇÕES PARA MATEMÁTICA ===');
contextBuilder.writeln('Para notações matemáticas:');
contextBuilder.writeln(
r'- NUNCA use LaTeX ou símbolos como $ ou $$ para fórmulas',
);
contextBuilder.writeln(
'- Use apenas texto normal e caracteres Unicode (ex: x², ³, ¹⁄², π, √)',
);
contextBuilder.writeln(
'- Preserve a notação matemática original quando possível',
);
contextBuilder.writeln('- Explique passo a passo os cálculos');
contextBuilder.writeln('- Use exemplos numéricos concretos');
}
contextBuilder.writeln('Pergunta do Aluno: $userQuery\n');
final contextText = contextBuilder.toString();
@@ -219,6 +276,7 @@ class RAGService {
body: jsonEncode({
'model': 'qwen3-coder:30b',
'messages': [
{'role': 'system', 'content': _systemMessage},
{'role': 'user', 'content': prompt},
],
'stream': false,
@@ -228,7 +286,11 @@ class RAGService {
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final answer = responseData['message']['content'] as String;
var answer = responseData['message']['content'] as String;
// Post-process to remove LaTeX symbols
answer = _removeLaTeXSymbols(answer);
final confidence = _calculateConfidence(sources);
final relatedConcepts = _extractRelatedConcepts(sources);
@@ -331,7 +393,9 @@ $query
- Use linguagem clara e educacional
- Adapte a resposta ao nível do aluno
- Forneça exemplos quando possível
- Seja conciso mas completo''';
- Seja conciso mas completo
- NUNCA use LaTeX ou símbolos como \$ ou \$\$ para fórmulas
- Use apenas texto normal e caracteres Unicode para símbolos matemáticos''';
}
/// Calculate relevance score
@@ -395,6 +459,77 @@ $query
return concepts.toList()..sort();
}
/// Detect if subject is math-related
static bool _isMathSubject(String? subject) {
if (subject == null) return false;
final lowerSubject = subject.toLowerCase();
final mathKeywords = [
'matemática',
'math',
'matematica',
'álgebra',
'algebra',
'geometria',
'cálculo',
'calculo',
'estatística',
'estatistica',
'funções',
'funcoes',
'equações',
'equacoes',
'números',
'numeros',
];
return mathKeywords.any((keyword) => lowerSubject.contains(keyword));
}
/// Filter out table data from text (for math subjects)
/// Removes lines that look like tabular data with multiple numbers
static String _filterTableData(String text) {
final lines = text.split('\n');
final filtered = <String>[];
for (final line in lines) {
final trimmed = line.trim();
// Skip lines that look like table data
// Pattern: multiple numbers separated by spaces/tabs
final numberPattern = RegExp(r'\d+\s+\d+');
final matches = numberPattern.allMatches(trimmed);
// If a line has 2+ number pairs separated by spaces, it's likely table data
if (matches.length >= 2) {
continue;
}
// Skip lines with specific date patterns (table data)
if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) &&
RegExp(r'\d+').allMatches(trimmed).length > 2) {
continue;
}
// Keep the line
filtered.add(line);
}
return filtered.join('\n');
}
/// Remove LaTeX symbols from AI response
static String _removeLaTeXSymbols(String text) {
// Remove patterns like $...$ and $$...$$
var cleaned = text.replaceAll(RegExp(r'\$\$[^$]+\$\$'), '');
cleaned = cleaned.replaceAll(RegExp(r'\$[^$]+\$'), '');
// Also remove standalone $ symbols
cleaned = cleaned.replaceAll(r'$', r'\$');
return cleaned;
}
/// Create response for no content found
static RAGResponse _createNoContentResponse(String query, TutorMode mode) {
return RAGResponse(

View File

@@ -0,0 +1,95 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart';
import '../utils/logger.dart';
/// Service for managing app theme preferences
class ThemeService {
static const String _themeKey = 'app_theme_mode';
static const ThemeMode _defaultTheme = ThemeMode.light;
/// Get current theme mode from storage
static Future<ThemeMode> getThemeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final themeString = prefs.getString(_themeKey);
if (themeString == null) {
return _defaultTheme;
}
switch (themeString) {
case 'ThemeMode.light':
return ThemeMode.light;
case 'ThemeMode.dark':
return ThemeMode.dark;
case 'ThemeMode.system':
return ThemeMode.system;
default:
return _defaultTheme;
}
} catch (e) {
Logger.error('Error getting theme mode: $e');
return _defaultTheme;
}
}
/// Save theme mode to storage
static Future<void> setThemeMode(ThemeMode themeMode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, themeMode.toString());
Logger.info('Theme mode saved: ${themeMode.toString()}');
} catch (e) {
Logger.error('Error saving theme mode: $e');
}
}
/// Get theme mode from storage (for future use)
static Future<ThemeMode> getStoredThemeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final themeString = prefs.getString(_themeKey);
if (themeString == null) {
return _defaultTheme;
}
switch (themeString) {
case 'ThemeMode.light':
return ThemeMode.light;
case 'ThemeMode.dark':
return ThemeMode.dark;
case 'ThemeMode.system':
return ThemeMode.system;
default:
return _defaultTheme;
}
} catch (e) {
Logger.error('Error getting stored theme mode: $e');
return _defaultTheme;
}
}
/// Check if dark mode is available (for future settings)
static bool isDarkModeAvailable() {
// Dark mode is now available
return true;
}
/// Get theme mode string for display
static String getThemeModeString(ThemeMode themeMode) {
switch (themeMode) {
case ThemeMode.light:
return 'Light Mode';
case ThemeMode.dark:
return 'Dark Mode';
case ThemeMode.system:
return 'System Default';
}
}
/// Reset theme to default
static Future<void> resetTheme() async {
await setThemeMode(_defaultTheme);
}
}

View File

@@ -1,68 +1,90 @@
import 'package:flutter/material.dart';
/// EPVC School Color Palette - New Color Scheme
/// EPVC School Color Palette - Light/Dark Mode Support
class AppColors {
// Primary Brand Colors
static const Color primaryTeal = Color(
0xFF82C9BD,
); // Main teal color - PRIMARY
static const Color primaryOrange = Color(
0xFFF68D2D,
); // Accent orange - SECONDARY
// Primary Brand Colors (light mode)
static const Color primaryTeal = Color(0xFF82C9BD);
static const Color primaryOrange = Color(0xFFF68D2D);
// Gradient Colors
static const Color gradientStart = Color(0xFF82C9BD); // Teal gradient start
static const Color gradientEnd = Color(
0xFF6AB8A8,
); // Darker teal gradient end
// Gradient Colors (light mode)
static const Color gradientStart = Color(0xFF82C9BD);
static const Color gradientEnd = Color(0xFF6AB8A8);
// Secondary Colors
static const Color secondaryTeal = Color(0xFF6AB8A8); // Darker teal
static const Color accentTeal = Color(0xFF5AA69A); // Lighter teal accent
static const Color lightOrange = Color(0xFFF7A960); // Lighter orange
// Secondary Colors (light mode)
static const Color secondaryTeal = Color(0xFF6AB8A8);
static const Color accentTeal = Color(0xFF5AA69A);
static const Color lightOrange = Color(0xFFF7A960);
// Neutral Colors
static const Color background = Color(0xFFF8F9FA); // Light gray background
static const Color surface = Color(0xFFFFFFFF); // White surfaces
static const Color cardBackground = Color(0xFFFFFFFF); // White cards
// Status Colors (shared)
static const Color success = Color(0xFF10B981);
static const Color warning = Color(0xFFF59E0B);
static const Color error = Color(0xFFEF4444);
static const Color info = Color(0xFF3B82F6);
// Text Colors
static const Color textPrimary = Color(0xFF1A1A1A); // Primary text
static const Color textSecondary = Color(0xFF6B7280); // Secondary text
static const Color textHint = Color(0xFF9CA3AF); // Hint text
// Status Colors
static const Color success = Color(0xFF10B981); // Green for success
static const Color warning = Color(0xFFF59E0B); // Amber for warnings
static const Color error = Color(0xFFEF4444); // Red for errors
static const Color info = Color(0xFF3B82F6); // Blue for info
// Interactive Colors
static const Color buttonPrimary = Color(0xFF82C9BD); // Primary button (teal)
static const Color buttonAccent = Color(0xFFF68D2D); // Accent button (orange)
static const Color buttonSecondary = Color(0xFFE5E7EB); // Secondary button
static const Color iconActive = Color(0xFF82C9BD); // Active icons (teal)
static const Color iconInactive = Color(0xFF9CA3AF); // Inactive icons
// Chat Specific Colors
static const Color chatBubbleStudent = Color(
0xFF82C9BD,
); // Student messages (teal)
static const Color chatBubbleAI = Color(0xFFF3F4F6); // AI messages
static const Color chatInputBackground = Color(
0xFFF8F9FA,
); // Input background
static const Color chatSendButton = Color(0xFF82C9BD); // Send button (teal)
// Dark Mode Colors
static const Color darkBackground = Color(0xFF1F2937); // Dark background
static const Color darkSurface = Color(0xFF374151); // Dark surface
static const Color darkTextPrimary = Color(0xFFF9FAFB); // Dark primary text
static const Color darkTextSecondary = Color(
0xFFD1D5DB,
); // Dark secondary text
// Legacy compatibility (for existing code)
@deprecated
static const Color primaryBlue = primaryTeal; // Map old primaryBlue to new primaryTeal
@Deprecated('Use AppColors.primaryTeal')
static const Color primaryBlue = primaryTeal;
}
/// Brand colors tuned for dark mode (lower luminance, same hue family).
class DarkBrandColors {
static const Color primaryTeal = Color(0xFF4D8F84);
static const Color primaryOrange = Color(0xFFC47A2A);
static const Color gradientStart = Color(0xFF3D7A70);
static const Color gradientEnd = Color(0xFF2F635C);
static const Color secondaryTeal = Color(0xFF3D7A70);
static const Color accentTeal = Color(0xFF356B62);
}
/// Light Mode Colors
class LightColors {
static const Color background = Color(0xFFF8F9FA);
static const Color surface = Color(0xFFFFFFFF);
static const Color surfaceVariant = Color(0xFFF3F4F6);
static const Color cardBackground = Color(0xFFFFFFFF);
static const Color textPrimary = Color(0xFF1A1A1A);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textHint = Color(0xFF9CA3AF);
static const Color buttonPrimary = AppColors.primaryTeal;
static const Color buttonAccent = AppColors.primaryOrange;
static const Color buttonSecondary = Color(0xFFE5E7EB);
static const Color iconActive = AppColors.primaryTeal;
static const Color iconInactive = Color(0xFF9CA3AF);
static const Color chatBubbleStudent = AppColors.primaryTeal;
static const Color chatBubbleAI = Color(0xFFF3F4F6);
static const Color chatInputBackground = Color(0xFFF8F9FA);
static const Color chatSendButton = AppColors.primaryTeal;
static const Color border = Color(0xFFE2E8F0);
static const Color divider = Color(0xFFE5E7EB);
static const Color overlay = Color(0x80000000);
}
/// Dark Mode Colors
class DarkColors {
static const Color background = Color(0xFF0F1218);
static const Color surface = Color(0xFF1A2332);
static const Color surfaceVariant = Color(0xFF243044);
static const Color cardBackground = Color(0xFF1A2332);
static const Color textPrimary = Color(0xFFF9FAFB);
static const Color textSecondary = Color(0xFFD1D5DB);
static const Color textHint = Color(0xFF9CA3AF);
static const Color buttonPrimary = DarkBrandColors.primaryTeal;
static const Color buttonAccent = DarkBrandColors.primaryOrange;
static const Color buttonSecondary = Color(0xFF2D3A4D);
static const Color iconActive = DarkBrandColors.primaryTeal;
static const Color iconInactive = Color(0xFF6B7280);
static const Color chatBubbleStudent = DarkBrandColors.primaryTeal;
static const Color chatBubbleAI = Color(0xFF243044);
static const Color chatInputBackground = Color(0xFF1A2332);
static const Color chatSendButton = DarkBrandColors.primaryTeal;
static const Color border = Color(0xFF2D3A4D);
static const Color divider = Color(0xFF2D3A4D);
static const Color overlay = Color(0x80000000);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'app_theme_extension.dart';
/// Application Theme Configuration
class AppTheme {
@@ -9,24 +10,31 @@ class AppTheme {
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
seedColor: AppColors.primaryTeal,
brightness: Brightness.light,
primary: AppColors.primaryBlue,
secondary: AppColors.primaryTeal,
surface: AppColors.surface,
background: AppColors.background,
primary: AppColors.primaryTeal,
onPrimary: Colors.white,
secondary: AppColors.primaryOrange,
onSecondary: Colors.white,
surface: LightColors.surface,
onSurface: LightColors.textPrimary,
onSurfaceVariant: LightColors.textSecondary,
surfaceContainerHighest: LightColors.surfaceVariant,
background: LightColors.background,
error: AppColors.error,
),
scaffoldBackgroundColor: LightColors.background,
extensions: [AppThemeExtras.light],
// App Bar Theme
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimary,
backgroundColor: LightColors.surface,
foregroundColor: LightColors.textPrimary,
elevation: 0,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.dark,
titleTextStyle: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
@@ -34,7 +42,7 @@ class AppTheme {
// Card Theme
cardTheme: CardThemeData(
color: AppColors.cardBackground,
color: LightColors.cardBackground,
elevation: 2,
shadowColor: Colors.black.withOpacity(0.08),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -44,10 +52,10 @@ class AppTheme {
// Elevated Button Theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.buttonPrimary,
backgroundColor: LightColors.buttonPrimary,
foregroundColor: Colors.white,
elevation: 2,
shadowColor: AppColors.primaryBlue.withOpacity(0.3),
shadowColor: AppColors.primaryTeal.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -59,8 +67,8 @@ class AppTheme {
// Outlined Button Theme
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: BorderSide(color: AppColors.primaryBlue.withOpacity(0.3)),
foregroundColor: AppColors.primaryTeal,
side: BorderSide(color: AppColors.primaryTeal.withOpacity(0.3)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -72,7 +80,7 @@ class AppTheme {
// Text Button Theme
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.primaryTeal,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
@@ -82,18 +90,18 @@ class AppTheme {
// Input Field Theme
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
fillColor: LightColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryBlue.withOpacity(0.3)),
borderSide: BorderSide(color: AppColors.primaryTeal.withOpacity(0.3)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryBlue.withOpacity(0.3)),
borderSide: BorderSide(color: AppColors.primaryTeal.withOpacity(0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
borderSide: const BorderSide(color: AppColors.primaryTeal, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -103,9 +111,9 @@ class AppTheme {
horizontal: 16,
vertical: 12,
),
hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 14),
hintStyle: const TextStyle(color: LightColors.textHint, fontSize: 14),
labelStyle: const TextStyle(
color: AppColors.textSecondary,
color: LightColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -113,85 +121,85 @@ class AppTheme {
// Text Field Theme
textSelectionTheme: TextSelectionThemeData(
cursorColor: AppColors.primaryBlue,
selectionColor: AppColors.primaryBlue.withOpacity(0.3),
selectionHandleColor: AppColors.primaryBlue,
cursorColor: AppColors.primaryTeal,
selectionColor: AppColors.primaryTeal.withOpacity(0.3),
selectionHandleColor: AppColors.primaryTeal,
),
// Text Theme
textTheme: const TextTheme(
displayLarge: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 32,
fontWeight: FontWeight.bold,
),
displayMedium: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.bold,
),
displaySmall: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
headlineLarge: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
),
headlineMedium: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w600,
),
headlineSmall: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
titleLarge: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
titleMedium: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
titleSmall: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.normal,
),
bodyMedium: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.normal,
),
bodySmall: TextStyle(
color: AppColors.textSecondary,
color: LightColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.normal,
),
labelLarge: TextStyle(
color: AppColors.textPrimary,
color: LightColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
labelMedium: TextStyle(
color: AppColors.textSecondary,
color: LightColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: TextStyle(
color: AppColors.textHint,
color: LightColors.textHint,
fontSize: 10,
fontWeight: FontWeight.w500,
),
@@ -199,9 +207,9 @@ class AppTheme {
// Bottom Navigation Bar Theme
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surface,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: AppColors.iconInactive,
backgroundColor: LightColors.surface,
selectedItemColor: AppColors.primaryTeal,
unselectedItemColor: LightColors.iconInactive,
type: BottomNavigationBarType.fixed,
elevation: 8,
selectedLabelStyle: TextStyle(
@@ -216,7 +224,7 @@ class AppTheme {
// Floating Action Button Theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primaryBlue,
backgroundColor: AppColors.primaryTeal,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -224,28 +232,28 @@ class AppTheme {
// Divider Theme
dividerTheme: const DividerThemeData(
color: AppColors.buttonSecondary,
color: LightColors.divider,
thickness: 1,
space: 1,
),
// Icon Theme
iconTheme: const IconThemeData(color: AppColors.iconActive, size: 24),
iconTheme: const IconThemeData(color: LightColors.iconActive, size: 24),
// Progress Indicator Theme
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primaryBlue,
linearTrackColor: AppColors.buttonSecondary,
circularTrackColor: AppColors.buttonSecondary,
color: AppColors.primaryTeal,
linearTrackColor: LightColors.buttonSecondary,
circularTrackColor: LightColors.buttonSecondary,
),
// Chip Theme
chipTheme: ChipThemeData(
backgroundColor: AppColors.buttonSecondary,
selectedColor: AppColors.primaryBlue.withOpacity(0.1),
disabledColor: AppColors.buttonSecondary.withOpacity(0.5),
labelStyle: const TextStyle(color: AppColors.textPrimary),
secondaryLabelStyle: const TextStyle(color: AppColors.textPrimary),
backgroundColor: LightColors.buttonSecondary,
selectedColor: AppColors.primaryTeal.withOpacity(0.1),
disabledColor: LightColors.buttonSecondary.withOpacity(0.5),
labelStyle: const TextStyle(color: LightColors.textPrimary),
secondaryLabelStyle: const TextStyle(color: LightColors.textPrimary),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
@@ -256,53 +264,105 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
brightness: Brightness.dark,
primary: AppColors.primaryBlue,
secondary: AppColors.primaryTeal,
surface: AppColors.darkSurface,
background: AppColors.darkBackground,
colorScheme: ColorScheme.dark(
primary: DarkBrandColors.primaryTeal,
onPrimary: Colors.white,
secondary: DarkBrandColors.primaryOrange,
onSecondary: Colors.white,
surface: DarkColors.surface,
onSurface: DarkColors.textPrimary,
onSurfaceVariant: DarkColors.textSecondary,
surfaceContainerHighest: DarkColors.surfaceVariant,
surfaceContainerLow: DarkColors.background,
background: DarkColors.background,
error: AppColors.error,
outline: DarkColors.border,
),
// Dark mode specific overrides would go here
scaffoldBackgroundColor: DarkColors.background,
extensions: [AppThemeExtras.dark],
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.darkSurface,
foregroundColor: AppColors.darkTextPrimary,
backgroundColor: DarkColors.surface,
foregroundColor: DarkColors.textPrimary,
elevation: 0,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
cardTheme: CardThemeData(
color: AppColors.darkSurface,
color: DarkColors.cardBackground,
elevation: 2,
shadowColor: Colors.black.withOpacity(0.3),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// Elevated Button Theme for Dark Mode
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: DarkColors.buttonPrimary,
foregroundColor: Colors.white,
elevation: 2,
shadowColor: DarkBrandColors.primaryTeal.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
// Outlined Button Theme for Dark Mode
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: DarkBrandColors.primaryTeal,
side: BorderSide(
color: DarkBrandColors.primaryTeal.withOpacity(0.3),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
// Text Button Theme for Dark Mode
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: DarkBrandColors.primaryTeal,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
),
// Input Field Theme for Dark Mode
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.darkSurface,
fillColor: DarkColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryBlue.withOpacity(0.3)),
borderSide: BorderSide(
color: DarkBrandColors.primaryTeal.withOpacity(0.3),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryBlue.withOpacity(0.3)),
borderSide: BorderSide(
color: DarkBrandColors.primaryTeal.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
borderSide: const BorderSide(
color: DarkBrandColors.primaryTeal,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -313,11 +373,11 @@ class AppTheme {
vertical: 12,
),
hintStyle: const TextStyle(
color: AppColors.darkTextSecondary,
color: DarkColors.textSecondary,
fontSize: 14,
),
labelStyle: const TextStyle(
color: AppColors.darkTextSecondary,
color: DarkColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -325,88 +385,141 @@ class AppTheme {
// Text Field Theme for Dark Mode
textSelectionTheme: TextSelectionThemeData(
cursorColor: AppColors.primaryBlue,
selectionColor: AppColors.primaryBlue.withOpacity(0.3),
selectionHandleColor: AppColors.primaryBlue,
cursorColor: DarkBrandColors.primaryTeal,
selectionColor: DarkBrandColors.primaryTeal.withOpacity(0.3),
selectionHandleColor: DarkBrandColors.primaryTeal,
),
textTheme: const TextTheme(
displayLarge: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 32,
fontWeight: FontWeight.bold,
),
displayMedium: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.bold,
),
displaySmall: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
),
headlineLarge: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
),
headlineMedium: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w600,
),
headlineSmall: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
titleLarge: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
titleMedium: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
titleSmall: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.normal,
),
bodyMedium: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.normal,
),
bodySmall: TextStyle(
color: AppColors.darkTextSecondary,
color: DarkColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.normal,
),
labelLarge: TextStyle(
color: AppColors.darkTextPrimary,
color: DarkColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
labelMedium: TextStyle(
color: AppColors.darkTextSecondary,
color: DarkColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: TextStyle(
color: AppColors.darkTextSecondary,
color: DarkColors.textSecondary,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
// Bottom Navigation Bar Theme for Dark Mode
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: DarkColors.surface,
selectedItemColor: DarkBrandColors.primaryTeal,
unselectedItemColor: DarkColors.iconInactive,
type: BottomNavigationBarType.fixed,
elevation: 8,
selectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
// Floating Action Button Theme for Dark Mode
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: DarkBrandColors.primaryTeal,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// Divider Theme for Dark Mode
dividerTheme: const DividerThemeData(
color: DarkColors.divider,
thickness: 1,
space: 1,
),
// Icon Theme for Dark Mode
iconTheme: const IconThemeData(color: DarkColors.iconActive, size: 24),
// Progress Indicator Theme for Dark Mode
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: DarkBrandColors.primaryTeal,
linearTrackColor: DarkColors.buttonSecondary,
circularTrackColor: DarkColors.buttonSecondary,
),
// Chip Theme for Dark Mode
chipTheme: ChipThemeData(
backgroundColor: DarkColors.buttonSecondary,
selectedColor: DarkBrandColors.primaryTeal.withOpacity(0.1),
disabledColor: DarkColors.buttonSecondary.withOpacity(0.5),
labelStyle: const TextStyle(color: DarkColors.textPrimary),
secondaryLabelStyle: const TextStyle(color: DarkColors.textPrimary),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Theme extension for gradients and colors not covered by [ColorScheme].
@immutable
class AppThemeExtras extends ThemeExtension<AppThemeExtras> {
const AppThemeExtras({
required this.dashboardBackgroundGradient,
required this.dashboardGradientStops,
required this.heroProgressStart,
required this.heroProgressEnd,
required this.actionCardGradientStart,
required this.actionCardGradientEnd,
required this.authBackgroundGradient,
required this.dashboardHeaderTextColor,
});
final List<Color> dashboardBackgroundGradient;
final List<double> dashboardGradientStops;
final Color heroProgressStart;
final Color heroProgressEnd;
final Color actionCardGradientStart;
final Color actionCardGradientEnd;
final List<Color> authBackgroundGradient;
final Color dashboardHeaderTextColor;
static final AppThemeExtras light = AppThemeExtras(
dashboardBackgroundGradient: [
AppColors.primaryTeal,
AppColors.primaryTeal.withValues(alpha: 0.8),
AppColors.primaryOrange,
LightColors.background,
],
dashboardGradientStops: [0.0, 0.2, 0.6, 1.0],
heroProgressStart: Colors.white,
heroProgressEnd: Color(0xFFF8F9FA),
actionCardGradientStart: AppColors.primaryTeal,
actionCardGradientEnd: AppColors.gradientEnd,
authBackgroundGradient: [
const Color(0xFFD4E8E8),
const Color(0xFFE8D4C0),
const Color(0xFFD8E0E8),
],
dashboardHeaderTextColor: Colors.white,
);
static final AppThemeExtras dark = AppThemeExtras(
dashboardBackgroundGradient: [
DarkColors.surfaceVariant,
DarkColors.surface,
DarkColors.background,
DarkColors.background,
],
dashboardGradientStops: [0.0, 0.25, 0.55, 1.0],
heroProgressStart: Colors.white.withValues(alpha: 0.9),
heroProgressEnd: Colors.white.withValues(alpha: 0.55),
actionCardGradientStart: DarkBrandColors.gradientStart,
actionCardGradientEnd: DarkBrandColors.gradientEnd,
authBackgroundGradient: [
DarkColors.surfaceVariant,
DarkColors.surface,
DarkColors.background,
],
dashboardHeaderTextColor: DarkColors.textPrimary,
);
static AppThemeExtras of(BuildContext context) {
return Theme.of(context).extension<AppThemeExtras>() ?? light;
}
@override
AppThemeExtras copyWith({
List<Color>? dashboardBackgroundGradient,
List<double>? dashboardGradientStops,
Color? heroProgressStart,
Color? heroProgressEnd,
Color? actionCardGradientStart,
Color? actionCardGradientEnd,
List<Color>? authBackgroundGradient,
Color? dashboardHeaderTextColor,
}) {
return AppThemeExtras(
dashboardBackgroundGradient:
dashboardBackgroundGradient ?? this.dashboardBackgroundGradient,
dashboardGradientStops:
dashboardGradientStops ?? this.dashboardGradientStops,
heroProgressStart: heroProgressStart ?? this.heroProgressStart,
heroProgressEnd: heroProgressEnd ?? this.heroProgressEnd,
actionCardGradientStart:
actionCardGradientStart ?? this.actionCardGradientStart,
actionCardGradientEnd:
actionCardGradientEnd ?? this.actionCardGradientEnd,
authBackgroundGradient:
authBackgroundGradient ?? this.authBackgroundGradient,
dashboardHeaderTextColor:
dashboardHeaderTextColor ?? this.dashboardHeaderTextColor,
);
}
@override
AppThemeExtras lerp(ThemeExtension<AppThemeExtras>? other, double t) {
if (other is! AppThemeExtras) return this;
return t < 0.5 ? this : other;
}
}

View File

@@ -0,0 +1,428 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/user_stats.dart';
import '../../../../core/models/achievement.dart';
/// Página de conquistas para alunos
class StudentAchievementsPage extends StatefulWidget {
const StudentAchievementsPage({super.key});
@override
State<StudentAchievementsPage> createState() =>
_StudentAchievementsPageState();
}
class _StudentAchievementsPageState extends State<StudentAchievementsPage> {
UserStats? _userStats;
List<Achievement> _allAchievements = [];
List<Achievement> _unlockedAchievements = [];
List<Achievement> _lockedAchievements = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadAchievements();
}
Future<void> _loadAchievements() async {
try {
final user = AuthService.currentUser;
if (user == null) return;
final [stats, allAchievements] = await Future.wait([
GamificationService.getUserStats(user.uid),
GamificationService.getAvailableAchievements(),
]);
final userStats = stats as UserStats?;
final achievements = allAchievements as List<Achievement>;
if (userStats != null) {
final unlockedIds = userStats.unlockedAchievements
.map((ua) => ua.achievementId)
.toSet();
final unlocked = achievements
.where((a) => unlockedIds.contains(a.id))
.toList();
final locked = achievements
.where((a) => !unlockedIds.contains(a.id))
.toList();
if (mounted) {
setState(() {
_userStats = userStats;
_allAchievements = achievements;
_unlockedAchievements = unlocked;
_lockedAchievements = locked;
_loading = false;
});
}
}
} catch (e) {
print('Error loading achievements: $e');
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final themeExtras = AppThemeExtras.of(context);
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.primary,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/student-dashboard'),
),
title: const Text(
'Minhas Conquistas',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${_unlockedAchievements.length}/${_allAchievements.length}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [cs.primary.withValues(alpha: 0.05), cs.surface],
),
),
child: SafeArea(
top: false,
child: Column(
children: [
const SizedBox(height: 52),
// Content
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: DefaultTabController(
length: 2,
child: Column(
children: [
// Tabs
Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
labelColor: cs.onPrimary,
unselectedLabelColor: cs.onSurfaceVariant,
indicator: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(10),
),
indicatorSize: TabBarIndicatorSize.tab,
tabs: const [
Tab(
icon: Icon(Icons.emoji_events),
text: 'Desbloqueadas',
),
Tab(
icon: Icon(Icons.lock_outline),
text: 'Bloqueadas',
),
],
),
),
// Tab Content
Expanded(
child: TabBarView(
children: [
_buildUnlockedAchievements(),
_buildLockedAchievements(),
],
),
),
],
),
),
),
],
),
),
),
);
}
Widget _buildUnlockedAchievements() {
if (_unlockedAchievements.isEmpty) {
return _buildEmptyState(
icon: Icons.emoji_events_outlined,
title: 'Nenhuma conquista desbloqueada',
subtitle:
'Complete quizzes e mantenha seu streak para desbloquear conquistas!',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _unlockedAchievements.length,
itemBuilder: (context, index) {
final achievement = _unlockedAchievements[index];
return _buildAchievementCard(achievement, isUnlocked: true)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: Duration(milliseconds: index * 50));
},
);
}
Widget _buildLockedAchievements() {
if (_lockedAchievements.isEmpty) {
return _buildEmptyState(
icon: Icons.emoji_events,
title: 'Parabéns!',
subtitle: 'Você desbloqueou todas as conquistas disponíveis!',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _lockedAchievements.length,
itemBuilder: (context, index) {
final achievement = _lockedAchievements[index];
return _buildAchievementCard(achievement, isUnlocked: false)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: Duration(milliseconds: index * 50));
},
);
}
Widget _buildAchievementCard(
Achievement achievement, {
required bool isUnlocked,
}) {
final cs = Theme.of(context).colorScheme;
final color = _getRarityColor(achievement.rarity);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isUnlocked ? cs.surface : cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isUnlocked
? color.withValues(alpha: 0.3)
: cs.outline.withValues(alpha: 0.2),
width: isUnlocked ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: isUnlocked ? 0.1 : 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
// Icon
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isUnlocked
? color.withValues(alpha: 0.2)
: cs.outline.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(24),
),
child: Icon(
_getIconData(achievement.icon),
color: isUnlocked ? color : cs.outline,
size: 24,
),
),
const SizedBox(width: 16),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
achievement.name,
style: TextStyle(
color: isUnlocked ? cs.onSurface : cs.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
achievement.description,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getRarityColor(
achievement.rarity,
).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
achievement.rarity.toUpperCase(),
style: TextStyle(
color: _getRarityColor(achievement.rarity),
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Text(
'+${achievement.points} pts',
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
// Status
if (isUnlocked)
Icon(Icons.check_circle, color: Colors.green, size: 24)
else
Icon(Icons.lock, color: cs.outline, size: 24),
],
),
);
}
Widget _buildEmptyState({
required IconData icon,
required String title,
required String subtitle,
}) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: cs.onSurfaceVariant),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
);
}
Color _getRarityColor(String rarity) {
switch (rarity) {
case 'common':
return Colors.grey;
case 'rare':
return Colors.blue;
case 'epic':
return Colors.purple;
case 'legendary':
return Colors.orange;
default:
return Colors.grey;
}
}
IconData _getIconData(String iconName) {
switch (iconName) {
case 'emoji_events':
return Icons.emoji_events;
case 'school':
return Icons.school;
case 'local_fire_department':
return Icons.local_fire_department;
case 'schedule':
return Icons.schedule;
case 'trending_up':
return Icons.trending_up;
case 'star':
return Icons.star;
case 'military_tech':
return Icons.military_tech;
case 'workspace_premium':
return Icons.workspace_premium;
case 'psychology':
return Icons.psychology;
case 'lightbulb':
return Icons.lightbulb;
case 'whatshot':
return Icons.whatshot;
case 'stars':
return Icons.stars;
default:
return Icons.emoji_events;
}
}
}

View File

@@ -0,0 +1,873 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/logger.dart';
class ChatHistoryPage extends StatefulWidget {
const ChatHistoryPage({super.key});
@override
State<ChatHistoryPage> createState() => _ChatHistoryPageState();
}
class _ChatHistoryPageState extends State<ChatHistoryPage> {
List<Map<String, dynamic>> _conversations = [];
List<Map<String, dynamic>> _filteredConversations = [];
bool _isLoading = true;
final TextEditingController _searchController = TextEditingController();
String _selectedDateFilter = 'all'; // all, today, yesterday, week, month
final Set<String> _selectedConversationIds = {};
bool _isSelectionMode = false;
final Map<String, String> _materialNamesCache = {}; // materialId -> fileName
String? _source; // 'chat' or 'intro'
String? _previousConversationId;
@override
void initState() {
super.initState();
_loadConversations();
_searchController.addListener(_filterConversations);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Extract source and previous conversation ID from URL parameters
final uri = GoRouterState.of(context).uri;
_source = uri.queryParameters['source'];
_previousConversationId = uri.queryParameters['conversationId'];
}
void _handleBackNavigation() {
if (_source == 'chat' && _previousConversationId != null) {
// Go back to the previous conversation
context.go('/ai-tutor/$_previousConversationId');
} else if (_source == 'chat') {
// Go to new chat (no previous conversation)
context.go('/ai-tutor');
} else if (_source == 'intro') {
// Go back to intro screen
context.go('/ai-tutor');
} else {
// Default: go to intro screen (from dashboard or other sources)
context.go('/ai-tutor');
}
}
/// Fetch material names for given material IDs
Future<void> _loadMaterialNames(List<String> materialIds) async {
if (materialIds.isEmpty) return;
final uncachedIds = materialIds
.where((id) => !_materialNamesCache.containsKey(id))
.toList();
if (uncachedIds.isEmpty) return;
try {
final batches = <Future<QuerySnapshot>>[];
for (int i = 0; i < uncachedIds.length; i += 10) {
final batch = uncachedIds.skip(i).take(10).toList();
batches.add(
FirebaseFirestore.instance
.collection('materials')
.where(FieldPath.documentId, whereIn: batch)
.get(),
);
}
final results = await Future.wait(batches);
for (final snapshot in results) {
for (final doc in snapshot.docs) {
final data = doc.data() as Map<String, dynamic>?;
if (data != null) {
final fileName = data['fileName'] as String?;
if (fileName != null) {
_materialNamesCache[doc.id] = fileName;
}
}
}
}
if (mounted) {
setState(() {});
}
} catch (e) {
Logger.error('Error loading material names: $e');
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadConversations() async {
setState(() => _isLoading = true);
try {
final conversations = await ChatMemoryService.getConversations();
// Collect all material IDs from all conversations
final allMaterialIds = <String>[];
for (final conv in conversations) {
final materialIds = conv['selectedMaterialIds'] as List<String>? ?? [];
allMaterialIds.addAll(materialIds);
}
// Load material names
await _loadMaterialNames(allMaterialIds);
if (mounted) {
setState(() {
_conversations = conversations;
_filteredConversations = conversations;
_isLoading = false;
});
}
} catch (e) {
Logger.error('Error loading conversations: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedConversationIds.clear();
}
});
}
void _toggleConversationSelection(String conversationId) {
setState(() {
if (_selectedConversationIds.contains(conversationId)) {
_selectedConversationIds.remove(conversationId);
} else {
_selectedConversationIds.add(conversationId);
}
});
}
Future<void> _deleteSelectedConversations() async {
if (_selectedConversationIds.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar ${_selectedConversationIds.length} conversas'),
content: const Text('Tem certeza que deseja eliminar estas conversas?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
for (final id in _selectedConversationIds) {
await ChatMemoryService.deleteConversation(id);
}
await _loadConversations();
setState(() {
_selectedConversationIds.clear();
_isSelectionMode = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversas eliminadas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
Logger.error('Error deleting conversations: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
void _filterConversations() {
final searchQuery = _searchController.text.toLowerCase();
setState(() {
_filteredConversations = _conversations.where((conv) {
// Filter by search query (title)
final title = (conv['title'] as String? ?? '').toLowerCase();
final matchesSearch = title.contains(searchQuery);
// Filter by date
final matchesDate = _matchesDateFilter(conv);
return matchesSearch && matchesDate;
}).toList();
});
}
bool _matchesDateFilter(Map<String, dynamic> conv) {
final updatedAt = conv['updatedAt'] as Timestamp?;
if (updatedAt == null) return true;
final date = updatedAt.toDate();
final now = DateTime.now();
final difference = now.difference(date);
switch (_selectedDateFilter) {
case 'today':
return difference.inDays == 0;
case 'yesterday':
return difference.inDays == 1;
case 'week':
return difference.inDays < 7;
case 'month':
return difference.inDays < 30;
default:
return true;
}
}
Future<void> _deleteConversation(String conversationId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Eliminar conversa'),
content: const Text('Tem certeza que deseja eliminar esta conversa?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
await ChatMemoryService.deleteConversation(conversationId);
await _loadConversations();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversa eliminada'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
Logger.error('Error deleting conversation: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversa'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
String _formatDate(Timestamp? timestamp) {
if (timestamp == null) return '';
final date = timestamp.toDate();
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Hoje ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else if (difference.inDays == 1) {
return 'Ontem';
} else if (difference.inDays < 7) {
return '${difference.inDays} dias atrás';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final orangeAccent = isDark
? const Color(0xFFC47A2A)
: const Color(0xFFF68D2D);
final lightOrange = isDark
? const Color(0xFFD89035)
: const Color(0xFFF7A960);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_handleBackNavigation();
},
child: Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
cs.surfaceVariant,
orangeAccent.withValues(alpha: 0.3),
cs.surfaceContainerLowest,
]
: [
cs.primary.withValues(alpha: 0.08),
orangeAccent.withValues(alpha: 0.05),
cs.surfaceContainerLowest,
],
stops: const [0.0, 0.4, 1.0],
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom app bar
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 15,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 20,
),
onPressed: _handleBackNavigation,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isSelectionMode
? '${_selectedConversationIds.length} selecionadas'
: 'Histórico',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (_isSelectionMode)
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: _selectedConversationIds.isEmpty
? null
: _deleteSelectedConversations,
)
else
IconButton(
icon: const Icon(
Icons.checklist,
color: Colors.white,
),
onPressed: _toggleSelectionMode,
),
if (!_isSelectionMode)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white),
onPressed: _loadConversations,
),
if (_isSelectionMode)
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: _toggleSelectionMode,
),
],
),
),
// Search and filters
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar conversas...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: orangeAccent.withValues(alpha: 0.3),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: orangeAccent.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(
color: AppColors.primaryOrange,
width: 2,
),
),
filled: true,
fillColor: cs.surface,
),
),
const SizedBox(height: 12),
// Date filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDateFilterChip(
'Todas',
'all',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Hoje',
'today',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Ontem',
'yesterday',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Semana',
'week',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Mês',
'month',
cs,
orangeAccent,
),
],
),
),
],
),
),
// Conversation list
Expanded(
child: _isLoading
? Center(
child: CircularProgressIndicator(color: orangeAccent),
)
: _filteredConversations.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(
alpha: 0.2,
),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.chat_bubble_outline,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 20),
Text(
_conversations.isEmpty
? 'Sem conversas ainda'
: 'Nenhuma conversa encontrada',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
_conversations.isEmpty
? 'Começa uma conversa com o Vico!'
: 'Tenta ajustar os filtros',
style: TextStyle(
color: cs.onSurfaceVariant.withValues(
alpha: 0.7,
),
fontSize: 14,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _filteredConversations.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final conversation = _filteredConversations[index];
return _buildConversationCard(
conversation,
cs,
orangeAccent,
lightOrange,
);
},
),
),
],
),
),
),
),
);
}
Widget _buildDateFilterChip(
String label,
String value,
ColorScheme cs,
Color orangeAccent,
) {
final isSelected = _selectedDateFilter == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedDateFilter = value;
});
_filterConversations();
},
backgroundColor: cs.surface,
selectedColor: orangeAccent.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? orangeAccent : cs.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
side: BorderSide(
color: isSelected ? orangeAccent : cs.outline.withValues(alpha: 0.3),
),
);
}
Widget _buildConversationCard(
Map<String, dynamic> conversation,
ColorScheme cs,
Color orangeAccent,
Color lightOrange,
) {
final isSelected = _selectedConversationIds.contains(conversation['id']);
return Dismissible(
key: Key(conversation['id']),
direction: _isSelectionMode
? DismissDirection.none
: DismissDirection.endToStart,
onDismissed: _isSelectionMode
? null
: (_) => _deleteConversation(conversation['id']),
background: Container(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
child: InkWell(
onTap: () {
if (_isSelectionMode) {
_toggleConversationSelection(conversation['id']);
} else {
ChatMemoryService.setCurrentConversationId(conversation['id']);
context.go('/ai-tutor/${conversation['id']}');
}
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? orangeAccent.withValues(alpha: 0.1)
: cs.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? orangeAccent
: orangeAccent.withValues(alpha: 0.2),
width: isSelected ? 2 : 1.5,
),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (_isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Checkbox(
value: isSelected,
onChanged: (_) =>
_toggleConversationSelection(conversation['id']),
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return orangeAccent;
}
return null;
}),
),
)
else
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.chat_bubble,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conversation['title'],
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(conversation['updatedAt']),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
),
),
const SizedBox(width: 12),
Icon(
Icons.message,
size: 12,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${conversation['messageCount']} msgs',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
),
),
],
),
],
),
),
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
],
),
if (conversation['selectedMaterialIds'] != null &&
(conversation['selectedMaterialIds'] as List).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: (conversation['selectedMaterialIds'] as List)
.take(3)
.map<Widget>((id) {
final materialId = id as String;
final displayName =
_materialNamesCache[materialId] ?? materialId;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: lightOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: lightOrange.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
displayName.length > 20
? '${displayName.substring(0, 20)}...'
: displayName,
style: TextStyle(
color: lightOrange,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
);
})
.toList(),
),
),
],
),
),
),
).animate().slideX(duration: 300.ms).fadeIn(duration: 300.ms);
}
}

View File

@@ -327,13 +327,20 @@ class _TutorChatPageState extends State<TutorChatPage>
void _addWelcomeMessage() {
final welcomeMessage = {
'content': '''Olá! Sou seu assistente educacional AI. Posso ajudar você a:
'content':
'''**Olá! Sou o Vico, o teu Assistente IA oficial do Learn 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': <SourceCitation>[],

View File

@@ -53,10 +53,12 @@ class _ChatInputState extends State<ChatInput> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
color: cs.surface.withOpacity(0.98),
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
@@ -169,17 +171,19 @@ class _ChatInputState extends State<ChatInput> {
}
Widget _buildInputField(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
Colors.grey[100]!,
Colors.grey[50]!,
cs.surfaceContainerHighest,
cs.surface,
],
),
border: Border.all(
color: Colors.grey[300]!,
color: cs.outline.withOpacity(0.3),
),
),
child: Row(
@@ -190,9 +194,9 @@ class _ChatInputState extends State<ChatInput> {
controller: widget.controller,
focusNode: _focusNode,
maxLines: _isExpanded ? 5 : 1,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
color: Color(0xFF2D3748),
color: cs.onSurface,
),
decoration: InputDecoration(
hintText: 'Faça sua pergunta sobre o conteúdo...',
@@ -253,8 +257,11 @@ class _ChatInputState extends State<ChatInput> {
height: 48,
decoration: BoxDecoration(
gradient: widget.controller.text.isNotEmpty
? const LinearGradient(
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
? LinearGradient(
colors: [
cs.primary,
cs.primary.withOpacity(0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)

View File

@@ -1,6 +1,8 @@
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';
import '../../../../core/theme/app_theme_extension.dart';
/// Widget for displaying chat messages with source citations
class MessageBubble extends StatelessWidget {
@@ -72,22 +74,28 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildAvatar(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final extras = AppThemeExtras.of(context);
final accent = isUser ? cs.primary : cs.secondary;
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: isUser
? const LinearGradient(
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
? LinearGradient(
colors: [
extras.actionCardGradientStart,
extras.actionCardGradientEnd,
],
)
: const LinearGradient(
colors: [Color(0xFFF68D2D), Color(0xFFE67E22)],
: LinearGradient(
colors: [cs.secondary, cs.secondary.withOpacity(0.85)],
),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: (isUser ? const Color(0xFF82C9BD) : const Color(0xFFF68D2D))
.withOpacity(0.3),
color: accent.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -102,6 +110,9 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildMessageBubble(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final extras = AppThemeExtras.of(context);
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
@@ -109,15 +120,18 @@ class MessageBubble extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
gradient: isUser
? const LinearGradient(
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
? LinearGradient(
colors: [
extras.actionCardGradientStart,
extras.actionCardGradientEnd,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [
Colors.white.withOpacity(0.95),
Colors.white.withOpacity(0.9),
cs.surfaceContainerHighest,
cs.surface,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -139,15 +153,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: cs.onSurface,
fontSize: 16,
height: 1.4,
),
strong: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.4,
),
em: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontStyle: FontStyle.italic,
height: 1.4,
),
listBullet: TextStyle(
color: cs.onSurface,
fontSize: 16,
height: 1.4,
),
),
),
],
),
);
@@ -233,7 +275,7 @@ class MessageBubble extends StatelessWidget {
Icon(
Icons.menu_book,
size: 14,
color: const Color(0xFF82C9BD),
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
Expanded(
@@ -251,7 +293,7 @@ class MessageBubble extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF82C9BD).withOpacity(0.1),
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
@@ -259,7 +301,7 @@ class MessageBubble extends StatelessWidget {
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: const Color(0xFF82C9BD),
color: Theme.of(context).colorScheme.primary,
),
),
),

View File

@@ -0,0 +1,468 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/class_stats.dart';
import '../../../../core/models/achievement.dart';
import '../widgets/class_analytics_card.dart';
import '../widgets/class_students_inline_widget.dart';
import '../widgets/create_achievement_dialog.dart';
/// Analytics page for teachers with class breakdowns and rankings
class AnalyticsPage extends StatefulWidget {
const AnalyticsPage({super.key});
@override
State<AnalyticsPage> createState() => _AnalyticsPageState();
}
class _AnalyticsPageState extends State<AnalyticsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _classSearchController = TextEditingController();
String _classSearchQuery = '';
List<ClassStats> _classStats = [];
bool _loading = true;
String? _selectedClassId;
String? _selectedClassName;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadClassStats();
}
@override
void dispose() {
_tabController.dispose();
_classSearchController.dispose();
super.dispose();
}
Future<void> _loadClassStats() async {
try {
final user = AuthService.currentUser;
if (user == null) return;
// Obter turmas do professor
final classesSnapshot = await FirebaseFirestore.instance
.collection('classes')
.where('teacherId', isEqualTo: user.uid)
.get();
final classStatsList = <ClassStats>[];
for (final classDoc in classesSnapshot.docs) {
final classId = classDoc.id;
// Forçar atualização para obter dados mais recentes
final stats = await GamificationService.getClassStats(
classId,
forceRefresh: true,
);
if (stats != null) {
classStatsList.add(stats);
}
}
if (mounted) {
setState(() {
_classStats = classStatsList;
_loading = false;
});
}
} catch (e) {
print('Error loading class stats: $e');
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final themeExtras = AppThemeExtras.of(context);
return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (didPop) return;
context.go('/teacher-dashboard');
},
child: Scaffold(
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: themeExtras.dashboardBackgroundGradient,
stops: themeExtras.dashboardGradientStops,
),
),
child: SafeArea(
top: false,
bottom: false,
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.only(
left: 24,
right: 24,
bottom: 28,
top: 52,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
),
onPressed: () => context.go('/teacher-dashboard'),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Analytics',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Acompanhe o desempenho das turmas',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 16,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.add, color: Colors.white),
onPressed: _showCreateAchievementDialog,
tooltip: 'Criar Conquista',
),
],
),
const SizedBox(height: 20),
TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white.withValues(
alpha: 0.7,
),
indicatorColor: Colors.white,
indicatorWeight: 2,
tabs: const [
Tab(text: 'Turmas'),
Tab(text: 'Alunos'),
],
),
],
),
),
// Content
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildClassesTab(), _buildStudentsTab()],
),
),
],
),
),
),
),
);
}
Widget _buildClassesTab() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_classStats.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.analytics_outlined,
size: 64,
color: Colors.white.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Nenhuma turma encontrada',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
'Crie turmas para ver as analytics aqui',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
);
}
final filtered = _classSearchQuery.isEmpty
? _classStats
: _classStats
.where(
(s) => s.className.toLowerCase().contains(_classSearchQuery),
)
.toList();
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
sliver: SliverToBoxAdapter(
child: Column(
children: [
// Overview Cards
Center(
child: _buildOverviewCard(
'Total de Alunos',
'${_classStats.fold(0, (sum, s) => sum + s.totalStudents)}',
Icons.people,
Colors.blue,
),
),
const SizedBox(height: 20),
// Search bar
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Theme(
data: ThemeData.dark().copyWith(
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Colors.white24,
selectionHandleColor: Colors.white,
),
),
child: TextField(
controller: _classSearchController,
onChanged: (v) => setState(
() => _classSearchQuery = v.trim().toLowerCase(),
),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
cursorColor: Colors.white,
decoration: InputDecoration(
hintText: 'Pesquisar turma…',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
),
if (_classSearchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_classSearchController.clear();
setState(() => _classSearchQuery = '');
},
child: Icon(
Icons.close,
color: Colors.white.withValues(alpha: 0.7),
size: 18,
),
),
],
),
),
const SizedBox(height: 20),
],
),
),
),
if (filtered.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Nenhuma turma encontrada',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ClassAnalyticsCard(
classStats: filtered[index],
onTap: () => _showClassStudents(filtered[index]),
),
childCount: filtered.length,
),
),
),
],
);
}
Widget _buildStudentsTab() {
if (_selectedClassId == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 64,
color: Colors.white.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Seleciona uma turma',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
'Clica numa turma no separador "Turmas" para ver os alunos',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ClassStudentsInlineWidget(
classId: _selectedClassId!,
className: _selectedClassName ?? '',
);
}
Widget _buildOverviewCard(
String title,
String value,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
).animate().scale(duration: 600.ms, curve: Curves.elasticOut);
}
void _showClassStudents(ClassStats stats) {
setState(() {
_selectedClassId = stats.classId;
_selectedClassName = stats.className;
});
_tabController.animateTo(1);
}
void _showCreateAchievementDialog() {
showDialog(
context: context,
builder: (context) => CreateAchievementDialog(
onAchievementCreated: (achievement) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Conquista "${achievement.name}" criada com sucesso!',
),
backgroundColor: Colors.green,
),
);
},
),
);
}
}

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../../core/models/class_stats.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// Card displaying analytics for a specific class
class ClassAnalyticsCard extends StatelessWidget {
final ClassStats classStats;
final VoidCallback onTap;
const ClassAnalyticsCard({
super.key,
required this.classStats,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 16),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cs.primary.withValues(alpha: 0.9),
cs.primary.withValues(alpha: 0.7),
],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
classStats.className,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${classStats.activeStudents} de ${classStats.totalStudents} alunos matriculados',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.trending_up,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'${(classStats.averageProgress * 100).toInt()}%',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Progress Bar
Container(
height: 8,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: classStats.averageProgress.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppThemeExtras.of(context).heroProgressStart,
AppThemeExtras.of(context).heroProgressEnd,
],
),
borderRadius: BorderRadius.circular(4),
),
),
),
),
const SizedBox(height: 20),
// Stats Grid
Row(
children: [
Expanded(
child: _buildStatCard(
icon: Icons.quiz,
value: '${classStats.activeQuizzes}',
label: 'Quizzes Ativos',
context: context,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
icon: Icons.description,
value: '${classStats.totalContent}',
label: 'Conteúdos',
context: context,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
icon: Icons.warning,
value: '${classStats.studentsNeedingSupport.length}',
label: 'Precisam Apoio',
context: context,
isWarning: classStats.studentsNeedingSupport.isNotEmpty,
),
),
],
),
// Students needing support preview
if (classStats.studentsNeedingSupport.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.priority_high,
color: Colors.orange.withValues(alpha: 0.8),
size: 16,
),
const SizedBox(width: 6),
Text(
'Alunos que precisam de atenção:',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
...classStats.studentsNeedingSupport
.take(3)
.map(
(student) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
student.studentName,
style: TextStyle(
color: Colors.white.withValues(
alpha: 0.8,
),
fontSize: 11,
),
),
),
Text(
'${student.averageScore.toInt()}%',
style: TextStyle(
color: Colors.white.withValues(
alpha: 0.8,
),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
if (classStats.studentsNeedingSupport.length > 3)
Text(
'+${classStats.studentsNeedingSupport.length - 3} alunos',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
),
),
],
// Click indicator
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Ver alunos da turma',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 12,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward,
color: Colors.white.withValues(alpha: 0.8),
size: 12,
),
],
),
],
),
),
),
),
).animate().scale(duration: 600.ms, curve: Curves.elasticOut);
}
Widget _buildStatCard({
required IconData icon,
required String value,
required String label,
required BuildContext context,
bool isWarning = false,
}) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
children: [
Icon(icon, color: isWarning ? Colors.orange : Colors.white, size: 20),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 10,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}

View File

@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/class_stats.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// Widget displaying student ranking for a specific class
class ClassRankingWidget extends StatefulWidget {
final String classId;
const ClassRankingWidget({super.key, required this.classId});
@override
State<ClassRankingWidget> createState() => _ClassRankingWidgetState();
}
class _ClassRankingWidgetState extends State<ClassRankingWidget> {
List<StudentRanking> _rankings = [];
ClassStats? _classStats;
bool _loading = true;
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadRankingData();
}
Future<void> _loadRankingData() async {
try {
final results = await Future.wait([
GamificationService.getClassRanking(widget.classId),
GamificationService.getClassStats(widget.classId),
]);
final rankings = results[0] as List<StudentRanking>;
final classStats = results[1] as ClassStats?;
if (mounted) {
setState(() {
_rankings = rankings;
_classStats = classStats;
_loading = false;
});
}
} catch (e) {
print('Error loading ranking data: $e');
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
List<StudentRanking> get _filteredRankings {
if (_searchQuery.isEmpty) return _rankings;
return _rankings
.where(
(student) =>
student.studentName.toLowerCase().contains(
_searchQuery.toLowerCase(),
) ||
student.studentEmail.toLowerCase().contains(
_searchQuery.toLowerCase(),
),
)
.toList();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return Container(
margin: const EdgeInsets.all(24),
child: Column(
children: [
// Header with class info
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.8)],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_classStats?.className ?? 'Carregando...',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${_rankings.length} alunos • Progresso médio: ${((_classStats?.averageProgress ?? 0) * 100).toInt()}%',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.leaderboard,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'Ranking',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
],
),
),
const SizedBox(height: 20),
// Search bar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(Icons.search, color: Colors.white.withValues(alpha: 0.7)),
const SizedBox(width: 12),
Expanded(
child: TextField(
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Buscar aluno...',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
),
border: InputBorder.none,
),
),
),
if (_searchQuery.isNotEmpty)
IconButton(
icon: Icon(
Icons.clear,
color: Colors.white.withValues(alpha: 0.7),
),
onPressed: () {
setState(() {
_searchQuery = '';
});
},
),
],
),
),
const SizedBox(height: 20),
// Ranking list
Expanded(
child: _filteredRankings.isEmpty
? _buildEmptyState()
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _filteredRankings.length,
itemBuilder: (context, index) {
final student = _filteredRankings[index];
final rankPosition = _rankings.indexOf(student) + 1;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildStudentRankingCard(student, rankPosition),
);
},
),
),
],
),
);
}
Widget _buildEmptyState() {
if (_searchQuery.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.white.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Nenhum aluno encontrado',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
'Tente buscar com outros termos',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 64,
color: Colors.white.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Nenhum aluno na turma',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
'Os alunos aparecerão aqui quando se matricularem',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
);
}
Widget _buildStudentRankingCard(StudentRanking student, int rankPosition) {
final cs = Theme.of(context).colorScheme;
// Determinar cor baseada na posição
Color rankColor;
IconData rankIcon;
if (rankPosition == 1) {
rankColor = Colors.amber;
rankIcon = Icons.emoji_events;
} else if (rankPosition == 2) {
rankColor = Colors.grey.withValues(alpha: 0.8);
rankIcon = Icons.workspace_premium;
} else if (rankPosition == 3) {
rankColor = Colors.brown.withValues(alpha: 0.8);
rankIcon = Icons.military_tech;
} else {
rankColor = Colors.white.withValues(alpha: 0.7);
rankIcon = Icons.numbers;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Row(
children: [
// Rank position
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: rankColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: rankColor.withValues(alpha: 0.5)),
),
child: Center(
child: rankPosition <= 3
? Icon(rankIcon, color: rankColor, size: 20)
: Text(
'$rankPosition',
style: TextStyle(
color: rankColor,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 16),
// Student info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
student.studentName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
student.studentEmail,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
),
),
],
),
),
// Stats
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Overall score
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getScoreColor(
student.overallScore,
).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${student.overallScore.toInt()}%',
style: TextStyle(
color: _getScoreColor(student.overallScore),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
// Quiz completion
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.quiz,
color: Colors.white.withValues(alpha: 0.7),
size: 12,
),
const SizedBox(width: 4),
Text(
'${student.completedQuizzes}/${student.totalQuizzes}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 11,
),
),
],
),
const SizedBox(height: 2),
// Streak
if (student.currentStreak > 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_fire_department,
color: Colors.orange.withValues(alpha: 0.8),
size: 12,
),
const SizedBox(width: 4),
Text(
'${student.currentStreak}d',
style: TextStyle(
color: Colors.orange.withValues(alpha: 0.8),
fontSize: 11,
),
),
],
),
],
),
],
),
).animate().slideX(duration: 300.ms, curve: Curves.easeOut);
}
Color _getScoreColor(double score) {
if (score >= 80) return Colors.green;
if (score >= 60) return Colors.blue;
if (score >= 40) return Colors.orange;
return Colors.red;
}
}

View File

@@ -0,0 +1,581 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
class ClassStudentsInlineWidget extends StatefulWidget {
final String classId;
final String className;
const ClassStudentsInlineWidget({
super.key,
required this.classId,
required this.className,
});
@override
State<ClassStudentsInlineWidget> createState() =>
_ClassStudentsInlineWidgetState();
}
class _ClassStudentsInlineWidgetState extends State<ClassStudentsInlineWidget> {
final _searchController = TextEditingController();
String _searchQuery = '';
String? _classCode;
late Stream<QuerySnapshot> _enrollmentsStream;
@override
void initState() {
super.initState();
_loadClassCode();
_initStream();
}
@override
void didUpdateWidget(ClassStudentsInlineWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.classId != widget.classId) {
_loadClassCode();
_initStream();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _initStream() {
_enrollmentsStream = FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.snapshots();
}
Future<void> _loadClassCode() async {
final doc = await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.get();
if (mounted) {
setState(() => _classCode = doc.data()?['code'] as String? ?? '');
}
}
Future<void> _confirmRemove(
String enrollmentDocId,
String studentName,
) async {
final cs = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Remover aluno'),
content: Text(
'Tens a certeza que queres remover "$studentName" desta turma?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: cs.error),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Remover'),
),
],
),
);
if (confirmed != true) return;
await _deleteEnrollment(enrollmentDocId, studentName);
}
Future<void> _deleteEnrollment(
String enrollmentDocId,
String studentName,
) async {
final cs = Theme.of(context).colorScheme;
try {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentDocId)
.delete();
if (!mounted) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
backgroundColor: cs.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
content: Text(
'"$studentName" foi removido da turma.',
style: const TextStyle(color: Colors.white),
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Erro ao remover: $e')));
}
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return StreamBuilder<QuerySnapshot>(
stream: _enrollmentsStream,
builder: (context, snapshot) {
final docs = snapshot.data?.docs ?? [];
final enrollments = List<QueryDocumentSnapshot>.from(docs)
..sort((a, b) {
final aTs = (a.data() as Map)['joinedAt'] as Timestamp?;
final bTs = (b.data() as Map)['joinedAt'] as Timestamp?;
if (aTs == null && bTs == null) return 0;
if (aTs == null) return 1;
if (bTs == null) return -1;
return aTs.compareTo(bTs);
});
final filtered = _searchQuery.isEmpty
? enrollments
: enrollments.where((doc) {
final d = doc.data() as Map<String, dynamic>;
final name = (d['studentName'] as String? ?? '').toLowerCase();
final email = (d['studentEmail'] as String? ?? '')
.toLowerCase();
return name.contains(_searchQuery) ||
email.contains(_searchQuery);
}).toList();
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
sliver: SliverToBoxAdapter(
child: _buildHeader(cs, enrollments.length, snapshot),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
sliver: SliverToBoxAdapter(child: _buildSearchBar()),
),
if (snapshot.connectionState == ConnectionState.waiting &&
snapshot.data == null)
const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
)
else if (filtered.isEmpty) ...[
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: _buildEmpty(cs),
),
),
] else
SliverPadding(
padding: EdgeInsets.fromLTRB(20, 14, 20, 20 + bottomInset),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final doc = filtered[index];
final data = doc.data() as Map<String, dynamic>;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _buildStudentCard(
cs: cs,
enrollmentDocId: doc.id,
studentName:
data['studentName'] as String? ?? 'Aluno sem nome',
studentEmail: data['studentEmail'] as String? ?? '',
joinedAt: data['joinedAt'] as Timestamp?,
),
);
}, childCount: filtered.length),
),
),
],
);
},
);
}
// ── Sub-widgets ────────────────────────────────────────────────────────────
Widget _buildHeader(
ColorScheme cs,
int count,
AsyncSnapshot<QuerySnapshot> snapshot,
) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.8)],
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.className,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
snapshot.connectionState == ConnectionState.waiting &&
snapshot.data == null
? 'A carregar…'
: '$count aluno${count == 1 ? '' : 's'} inscrito${count == 1 ? '' : 's'}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 13,
),
),
],
),
),
_buildCodeBadge(cs),
],
),
);
}
Widget _buildCodeBadge(ColorScheme cs) {
return GestureDetector(
onTap: () {
if (_classCode == null || _classCode == '') return;
Clipboard.setData(ClipboardData(text: _classCode!));
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
backgroundColor: cs.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 2),
content: const Text(
'Código copiado!',
style: TextStyle(color: Colors.white),
),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.3)),
),
child: Column(
children: [
const Text(
'Código',
style: TextStyle(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_classCode ?? '',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.copy,
color: Colors.white.withValues(alpha: 0.7),
size: 10,
),
const SizedBox(width: 3),
Text(
'copiar',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 9,
),
),
],
),
],
),
),
);
}
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Theme(
data: ThemeData.dark().copyWith(
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Colors.white24,
selectionHandleColor: Colors.white,
),
),
child: TextField(
controller: _searchController,
onChanged: (v) =>
setState(() => _searchQuery = v.trim().toLowerCase()),
style: const TextStyle(color: Colors.white, fontSize: 14),
cursorColor: Colors.white,
decoration: InputDecoration(
hintText: 'Pesquisar aluno…',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
child: Icon(
Icons.close,
color: Colors.white.withValues(alpha: 0.7),
size: 18,
),
),
],
),
);
}
Widget _buildStudentCard({
required ColorScheme cs,
required String enrollmentDocId,
required String studentName,
required String studentEmail,
required Timestamp? joinedAt,
}) {
return Dismissible(
key: Key(enrollmentDocId),
direction: DismissDirection.endToStart,
confirmDismiss: (_) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Remover aluno'),
content: Text(
'Tens a certeza que queres remover "$studentName" desta turma?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancelar'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: cs.error),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Remover'),
),
],
),
);
return confirmed ?? false;
},
onDismissed: (_) => _deleteEnrollment(enrollmentDocId, studentName),
background: Container(
decoration: BoxDecoration(
color: cs.error.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(14),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
studentName.isNotEmpty ? studentName[0].toUpperCase() : '?',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
studentName,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (studentEmail.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
studentEmail,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 12,
),
),
],
if (joinedAt != null) ...[
const SizedBox(height: 2),
Text(
'Entrou em ${DateFormat('dd/MM/yyyy').format(joinedAt.toDate())}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 11,
),
),
],
],
),
),
IconButton(
icon: Icon(
Icons.person_remove_outlined,
color: cs.error.withValues(alpha: 0.85),
size: 20,
),
tooltip: 'Remover aluno',
onPressed: () => _confirmRemove(enrollmentDocId, studentName),
),
],
),
),
);
}
Widget _buildEmpty(ColorScheme cs) {
if (_searchQuery.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 48,
color: Colors.white.withValues(alpha: 0.4),
),
const SizedBox(height: 12),
Text(
'Nenhum aluno encontrado',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 16,
),
),
],
),
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 56,
color: Colors.white.withValues(alpha: 0.35),
),
const SizedBox(height: 14),
Text(
'Nenhum aluno inscrito',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 16,
),
),
const SizedBox(height: 6),
Text(
'Partilha o código da turma com os alunos.',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 13,
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,633 @@
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/achievement.dart';
/// Dialog for creating custom achievements
class CreateAchievementDialog extends StatefulWidget {
final Function(Achievement) onAchievementCreated;
const CreateAchievementDialog({
super.key,
required this.onAchievementCreated,
});
@override
State<CreateAchievementDialog> createState() => _CreateAchievementDialogState();
}
class _CreateAchievementDialogState extends State<CreateAchievementDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _valueController = TextEditingController();
String _selectedCategory = 'quiz';
String _selectedRequirementType = 'quiz_completion';
String _selectedOperator = '>=';
String _selectedIcon = 'star';
String _selectedRarity = 'common';
int _points = 10;
final List<String> _categories = [
'quiz',
'study_time',
'streak',
'concept',
'general',
];
final List<String> _requirementTypes = [
'quiz_completion',
'quiz_score',
'study_time',
'streak_days',
'concepts_mastered',
];
final List<String> _operators = ['>=', '==', '>'];
final List<String> _icons = [
'star',
'emoji_events',
'school',
'local_fire_department',
'schedule',
'trending_up',
'military_tech',
'workspace_premium',
'psychology',
'lightbulb',
];
final List<String> _rarities = [
'common',
'rare',
'epic',
'legendary',
];
final Map<String, int> _rarityPoints = {
'common': 10,
'rare': 25,
'epic': 50,
'legendary': 100,
};
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_valueController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Dialog(
backgroundColor: cs.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: MediaQuery.of(context).size.width * 0.8,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
minWidth: 280,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.8)],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
const Icon(Icons.emoji_events, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Criar Nova Conquista',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Form
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Info
_buildSectionTitle('Informações Básicas'),
const SizedBox(height: 16),
_buildTextField(
controller: _nameController,
label: 'Nome da Conquista',
hint: 'Ex: Mestre dos Quizzes',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Campo obrigatório';
}
return null;
},
),
const SizedBox(height: 16),
_buildTextField(
controller: _descriptionController,
label: 'Descrição',
hint: 'Ex: Complete 10 quizzes com 100% de acerto',
maxLines: 2,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Campo obrigatório';
}
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDropdownField<String>(
label: 'Categoria',
value: _selectedCategory,
items: _categories,
onChanged: (value) {
setState(() {
_selectedCategory = value!;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDropdownField<String>(
label: 'Ícone',
value: _selectedIcon,
items: _icons,
onChanged: (value) {
setState(() {
_selectedIcon = value!;
});
},
),
),
],
),
const SizedBox(height: 16),
// Requirements
_buildSectionTitle('Requisitos'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDropdownField<String>(
label: 'Tipo de Requisito',
value: _selectedRequirementType,
items: _requirementTypes,
onChanged: (value) {
setState(() {
_selectedRequirementType = value!;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDropdownField<String>(
label: 'Operador',
value: _selectedOperator,
items: _operators,
onChanged: (value) {
setState(() {
_selectedOperator = value!;
});
},
),
),
],
),
const SizedBox(height: 16),
_buildTextField(
controller: _valueController,
label: 'Valor',
hint: 'Ex: 10',
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Campo obrigatório';
}
if (int.tryParse(value) == null) {
return 'Digite um número válido';
}
return null;
},
),
const SizedBox(height: 16),
// Reward
_buildSectionTitle('Recompensa'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDropdownField<String>(
label: 'Raridade',
value: _selectedRarity,
items: _rarities,
onChanged: (value) {
setState(() {
_selectedRarity = value!;
_points = _rarityPoints[value]!;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pontos',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
'$_points',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
// Preview
_buildSectionTitle('Preview'),
const SizedBox(height: 16),
_buildAchievementPreview(),
],
),
),
),
),
// Actions
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: cs.outline),
),
child: Text('Cancelar', style: TextStyle(color: cs.onSurface)),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _createAchievement,
style: ElevatedButton.styleFrom(
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('Criar Conquista'),
),
),
],
),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
final cs = Theme.of(context).colorScheme;
return Text(
title,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
int maxLines = 1,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: cs.onSurfaceVariant),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.all(16),
),
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,
),
],
);
}
Widget _buildDropdownField<T>({
required String label,
required T value,
required List<T> items,
required Function(T?) onChanged,
}) {
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<T>(
isExpanded: true,
value: value,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: items.map((item) {
return DropdownMenuItem(
value: item,
child: Text(
item.toString().split('_').map((word) =>
word[0].toUpperCase() + word.substring(1)
).join(' '),
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: onChanged,
),
],
);
}
Widget _buildAchievementPreview() {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
cs.primary.withValues(alpha: 0.1),
cs.primary.withValues(alpha: 0.05),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.primary.withValues(alpha: 0.3)),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _getRarityColor().withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: _getRarityColor().withValues(alpha: 0.5)),
),
child: Icon(
_getIconData(),
color: _getRarityColor(),
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_nameController.text.isEmpty ? 'Nome da Conquista' : _nameController.text,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_descriptionController.text.isEmpty
? 'Descrição da conquista'
: _descriptionController.text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getRarityColor().withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_points pts',
style: TextStyle(
color: _getRarityColor(),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
IconData _getIconData() {
switch (_selectedIcon) {
case 'emoji_events': return Icons.emoji_events;
case 'school': return Icons.school;
case 'local_fire_department': return Icons.local_fire_department;
case 'schedule': return Icons.schedule;
case 'trending_up': return Icons.trending_up;
case 'military_tech': return Icons.military_tech;
case 'workspace_premium': return Icons.workspace_premium;
case 'psychology': return Icons.psychology;
case 'lightbulb': return Icons.lightbulb;
default: return Icons.star;
}
}
Color _getRarityColor() {
switch (_selectedRarity) {
case 'common': return Colors.grey;
case 'rare': return Colors.blue;
case 'epic': return Colors.purple;
case 'legendary': return Colors.orange;
default: return Colors.grey;
}
}
Future<void> _createAchievement() async {
if (!_formKey.currentState!.validate()) return;
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final achievement = Achievement(
id: '', // Will be generated by Firestore
name: _nameController.text,
description: _descriptionController.text,
icon: _selectedIcon,
category: _selectedCategory,
requirements: AchievementRequirement(
type: _selectedRequirementType,
value: int.parse(_valueController.text),
operator: _selectedOperator,
),
points: _points,
rarity: _selectedRarity,
isActive: true,
createdAt: DateTime.now(),
createdBy: user.uid,
);
final achievementId = await GamificationService.createCustomAchievement(
teacherId: user.uid,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
category: achievement.category,
requirements: achievement.requirements,
points: achievement.points,
rarity: achievement.rarity,
);
final createdAchievement = achievement.copyWith(id: achievementId);
widget.onAchievementCreated(createdAchievement);
Navigator.of(context).pop();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao criar conquista: $e'),
backgroundColor: Colors.red,
),
);
}
}
}

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/session_service.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../shared/presentation/widgets/custom_notification.dart';
class LoginPage extends StatefulWidget {
@@ -70,7 +71,9 @@ class _LoginPageState extends State<LoginPage> {
);
print('DEBUG: Login Firebase bem-sucedido');
print('DEBUG: Role selecionado na tela anterior: ${widget.selectedRole}');
print(
'DEBUG: Role selecionado na tela anterior: ${widget.selectedRole}',
);
// Ler role na Firestore
final uid = result?.user?.uid;
@@ -79,27 +82,59 @@ class _LoginPageState extends State<LoginPage> {
final actualRole = await AuthService.getUserRole(uid);
print('DEBUG: Role real do usuário na Firestore: $actualRole');
// Validar se o role selecionado corresponde ao role real
// Se não há role selecionado, redirecionar para role-selection
final selectedRole = widget.selectedRole;
if (selectedRole != null && actualRole != null && selectedRole != actualRole) {
// Role não corresponde - mostrar erro
if (selectedRole == null) {
await AuthService.signOut();
if (mounted) {
setState(() => _isLoading = false);
context.go('/role-selection');
}
return;
}
// Se role não existe na Firestore (null), permitir login e criar documento
if (actualRole == null) {
print(
'DEBUG: Role não encontrado na Firestore, criando documento com role selecionado: $selectedRole',
);
try {
await AuthService.createUserRole(uid, selectedRole);
print('DEBUG: Role criado com sucesso');
} catch (e) {
print('DEBUG: Erro ao criar role: $e');
// Continuar mesmo se falhar, pois o usuário já está autenticado
}
}
// Validar se o role selecionado corresponde ao role real
if (actualRole != null && selectedRole != actualRole) {
// Fazer logout imediato antes de mostrar erro
await AuthService.signOut();
setState(() {
_isLoading = false;
});
String errorMessage;
if (selectedRole == 'teacher' && actualRole == 'student') {
errorMessage = 'Este email está registado como Aluno. Não pode aceder à área de Professores.';
errorMessage =
'Este email está registado como Aluno. Não pode aceder à área de Professores.';
} else if (selectedRole == 'student' && actualRole == 'teacher') {
errorMessage = 'Este email está registado como Professor. Não pode aceder à área de Alunos.';
errorMessage =
'Este email está registado como Professor. Não pode aceder à área de Alunos.';
} else {
errorMessage = 'O tipo de utilizador selecionado não corresponde ao perfil registado.';
errorMessage =
'O tipo de utilizador selecionado não corresponde ao perfil registado.';
}
_showRoleErrorDialog('Acesso Negado', errorMessage);
return;
}
// Usar selectedRole se actualRole for null (caso acabamos de criar)
final finalRole = actualRole ?? selectedRole;
// Save session based on remember me preference
await SessionService.saveSession(
rememberMe: _rememberMe,
@@ -119,7 +154,7 @@ class _LoginPageState extends State<LoginPage> {
);
// Redirecionar baseado no role real
if (actualRole == 'teacher') {
if (finalRole == 'teacher') {
context.go('/teacher-dashboard');
} else {
context.go('/student-dashboard');
@@ -151,25 +186,24 @@ class _LoginPageState extends State<LoginPage> {
return AlertDialog(
title: Text(
title,
style: const TextStyle(
color: Color(0xFF2D3748),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: Text(
message,
style: const TextStyle(color: Color(0xFF2D3748)),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
// Fazer logout para limpar a sessão
AuthService.signOut();
context.go('/role-selection');
},
child: const Text(
child: Text(
'Voltar',
style: TextStyle(color: Color(0xFF82C9BD)),
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
),
],
@@ -180,304 +214,388 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFF8F9FA),
Color.fromRGBO(130, 201, 189, 0.1),
Color.fromRGBO(246, 141, 45, 0.05),
Color(0xFFF8F9FA),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// Logo/Title
Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/role-selection');
}
},
child: Scaffold(
body: Stack(
children: [
// Main content
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: Theme.of(context).brightness == Brightness.dark
? AppThemeExtras.of(context).authBackgroundGradient
: [
Theme.of(context).colorScheme.background,
Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
Theme.of(
context,
).colorScheme.secondary.withOpacity(0.05),
Theme.of(context).colorScheme.background,
],
),
child: Column(
children: [
Text(
'EPVC',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
foreground: Paint()
..shader = LinearGradient(
colors: [
const Color(0xFF82C9BD),
const Color(0xFFF68D2D),
],
).createShader(Rect.fromLTWH(0, 0, 200, 20)),
),
),
const SizedBox(height: 8),
Text(
'Escola Profissional de Vila do Conde',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF2D3748),
fontWeight: FontWeight.w500,
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
),
),
child: SafeArea(
top: false,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
const SizedBox(height: 40),
// Login form
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Entrar',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF2D3748),
),
),
const SizedBox(height: 24),
const SizedBox(height: 60),
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Color(0xFF2D3748)),
decoration: InputDecoration(
labelText: 'Email',
labelStyle: const TextStyle(
color: Color(0xFF2D3748),
// Logo/Title
Container(
width: double.infinity,
height: 84,
decoration: const BoxDecoration(
color: Color(0xFFF9EEE8),
borderRadius: BorderRadius.all(
Radius.circular(20),
),
hintStyle: const TextStyle(
color: Color(0xFF718096),
),
prefixIcon: const Icon(
Icons.email,
color: Color(0xFF82C9BD),
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFFE2E8F0),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFF82C9BD),
),
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email é obrigatório';
}
if (!value.contains('@')) {
return 'Email inválido';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Color(0xFF2D3748)),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: const TextStyle(
color: Color(0xFF2D3748),
),
hintStyle: const TextStyle(
color: Color(0xFF718096),
),
prefixIcon: const Icon(
Icons.lock,
color: Color(0xFF82C9BD),
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: const Color(0xFF82C9BD),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFFE2E8F0),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFF82C9BD),
),
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Palavra-passe é obrigatória';
}
if (value.length < 6) {
return 'Palavra-passe muito curta';
}
return null;
},
),
const SizedBox(height: 12),
// Remember me checkbox
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (bool? value) {
setState(() {
_rememberMe = value ?? false;
});
},
activeColor: const Color(0xFF82C9BD),
checkColor: Colors.white,
),
GestureDetector(
onTap: () {
setState(() {
_rememberMe = !_rememberMe;
});
},
child: Text(
'Manter sessão iniciada',
style: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 14,
fontWeight: _rememberMe
? FontWeight.w500
: FontWeight.normal,
child: Center(
child: SizedBox(
width: 140,
height: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.cover,
),
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
),
const SizedBox(height: 12),
// Login button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF82C9BD),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
const SizedBox(height: 40),
// Login form
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Entrar',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Entrar',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.onSurface,
),
),
const SizedBox(height: 24),
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Email',
labelStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.email,
color: Theme.of(
context,
).colorScheme.primary,
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.5),
),
),
),
),
const SizedBox(height: 16),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary,
),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email é obrigatório';
}
if (!value.contains('@')) {
return 'Email inválido';
}
return null;
},
),
const SizedBox(height: 16),
// Signup link
GestureDetector(
onTap: () {
context.go('/signup');
},
child: Text(
'Não tem conta? Criar aqui',
style: const TextStyle(
color: Color(0xFF82C9BD),
fontWeight: FontWeight.w500,
),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.lock,
color: Theme.of(
context,
).colorScheme.primary,
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Theme.of(
context,
).colorScheme.primary,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary,
),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Palavra-passe é obrigatória';
}
if (value.length < 6) {
return 'Palavra-passe muito curta';
}
return null;
},
),
const SizedBox(height: 12),
// Remember me checkbox
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (bool? value) {
setState(() {
_rememberMe = value ?? false;
});
},
activeColor: Theme.of(
context,
).colorScheme.primary,
checkColor: Colors.white,
),
GestureDetector(
onTap: () {
setState(() {
_rememberMe = !_rememberMe;
});
},
child: Text(
'Manter sessão iniciada',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
fontSize: 14,
fontWeight: _rememberMe
? FontWeight.w500
: FontWeight.normal,
),
),
),
],
),
const SizedBox(height: 12),
// Login button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
8.0,
),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Entrar',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Signup link
GestureDetector(
onTap: () {
context.go(
'/signup?role=${widget.selectedRole}',
);
},
child: Text(
'Não tem conta? Criar aqui',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 1000),
),
const SizedBox(height: 40),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 1000),
),
const SizedBox(height: 40),
],
),
),
),
),
),
// Custom back button
Positioned(
top: 50,
left: 16,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.shadow.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: IconButton(
icon: Icon(
Icons.arrow_back,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => context.go('/role-selection'),
),
),
),
],
),
),
);

View File

@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../core/theme/app_theme_extension.dart';
class RoleSelectionPage extends StatefulWidget {
const RoleSelectionPage({super.key});
@@ -22,11 +21,14 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.background,
AppColors.primaryBlue.withOpacity(0.05),
AppColors.gradientStart.withOpacity(0.1),
],
colors: Theme.of(context).brightness == Brightness.dark
? AppThemeExtras.of(context).authBackgroundGradient
: [
Theme.of(context).colorScheme.background,
Theme.of(context).colorScheme.primary.withOpacity(0.1),
Theme.of(context).colorScheme.secondary.withOpacity(0.05),
Theme.of(context).colorScheme.background,
],
),
),
child: Stack(
@@ -36,120 +38,76 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
// Main content
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
children: [
const SizedBox(height: 60),
const Spacer(flex: 1),
// Logo and title
Center(
child: Column(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
AppColors.gradientStart,
AppColors.gradientEnd,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: AppColors.primaryBlue.withOpacity(
0.3,
),
blurRadius: 25,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.school,
size: 50,
color: Colors.white,
),
)
.animate()
.scale(
duration: const Duration(milliseconds: 800),
curve: Curves.elasticOut,
)
.then()
.shimmer(
duration: const Duration(milliseconds: 2000),
color: Colors.white.withOpacity(0.4),
),
const SizedBox(height: 32),
Text(
AppLocalizations.of(context)!.appTitle,
style: Theme.of(context).textTheme.headlineLarge
?.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 800),
delay: const Duration(milliseconds: 200),
)
.slideY(
duration: const Duration(milliseconds: 600),
delay: const Duration(milliseconds: 200),
begin: -0.3,
),
const SizedBox(height: 12),
ShaderMask(
shaderCallback: (bounds) =>
const LinearGradient(
colors: [
AppColors.primaryTeal,
AppColors.primaryOrange,
],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
).createShader(bounds),
child: Text(
AppLocalizations.of(context)!.schoolName,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 800),
delay: const Duration(milliseconds: 400),
)
.slideY(
duration: const Duration(milliseconds: 600),
delay: const Duration(milliseconds: 400),
begin: -0.2,
),
],
// Wide rectangle with image at top
Container(
width: double.infinity,
height: 84,
decoration: const BoxDecoration(
color: Color(0xFFF9EEE8),
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: SizedBox(
width: 140,
height: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.cover,
),
),
),
),
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
delay: const Duration(milliseconds: 200),
),
const Spacer(),
const SizedBox(height: 24),
// Role selection title
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
).createShader(bounds),
child: Text(
'Assistente de estudo IA',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
delay: const Duration(milliseconds: 300),
),
const SizedBox(height: 32),
// Title
Text(
'Quem é você?',
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
color: AppColors.textPrimary,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
)
@@ -169,7 +127,9 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
Text(
'Selecione o seu papel para continuar',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppColors.primaryOrange),
?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
)
.animate()
.fadeIn(
@@ -193,7 +153,7 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
'Aluno',
Icons.school_outlined,
'student',
AppColors.gradientStart,
Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 16),
@@ -203,7 +163,7 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
'Professor',
Icons.person_outline,
'teacher',
AppColors.gradientEnd,
Theme.of(context).colorScheme.secondary,
),
),
],
@@ -230,12 +190,16 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
? _handleContinue
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
elevation: 4,
shadowColor: AppColors.primaryBlue.withOpacity(
0.3,
),
shadowColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
@@ -278,6 +242,8 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
),
const SizedBox(height: 32),
const Spacer(flex: 1),
],
),
),
@@ -319,12 +285,16 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
end: Alignment.bottomRight,
)
: null,
color: isSelected ? null : Colors.white,
color: isSelected
? null
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? gradientColor
: AppColors.primaryBlue.withOpacity(0.2),
: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
width: isSelected ? 2 : 1,
),
boxShadow: [
@@ -343,7 +313,9 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
Icon(
icon,
size: 48,
color: isSelected ? Colors.white : AppColors.primaryBlue,
color: isSelected
? Colors.white
: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
@@ -351,7 +323,7 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: isSelected
? Colors.white
: AppColors.textPrimary,
: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
@@ -383,7 +355,7 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
width: 4,
height: 4,
decoration: BoxDecoration(
color: AppColors.gradientStart.withOpacity(0.3),
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
shape: BoxShape.circle,
),
)
@@ -410,4 +382,4 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
context.go('/signup?role=$_selectedRole');
}
}
}
}

View File

@@ -1,8 +1,10 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../shared/presentation/widgets/custom_notification.dart';
class SignupPage extends StatefulWidget {
@@ -22,12 +24,42 @@ class _SignupPageState extends State<SignupPage> {
bool _isLoading = false;
bool _obscurePassword = true;
late String _selectedRole;
String? _selectedSchoolClassId;
List<Map<String, String>> _availableClasses = [];
bool _isLoadingClasses = false;
@override
void initState() {
super.initState();
// Usar role passado da tela anterior ou default 'student'
_selectedRole = widget.selectedRole ?? 'student';
if (_selectedRole == 'student') {
_loadAvailableClasses();
}
}
Future<void> _loadAvailableClasses() async {
setState(() => _isLoadingClasses = true);
try {
print('DEBUG: Loading school_classes from Firestore');
final snapshot = await FirebaseFirestore.instance
.collection('school_classes')
.where('active', isEqualTo: true)
.orderBy('year')
.orderBy('section')
.get();
print('DEBUG: Loaded ${snapshot.docs.length} school classes');
setState(() {
_availableClasses = snapshot.docs.map((doc) {
final data = doc.data();
return {'id': doc.id, 'name': (data['name'] as String? ?? doc.id)};
}).toList();
_isLoadingClasses = false;
});
} catch (e) {
print('DEBUG: Error loading school_classes: $e');
setState(() => _isLoadingClasses = false);
}
}
@override
@@ -38,6 +70,99 @@ class _SignupPageState extends State<SignupPage> {
super.dispose();
}
Widget _buildClassSelector(BuildContext context) {
if (_isLoadingClasses) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Text(
'A carregar anos letivos...',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
);
}
if (_availableClasses.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Nenhum ano letivo disponível. Contacta o teu professor.',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
);
}
return DropdownButtonFormField<String>(
value: _selectedSchoolClassId,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Ano letivo',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).colorScheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).colorScheme.error),
),
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface,
),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
dropdownColor: Theme.of(context).colorScheme.surface,
hint: Text(
'Escolha o seu ano letivo',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
items: _availableClasses
.map(
(c) => DropdownMenuItem<String>(
value: c['id'],
child: Text(c['name']!),
),
)
.toList(),
onChanged: (value) => setState(() => _selectedSchoolClassId = value),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Seleciona o teu ano letivo';
}
return null;
},
);
}
Future<void> _handleSignup() async {
if (!_formKey.currentState!.validate()) return;
@@ -60,6 +185,9 @@ class _SignupPageState extends State<SignupPage> {
password: password,
displayName: name,
role: _selectedRole,
schoolClassId: _selectedRole == 'student'
? _selectedSchoolClassId
: null,
);
print('DEBUG: Signup Firebase bem-sucedido, navegando para dashboard');
@@ -101,315 +229,431 @@ class _SignupPageState extends State<SignupPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFF8F9FA),
Color.fromRGBO(130, 201, 189, 0.1),
Color.fromRGBO(246, 141, 45, 0.05),
Color(0xFFF8F9FA),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// Logo/Title
Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/role-selection');
}
},
child: Scaffold(
body: Stack(
children: [
// Main content
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: Theme.of(context).brightness == Brightness.dark
? AppThemeExtras.of(context).authBackgroundGradient
: [
Theme.of(context).colorScheme.background,
Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
Theme.of(
context,
).colorScheme.secondary.withOpacity(0.05),
Theme.of(context).colorScheme.background,
],
),
child: Column(
children: [
Text(
'EPVC',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
foreground: Paint()
..shader = LinearGradient(
colors: [
const Color(0xFF82C9BD),
const Color(0xFFF68D2D),
],
).createShader(Rect.fromLTWH(0, 0, 200, 20)),
),
),
const SizedBox(height: 8),
Text(
'Escola Profissional de Vila do Conde',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF2D3748),
fontWeight: FontWeight.w500,
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
),
),
child: SafeArea(
top: false,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
const SizedBox(height: 40),
// Signup form
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Criar Conta',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF2D3748),
),
),
const SizedBox(height: 24),
const SizedBox(height: 60),
// Name field
TextFormField(
controller: _nameController,
keyboardType: TextInputType.name,
style: const TextStyle(color: Color(0xFF2D3748)),
decoration: InputDecoration(
labelText: 'Nome Completo',
labelStyle: const TextStyle(
color: Color(0xFF2D3748),
// Logo/Title
Container(
width: double.infinity,
height: 84,
decoration: const BoxDecoration(
color: Color(0xFFF9EEE8),
borderRadius: BorderRadius.all(
Radius.circular(20),
),
hintStyle: const TextStyle(
color: Color(0xFF718096),
),
prefixIcon: const Icon(
Icons.person,
color: Color(0xFF82C9BD),
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFFE2E8F0),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFF82C9BD),
),
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nome é obrigatório';
}
if (value.length < 2) {
return 'Nome muito curto';
}
return null;
},
),
const SizedBox(height: 16),
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Color(0xFF2D3748)),
decoration: InputDecoration(
labelText: 'Email',
labelStyle: const TextStyle(
color: Color(0xFF2D3748),
),
hintStyle: const TextStyle(
color: Color(0xFF718096),
),
prefixIcon: const Icon(
Icons.email,
color: Color(0xFF82C9BD),
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFFE2E8F0),
child: Center(
child: SizedBox(
width: 140,
height: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.cover,
),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFF82C9BD),
),
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email é obrigatório';
}
if (!value.contains('@')) {
return 'Email inválido';
}
return null;
},
).animate().fadeIn(
duration: const Duration(milliseconds: 800),
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Color(0xFF2D3748)),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: const TextStyle(
color: Color(0xFF2D3748),
),
hintStyle: const TextStyle(
color: Color(0xFF718096),
),
prefixIcon: const Icon(
Icons.lock,
color: Color(0xFF82C9BD),
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: const Color(0xFF82C9BD),
const SizedBox(height: 40),
// Signup form
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.shadow.withOpacity(0.1),
blurRadius: 10.0,
offset: const Offset(0, 4),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFFE2E8F0),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
color: Color(0xFF82C9BD),
),
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
],
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Palavra-passe é obrigatória';
}
if (value.length < 6) {
return 'Palavra-passe muito curta';
}
return null;
},
),
const SizedBox(height: 24),
// Signup button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleSignup,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF82C9BD),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Criar Conta',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.onSurface,
),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Criar Conta',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
const SizedBox(height: 24),
// Name field
TextFormField(
controller: _nameController,
keyboardType: TextInputType.name,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Primeiro Nome',
labelStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.person,
color: Theme.of(
context,
).colorScheme.primary,
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
),
),
),
),
const SizedBox(height: 16),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary,
),
),
filled: true,
fillColor:
Theme.of(context).brightness ==
Brightness.dark
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nome é obrigatório';
}
if (value.length < 2) {
return 'Nome muito curto';
}
return null;
},
),
const SizedBox(height: 16),
// Login link
GestureDetector(
onTap: () {
context.go('/login?role=$_selectedRole');
},
child: Text(
'Já tem conta? Entrar aqui',
style: const TextStyle(
color: Color(0xFF82C9BD),
fontWeight: FontWeight.w500,
),
// Seletor de ano letivo (apenas para alunos)
if (_selectedRole == 'student') ...[
_buildClassSelector(context),
const SizedBox(height: 16),
],
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Email',
labelStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.email,
color: Theme.of(
context,
).colorScheme.primary,
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary,
),
),
filled: true,
fillColor:
Theme.of(context).brightness ==
Brightness.dark
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email é obrigatório';
}
if (!value.contains('@')) {
return 'Email inválido';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Palavra-passe',
labelStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface,
),
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.lock,
color: Theme.of(
context,
).colorScheme.primary,
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Theme.of(
context,
).colorScheme.primary,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: InputBorder.none,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary,
),
),
filled: true,
fillColor:
Theme.of(context).brightness ==
Brightness.dark
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Palavra-passe é obrigatória';
}
if (value.length < 6) {
return 'Palavra-passe muito curta';
}
return null;
},
),
const SizedBox(height: 24),
// Signup button
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: _isLoading
? null
: _handleSignup,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
8.0,
),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Criar Conta',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Login link
GestureDetector(
onTap: () {
context.go('/login?role=$_selectedRole');
},
child: Text(
'Já tem conta? Entrar aqui',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 1000),
),
const SizedBox(height: 40),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 1000),
),
const SizedBox(height: 40),
],
),
),
),
),
),
// Custom back button
Positioned(
top: 50,
left: 16,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.shadow.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: IconButton(
icon: Icon(
Icons.arrow_back,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => context.go('/role-selection'),
),
),
),
],
),
),
);

View File

@@ -0,0 +1,986 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// Página para visualizar os alunos de uma turma específica
class ClassStudentsPage extends StatefulWidget {
final String classId;
final String className;
const ClassStudentsPage({
super.key,
required this.classId,
required this.className,
});
@override
State<ClassStudentsPage> createState() => _ClassStudentsPageState();
}
class _ClassStudentsPageState extends State<ClassStudentsPage> {
bool _isCheckingAccess = true;
bool _accessGranted = false;
String _currentClassName = '';
String? _classCode;
@override
void initState() {
super.initState();
_currentClassName = widget.className;
_verifyOwnership();
}
Future<void> _verifyOwnership() async {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
setState(() {
_isCheckingAccess = false;
_accessGranted = false;
});
return;
}
final role = await AuthService.getUserRole(currentUser.uid);
if (role != 'teacher') {
setState(() {
_isCheckingAccess = false;
_accessGranted = false;
});
return;
}
final classDoc = await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.get();
final teacherId = classDoc.data()?['teacherId'] as String?;
final code = classDoc.data()?['code'] as String?;
setState(() {
_isCheckingAccess = false;
_accessGranted = classDoc.exists && teacherId == currentUser.uid;
_classCode = code ?? '----';
});
}
Future<void> _updateClassName(String newName) async {
try {
await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.update({'name': newName});
setState(() {
_currentClassName = newName;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Nome da turma atualizado com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao atualizar nome: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
Future<void> _deleteClass() async {
try {
// Delete all enrollments first
final enrollmentsSnapshot = await FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.get();
final batch = FirebaseFirestore.instance.batch();
for (final doc in enrollmentsSnapshot.docs) {
batch.delete(doc.reference);
}
await batch.commit();
// Delete the class
await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.delete();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Turma eliminada com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
context.pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao eliminar turma: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
void _showEditClassNameDialog() {
final textController = TextEditingController(text: _currentClassName);
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.edit, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text('Editar Nome'),
],
),
content: TextField(
controller: textController,
decoration: InputDecoration(
labelText: 'Nome da Turma',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: const Icon(Icons.school),
),
autofocus: true,
maxLength: 50,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
final newName = textController.text.trim();
if (newName.isNotEmpty && newName != _currentClassName) {
Navigator.of(context).pop();
_updateClassName(newName);
}
},
child: const Text('Guardar'),
),
],
),
);
}
void _showDeleteClassDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
const Text('Eliminar Turma'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tens a certeza que desejas eliminar a turma "$_currentClassName"?',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.warning_amber,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Esta ação não pode ser desfeita. Todos os alunos serão removidos.',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
_deleteClass();
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
),
child: const Text('Eliminar'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final themeExtras = AppThemeExtras.of(context);
if (_isCheckingAccess) {
return Scaffold(
backgroundColor: cs.surface,
body: Center(child: CircularProgressIndicator(color: cs.primary)),
);
}
if (!_accessGranted) {
return Scaffold(
backgroundColor: cs.surface,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lock_outline, size: 64, color: cs.primary),
const SizedBox(height: 24),
Text(
'Sem permissão',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Só podes ver os alunos das tuas próprias turmas.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: themeExtras.dashboardBackgroundGradient,
stops: themeExtras.dashboardGradientStops,
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
_buildAppBar(cs),
// Main Content
Expanded(
child: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.orderBy('joinedAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: cs.primary),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: cs.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Erro ao carregar alunos',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
],
),
);
}
final enrollments = snapshot.data?.docs ?? [];
if (enrollments.isEmpty) {
return _buildEmptyState(cs);
}
return _buildStudentsList(cs, enrollments);
},
),
),
],
),
),
),
);
}
Widget _buildAppBar(ColorScheme cs) {
return Container(
padding: const EdgeInsets.only(left: 16, right: 16, top: 52, bottom: 16),
child: Column(
children: [
// Top Row with Back and Actions
Row(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => context.pop(),
tooltip: 'Voltar',
),
),
const Spacer(),
// Edit Button
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.edit, color: Colors.white),
onPressed: _showEditClassNameDialog,
tooltip: 'Editar nome',
),
),
const SizedBox(width: 8),
// Delete Button
Container(
decoration: BoxDecoration(
color:
(Theme.of(context).brightness == Brightness.dark
? DarkBrandColors.primaryOrange
: AppColors.primaryOrange)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(
Icons.delete_outline,
color: Theme.of(context).brightness == Brightness.dark
? DarkBrandColors.primaryOrange
: AppColors.primaryOrange,
),
onPressed: _showDeleteClassDialog,
tooltip: 'Eliminar turma',
),
),
],
),
const SizedBox(height: 20),
// Class Info Card
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.school,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentClassName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Código: $_classCode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Stats Row
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.snapshots(),
builder: (context, snapshot) {
final count = snapshot.data?.docs.length ?? 0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
'$count ${count == 1 ? 'aluno matriculado' : 'alunos matriculados'}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
),
],
),
);
}
Widget _buildEmptyState(ColorScheme cs) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 40),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Icon(
Icons.people_outline,
size: 80,
color: cs.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
Text(
'Nenhum aluno entrou nesta turma ainda.',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Partilha o código da turma para os alunos se juntarem.',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
GestureDetector(
onTap: _copyCodeToClipboard,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: cs.primary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.copy, color: cs.primary, size: 18),
const SizedBox(width: 8),
Text(
'Código: $_classCode',
style: TextStyle(
color: cs.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 100)),
],
),
);
}
Widget _buildStudentsList(
ColorScheme cs,
List<QueryDocumentSnapshot> enrollments,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title
Container(
margin: const EdgeInsets.only(bottom: 16, left: 8),
child: Row(
children: [
Icon(Icons.people, color: cs.onSurface, size: 20),
const SizedBox(width: 8),
Text(
'Alunos Matriculados',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${enrollments.length}',
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// Students List
...enrollments.asMap().entries.map((entry) {
final index = entry.key;
final enrollment = entry.value.data() as Map<String, dynamic>;
final studentName =
enrollment['studentName'] as String? ?? 'Aluno sem nome';
final joinedAt = enrollment['joinedAt'] as Timestamp?;
final enrollmentId = entry.value.id;
return _buildStudentCard(
cs,
studentName,
joinedAt,
enrollmentId,
index,
);
}),
const SizedBox(height: 24),
],
),
);
}
Widget _buildStudentCard(
ColorScheme cs,
String studentName,
Timestamp? joinedAt,
String enrollmentId,
int index,
) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cs.primary.withValues(alpha: 0.8),
cs.primary.withValues(alpha: 0.4),
],
),
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Text(
studentName.isNotEmpty
? studentName[0].toUpperCase()
: '?',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 16),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
studentName,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
joinedAt != null
? 'Entrou em ${_formatDate(joinedAt.toDate())}'
: 'Data desconhecida',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
// Delete Button
Container(
decoration: BoxDecoration(
color: cs.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: Icon(
Icons.delete_outline,
color: cs.error,
size: 20,
),
onPressed: () => _showRemoveStudentDialog(
context,
enrollmentId,
studentName,
),
tooltip: 'Remover aluno',
padding: const EdgeInsets.all(10),
constraints: const BoxConstraints(),
),
),
],
),
),
),
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: Duration(milliseconds: index * 50));
}
String _formatDate(DateTime date) {
return DateFormat('dd/MM/yyyy').format(date);
}
void _copyCodeToClipboard() {
Clipboard.setData(ClipboardData(text: _classCode ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Código copiado!'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _showRemoveStudentDialog(
BuildContext context,
String enrollmentId,
String studentName,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(
Icons.person_remove,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
const Text('Remover Aluno'),
],
),
content: Text(
'Tens a certeza que desejas remover $studentName desta turma?',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
),
child: const Text('Remover'),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentId)
.delete();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Aluno removido com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao remover aluno: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
}
}

View File

@@ -0,0 +1,631 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/services/auth_service.dart';
/// Página para o aluno entrar numa disciplina usando o código
class JoinClassPage extends ConsumerStatefulWidget {
const JoinClassPage({super.key});
@override
ConsumerState<JoinClassPage> createState() => _JoinClassPageState();
}
class _JoinClassPageState extends ConsumerState<JoinClassPage> {
final _codeController = TextEditingController();
final _nameController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_codeController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _joinClass() async {
final code = _codeController.text.trim().toUpperCase();
final customName = _nameController.text.trim();
if (code.isEmpty) {
_showError('Insere o código da disciplina');
return;
}
if (customName.isEmpty) {
_showError('Insere o nome da disciplina');
return;
}
setState(() => _isLoading = true);
try {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
setState(() => _isLoading = false);
_showError('Erro: Utilizador não autenticado');
return;
}
// Verificar role — apenas alunos podem entrar por código
final userRole = await AuthService.getUserRole(currentUser.uid);
if (userRole != 'student') {
setState(() => _isLoading = false);
_showError('Apenas alunos podem entrar em disciplinas por código.');
return;
}
// Procurar disciplina pelo código
final classQuery = await FirebaseFirestore.instance
.collection('classes')
.where('code', isEqualTo: code)
.limit(1)
.get();
if (classQuery.docs.isEmpty) {
setState(() => _isLoading = false);
_showError('Código de disciplina inválido');
return;
}
final classDoc = classQuery.docs.first;
final classId = classDoc.id;
// Verificar se já está inscrito nesta disciplina
final existingEnrollment = await FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: classId)
.where('studentId', isEqualTo: currentUser.uid)
.limit(1)
.get();
if (existingEnrollment.docs.isNotEmpty) {
setState(() => _isLoading = false);
_showError('Já estás inscrito nesta disciplina');
return;
}
// Criar documento de inscrição
await FirebaseFirestore.instance.collection('enrollments').add({
'classId': classId,
'studentId': currentUser.uid,
'studentName':
currentUser.displayName ??
currentUser.email?.split('@')[0] ??
'Aluno',
'customClassName': customName,
'joinedAt': FieldValue.serverTimestamp(),
});
setState(() => _isLoading = false);
// Mostrar sucesso
if (mounted) {
final colorScheme = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 8),
const Text('Entraste na disciplina com sucesso!'),
],
),
backgroundColor: colorScheme.primary,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
// Voltar para a home
Navigator.of(context).pop();
}
} catch (e) {
setState(() => _isLoading = false);
_showError('Erro ao entrar na disciplina: $e');
}
}
void _showError(String message) {
final colorScheme = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: colorScheme.error,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
colorScheme.surfaceContainerHighest,
colorScheme.surface,
colorScheme.surfaceContainerLow,
]
: const [
Color(0xFFD4E8E8),
Color(0xFFE8D4C0),
Color(0xFFD8E0E8),
],
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
Container(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 20.0,
top: 52.0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(
Icons.arrow_back,
color: colorScheme.onSurface,
),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
'Adicionar uma Disciplina',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// Body content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
// Header illustration card
Center(
child: Card(
elevation: isDark ? 4 : 8,
shadowColor: isDark
? Colors.black.withOpacity(0.4)
: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
colorScheme.primary.withOpacity(0.1),
colorScheme.secondary.withOpacity(0.05),
]
: [
colorScheme.primary.withOpacity(0.05),
colorScheme.secondary.withOpacity(0.02),
],
),
),
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.primary.withOpacity(
0.2,
),
width: 2,
),
),
child: Icon(
Icons.group_add,
color: colorScheme.primary,
size: 40,
),
),
const SizedBox(height: 24),
Text(
'Insere o código da disciplina',
style: theme.textTheme.headlineSmall
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'O professor partilhou contigo um código de 6 caracteres.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
const SizedBox(height: 32),
// Instructions card
Card(
elevation: isDark ? 2 : 4,
shadowColor: isDark
? Colors.black.withOpacity(0.3)
: Colors.black.withOpacity(0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Como funciona',
style: theme.textTheme.titleMedium
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
_buildInstructionItem(
context,
'1.',
'Pedir ao professor o código da disciplina',
colorScheme,
),
const SizedBox(height: 8),
_buildInstructionItem(
context,
'2.',
'Inserir o código no campo abaixo',
colorScheme,
),
const SizedBox(height: 8),
_buildInstructionItem(
context,
'3.',
'Escrever o nome da disciplina',
colorScheme,
),
const SizedBox(height: 8),
_buildInstructionItem(
context,
'4.',
'Clicar em "Adicionar uma Disciplina" para confirmar',
colorScheme,
),
],
),
),
),
const SizedBox(height: 32),
// Campo de código
Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _codeController,
textCapitalization: TextCapitalization.characters,
maxLength: 6,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 8,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: 'XXXXXX',
hintStyle: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 8,
color: colorScheme.onSurfaceVariant.withOpacity(
0.5,
),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(24),
counterText: '',
prefixIcon: Icon(
Icons.vpn_key,
color: colorScheme.primary,
size: 24,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
),
),
),
const SizedBox(height: 24),
// Campo de nome da disciplina
Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _nameController,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: 'Nome da disciplina',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant.withOpacity(
0.5,
),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(20),
prefixIcon: Icon(
Icons.edit,
color: colorScheme.primary,
size: 24,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
),
),
),
const SizedBox(height: 32),
// Botão de entrar
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _joinClass,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 2,
shadowColor: colorScheme.primary.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
disabledBackgroundColor: colorScheme.primary
.withOpacity(0.5),
),
child: _isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: colorScheme.onPrimary,
strokeWidth: 2,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.login,
size: 20,
color: colorScheme.onPrimary,
),
const SizedBox(width: 8),
Text(
'Adicionar uma Disciplina',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimary,
),
),
],
),
),
),
const SizedBox(height: 16),
// Help text
Center(
child: TextButton.icon(
onPressed: () {
_showHelpDialog(context, colorScheme);
},
icon: Icon(
Icons.help_outline,
size: 16,
color: colorScheme.primary,
),
label: Text(
'Precisas de ajuda?',
style: TextStyle(
color: colorScheme.primary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
),
],
),
),
),
);
}
Widget _buildInstructionItem(
BuildContext context,
String number,
String text,
ColorScheme colorScheme,
) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
number,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
);
}
void _showHelpDialog(BuildContext context, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Theme.of(context).cardColor,
title: Row(
children: [
Icon(Icons.help_outline, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Ajuda - Código da Disciplina',
style: TextStyle(color: colorScheme.onSurface),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'O código da disciplina é um código único de 6 caracteres que o teu professor cria para cada disciplina.',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Text(
'Se não tens o código, contacta o teu professor.',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Entendido',
style: TextStyle(color: colorScheme.primary),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../../../dashboard/presentation/widgets/teacher_classes_list_widget.dart';
/// Página dedicada para o professor ver todas as suas turmas
/// Reutiliza o TeacherClassesListWidget existente
class TeacherAllClassesPage extends StatelessWidget {
const TeacherAllClassesPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: AppBar(
backgroundColor: const Color(0xFF82C9BD),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'As Minhas Turmas',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
body: const SingleChildScrollView(
padding: EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [TeacherClassesListWidget(), SizedBox(height: 40)],
),
),
);
}
}

View File

@@ -1,13 +1,24 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../core/routing/app_router.dart';
import '../widgets/progress_hero_widget.dart';
import '../widgets/quick_access_widget.dart';
import '../widgets/student_classes_list_widget.dart';
import '../widgets/profile_section_widget.dart';
class StudentDashboardPage extends StatefulWidget {
const StudentDashboardPage({super.key});
/// Clear the cached user name (call when name is updated in settings)
static void clearCachedUserName() {
_cachedUserName = null;
}
/// Cached user name to prevent flickering
static String? _cachedUserName;
@override
State<StudentDashboardPage> createState() => _StudentDashboardPageState();
}
@@ -18,14 +29,28 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
@override
void initState() {
super.initState();
_loadUserData();
// Use cached name if available, otherwise load data
if (StudentDashboardPage._cachedUserName != null) {
_userName = StudentDashboardPage._cachedUserName!;
} else {
_checkRoleAndLoadData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Reload user data when dependencies change (e.g., after navigation)
_loadUserData();
Future<void> _checkRoleAndLoadData() async {
final user = AuthService.currentUser;
if (user == null) {
if (mounted) context.go('/role-selection');
return;
}
final role = await AuthService.getUserRole(user.uid);
if (role != 'student') {
if (mounted) {
context.go('/role-selection');
}
return;
}
await _loadUserData();
}
Future<void> _loadUserData() async {
@@ -63,6 +88,7 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
print('DEBUG: Final displayName to use: "$displayName"');
setState(() {
_userName = displayName;
StudentDashboardPage._cachedUserName = displayName;
});
}
}
@@ -73,30 +99,35 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
@override
Widget build(BuildContext context) {
final themeExtras = AppThemeExtras.of(context);
final headerColor = themeExtras.dashboardHeaderTextColor;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF82C9BD),
Color(0xFF7BA89C),
Color(0xFFF68D2D),
Color(0xFFF8F9FA),
],
stops: [0.0, 0.2, 0.6, 1.0],
colors: themeExtras.dashboardBackgroundGradient,
stops: themeExtras.dashboardGradientStops,
),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with logout
// Header with logout and settings
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
@@ -104,17 +135,17 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
children: [
Text(
'Bem-vindo, $_userName!',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: headerColor,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const Text(
Text(
'Seu progresso de estudos',
style: TextStyle(
color: Colors.white,
color: headerColor,
fontSize: 16,
fontWeight: FontWeight.w300,
),
@@ -123,7 +154,14 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
),
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
icon: Icon(Icons.settings, color: headerColor),
onPressed: () {
AppRouter.goToSettings(context);
},
tooltip: 'Configurações',
),
IconButton(
icon: Icon(Icons.logout, color: headerColor),
onPressed: () async {
await AuthService.signOut();
if (mounted) {
@@ -146,7 +184,12 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
const SizedBox(height: 24),
// Profile Section (Priority 3)
// Classes List Section (Priority 3)
const StudentClassesListWidget(),
const SizedBox(height: 24),
// Profile Section (Priority 4)
const ProfileSectionWidget(),
const SizedBox(height: 40),

View File

@@ -1,30 +1,65 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../core/routing/app_router.dart';
import '../widgets/teacher_hero_widget.dart';
import '../widgets/teacher_quick_actions_widget.dart';
import '../widgets/teacher_analytics_preview_widget.dart';
import '../widgets/teacher_classes_list_widget.dart';
class TeacherDashboardPage extends StatefulWidget {
const TeacherDashboardPage({super.key});
/// Clear the cached user name (call when name is updated in settings)
static void clearCachedUserName() {
_cachedUserName = null;
}
/// Cached user name to prevent flickering
static String? _cachedUserName;
@override
State<TeacherDashboardPage> createState() => _TeacherDashboardPageState();
}
class _TeacherDashboardPageState extends State<TeacherDashboardPage> {
class _TeacherDashboardPageState extends State<TeacherDashboardPage>
with AutomaticKeepAliveClientMixin {
String _userName = 'Professor';
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadUserData();
// Use cached name if available, otherwise load data
if (TeacherDashboardPage._cachedUserName != null) {
_userName = TeacherDashboardPage._cachedUserName!;
} else {
_checkRoleAndLoadData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadUserData();
Future<void> _refreshDashboard() async {
// Clear cached data to force refresh
TeacherDashboardPage.clearCachedUserName();
await _loadUserData();
}
Future<void> _checkRoleAndLoadData() async {
final user = AuthService.currentUser;
if (user == null) {
if (mounted) context.go('/role-selection');
return;
}
final role = await AuthService.getUserRole(user.uid);
if (role != 'teacher') {
if (mounted) {
context.go('/role-selection');
}
return;
}
await _loadUserData();
}
Future<void> _loadUserData() async {
@@ -58,6 +93,7 @@ class _TeacherDashboardPageState extends State<TeacherDashboardPage> {
print('DEBUG: Final displayName to use: "$displayName"');
setState(() {
_userName = displayName;
TeacherDashboardPage._cachedUserName = displayName;
});
}
}
@@ -68,84 +104,100 @@ class _TeacherDashboardPageState extends State<TeacherDashboardPage> {
@override
Widget build(BuildContext context) {
super.build(context);
final themeExtras = AppThemeExtras.of(context);
final headerColor = themeExtras.dashboardHeaderTextColor;
return Scaffold(
body: Container(
decoration: const BoxDecoration(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF82C9BD),
Color(0xFF7BA89C),
Color(0xFFF68D2D),
Color(0xFFF8F9FA),
],
stops: [0.0, 0.2, 0.6, 1.0],
colors: themeExtras.dashboardBackgroundGradient,
stops: themeExtras.dashboardGradientStops,
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with logout
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bem-vindo, $_userName!',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
child: RefreshIndicator(
onRefresh: _refreshDashboard,
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with logout and settings
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bem-vindo, $_userName!',
style: TextStyle(
color: headerColor,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
const Text(
'Painel de Gestão de Conteúdo',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w300,
const SizedBox(height: 4),
Text(
'Painel de Gestão de Conteúdo',
style: TextStyle(
color: headerColor,
fontSize: 16,
fontWeight: FontWeight.w300,
),
),
),
],
],
),
),
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
await AuthService.signOut();
if (mounted) {
context.go('/login');
}
},
tooltip: 'Sair',
),
],
),
const SizedBox(height: 32),
IconButton(
icon: Icon(Icons.settings, color: headerColor),
onPressed: () {
AppRouter.goToSettings(context);
},
tooltip: 'Configurações',
),
IconButton(
icon: Icon(Icons.logout, color: headerColor),
onPressed: () async {
await AuthService.signOut();
if (mounted) {
context.go('/login');
}
},
tooltip: 'Sair',
),
],
),
const SizedBox(height: 32),
// Hero Section - Class Overview
TeacherHeroWidget(userName: _userName),
// Hero Section - Class Overview
TeacherHeroWidget(userName: _userName),
const SizedBox(height: 24),
const SizedBox(height: 24),
// Quick Actions Section
const TeacherQuickActionsWidget(),
// Quick Actions Section
const TeacherQuickActionsWidget(),
const SizedBox(height: 24),
const SizedBox(height: 24),
// Analytics Preview Section
const TeacherAnalyticsPreviewWidget(),
// Classes List Section
const TeacherClassesListWidget(),
const SizedBox(height: 40),
],
const SizedBox(height: 40),
],
),
),
),
),

View File

@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// Layout variant for dashboard quick-action cards.
enum DashboardActionCardLayout { vertical, horizontal }
/// Reusable action card with flexible height and wrapping subtitles.
class DashboardActionCard extends StatelessWidget {
const DashboardActionCard({
super.key,
required this.title,
required this.subtitle,
required this.icon,
required this.onTap,
this.layout = DashboardActionCardLayout.vertical,
this.minHeight = 150,
this.useGradient = false,
this.badge,
this.iconSize = 24,
this.iconPadding = 10,
this.titleFontSize,
this.subtitleFontSize,
this.padding,
this.leadingIcon,
this.onTapDisabled,
});
final String title;
final String subtitle;
final IconData icon;
final VoidCallback? onTap;
final bool? onTapDisabled;
final DashboardActionCardLayout layout;
final double minHeight;
final bool useGradient;
final String? badge;
final double iconSize;
final double iconPadding;
final double? titleFontSize;
final double? subtitleFontSize;
final EdgeInsetsGeometry? padding;
final Widget? leadingIcon;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final extras = AppThemeExtras.of(context);
final isHorizontal = layout == DashboardActionCardLayout.horizontal;
final effectivePadding =
padding ?? EdgeInsets.all(isHorizontal ? 16 : (useGradient ? 20 : 14));
final decoration = BoxDecoration(
gradient: useGradient
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
extras.actionCardGradientStart,
extras.actionCardGradientEnd,
],
)
: null,
color: useGradient ? null : cs.surface,
borderRadius: BorderRadius.circular(minHeight <= 110 ? 12 : 16),
border: useGradient
? null
: Border.all(color: cs.outline.withOpacity(0.2), width: 1),
boxShadow: [
BoxShadow(
color: (useGradient ? cs.primary : cs.shadow).withOpacity(
useGradient ? 0.3 : 0.05,
),
blurRadius: useGradient ? 15 : 10,
offset: Offset(0, useGradient ? 8 : 4),
),
],
);
final titleColor = useGradient ? Colors.white : cs.onSurface;
final subtitleColor = useGradient ? Colors.white : cs.onSurfaceVariant;
final iconBgColor = useGradient
? Colors.white.withOpacity(0.2)
: cs.primary.withOpacity(0.1);
final iconColor = useGradient ? Colors.white : cs.primary;
final effectiveMinHeight = minHeight > 0 ? minHeight : null;
return ConstrainedBox(
constraints: effectiveMinHeight != null
? BoxConstraints(minHeight: effectiveMinHeight)
: const BoxConstraints(),
child: Container(
decoration: decoration,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(minHeight <= 110 ? 12 : 16),
onTap: onTapDisabled == true ? null : onTap,
child: Padding(
padding: effectivePadding,
child: isHorizontal
? _buildHorizontalContent(
context,
titleColor,
subtitleColor,
iconBgColor,
iconColor,
)
: _buildVerticalContent(
context,
titleColor,
subtitleColor,
iconBgColor,
iconColor,
),
),
),
),
),
);
}
Widget _buildHorizontalContent(
BuildContext context,
Color titleColor,
Color subtitleColor,
Color iconBgColor,
Color iconColor,
) {
final cs = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_iconBox(iconBgColor, iconColor),
const SizedBox(width: 16),
Expanded(
child: _titleSubtitleColumn(
titleColor,
subtitleColor,
titleSize: titleFontSize ?? 16,
subtitleSize: subtitleFontSize ?? 13,
),
),
Icon(Icons.arrow_forward_ios, color: cs.primary, size: 16),
],
);
}
Widget _buildVerticalContent(
BuildContext context,
Color titleColor,
Color subtitleColor,
Color iconBgColor,
Color iconColor,
) {
final cs = Theme.of(context).colorScheme;
final isCompact = minHeight <= 130;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
leadingIcon ?? _iconBox(iconBgColor, iconColor),
if (badge != null) ...[
const Spacer(),
Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 10,
vertical: isCompact ? 3 : 4,
),
decoration: BoxDecoration(
color: cs.secondary,
borderRadius: BorderRadius.circular(isCompact ? 10 : 12),
),
child: Text(
badge!,
style: TextStyle(
color: Colors.white,
fontSize: isCompact ? 9 : 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
SizedBox(height: isCompact ? 8 : 12),
_titleSubtitleColumn(
titleColor,
subtitleColor,
titleSize: titleFontSize ?? (useGradient ? 18 : 16),
subtitleSize: subtitleFontSize ?? (isCompact ? 11 : 12),
),
],
);
}
Widget _iconBox(Color bgColor, Color iconColor) {
return Container(
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: iconColor, size: iconSize),
);
}
Widget _titleSubtitleColumn(
Color titleColor,
Color subtitleColor, {
required double titleSize,
required double subtitleSize,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
color: titleColor,
fontSize: titleSize,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
if (subtitle.isNotEmpty) ...[
SizedBox(height: subtitleSize >= 13 ? 4 : 2),
Text(
subtitle,
style: TextStyle(
color: subtitleColor,
fontSize: subtitleSize,
height: 1.25,
),
),
],
],
);
}
}
/// Surface-styled vertical card (Quiz, Criar Turma, etc.).
class DashboardActionCardSurface extends StatelessWidget {
const DashboardActionCardSurface({
super.key,
required this.title,
required this.subtitle,
required this.icon,
required this.onTap,
this.minHeight = 150,
this.iconColor,
this.leadingWidget,
this.onTapDisabled,
this.titleFontSize = 14,
this.subtitleFontSize = 11,
this.iconSize = 20,
this.padding = const EdgeInsets.all(12),
});
final String title;
final String subtitle;
final IconData icon;
final VoidCallback? onTap;
final bool? onTapDisabled;
final double minHeight;
final Color? iconColor;
final Widget? leadingWidget;
final double titleFontSize;
final double subtitleFontSize;
final double iconSize;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final effectiveIconColor = iconColor ?? cs.secondary;
return DashboardActionCard(
title: title,
subtitle: subtitle,
icon: icon,
onTap: onTap,
onTapDisabled: onTapDisabled,
minHeight: minHeight,
useGradient: false,
iconSize: iconSize,
leadingIcon:
leadingWidget ??
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: effectiveIconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: effectiveIconColor, size: iconSize),
),
titleFontSize: titleFontSize,
subtitleFontSize: subtitleFontSize,
padding: padding,
);
}
}

View File

@@ -1,13 +1,77 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme_extension.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/user_stats.dart';
import '../../../../core/models/achievement.dart';
/// Profile section with user info and achievements
class ProfileSectionWidget extends StatelessWidget {
class ProfileSectionWidget extends StatefulWidget {
const ProfileSectionWidget({super.key});
@override
State<ProfileSectionWidget> createState() => _ProfileSectionWidgetState();
}
class _ProfileSectionWidgetState extends State<ProfileSectionWidget> {
UserStats? _userStats;
List<Achievement> _recentAchievements = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadUserData();
}
Future<void> _loadUserData() async {
try {
final user = AuthService.currentUser;
if (user != null) {
final results = await Future.wait([
GamificationService.getUserStats(user.uid),
GamificationService.getAvailableAchievements(),
]);
final stats = results[0] as UserStats?;
final achievements = results[1] as List<Achievement>;
// Obter conquistas desbloqueadas recentemente
final unlockedAchievementIds =
stats?.unlockedAchievements.map((ua) => ua.achievementId).toSet() ??
{};
final recentUnlocked = achievements
.where((a) => unlockedAchievementIds.contains(a.id))
.take(4)
.toList();
if (mounted) {
setState(() {
_userStats = stats;
_recentAchievements = recentUnlocked;
_loading = false;
});
}
}
} catch (e) {
print('Error loading user data: $e');
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final user = AuthService.currentUser;
final userName = user?.displayName ?? 'Estudante';
final userEmail = user?.email ?? '';
@@ -16,12 +80,14 @@ class ProfileSectionWidget extends StatelessWidget {
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Theme.of(context).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -37,8 +103,11 @@ class ProfileSectionWidget extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF82C9BD), Color(0xFF6BA8A0)],
gradient: LinearGradient(
colors: [
AppThemeExtras.of(context).actionCardGradientStart,
AppThemeExtras.of(context).actionCardGradientEnd,
],
),
borderRadius: BorderRadius.circular(24),
),
@@ -55,8 +124,8 @@ class ProfileSectionWidget extends StatelessWidget {
children: [
Text(
userName,
style: const TextStyle(
color: Color(0xFF2D3748),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -69,16 +138,20 @@ class ProfileSectionWidget extends StatelessWidget {
children: [
Text(
userEmail,
style: const TextStyle(
color: Color(0xFF718096),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
if (userEmail.length > 20) ...[
const SizedBox(width: 8),
const Icon(
Icon(
Icons.more_horiz,
color: Color(0xFF718096),
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
size: 16,
),
],
@@ -88,18 +161,6 @@ class ProfileSectionWidget extends StatelessWidget {
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.settings,
color: Color(0xFFF68D2D),
size: 20,
),
),
],
),
const SizedBox(height: 20),
@@ -107,16 +168,16 @@ class ProfileSectionWidget extends StatelessWidget {
// Achievements
Row(
children: [
const Icon(
Icon(
Icons.emoji_events,
color: Color(0xFFF68D2D),
color: Theme.of(context).colorScheme.secondary,
size: 20,
),
const SizedBox(width: 8),
const Text(
Text(
'Conquistas',
style: TextStyle(
color: Color(0xFF2D3748),
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -125,49 +186,76 @@ class ProfileSectionWidget extends StatelessWidget {
),
const SizedBox(height: 12),
// Achievement Badges
Row(
children: [
_buildAchievementBadge(
icon: Icons.local_fire_department,
label: '7 dias',
color: const Color(0xFFF68D2D),
// Achievement List (Teacher-style design)
if (_recentAchievements.isNotEmpty) ...[
..._recentAchievements.map((achievement) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildAchievementItem(
context,
achievement.name,
achievement.points,
_getRarityColor(achievement.rarity),
_getIconData(achievement.icon),
),
);
}),
] else ...[
// Streak item
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildAchievementItem(
context,
'${_userStats?.currentStreak ?? 0} dias seguidos',
(_userStats?.currentStreak ?? 0) * 5,
Theme.of(context).colorScheme.secondary,
Icons.local_fire_department,
),
const SizedBox(width: 12),
_buildAchievementBadge(
icon: Icons.school,
label: '3 conceitos',
color: const Color(0xFF82C9BD),
),
// Concepts item
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildAchievementItem(
context,
'${_userStats?.masteredConcepts.length ?? 0} conceitos dominados',
(_userStats?.masteredConcepts.length ?? 0) * 10,
Theme.of(context).colorScheme.primary,
Icons.school,
),
const SizedBox(width: 12),
_buildAchievementBadge(
icon: Icons.speed,
label: 'Rápido',
color: const Color(0xFF6BA8A0),
),
if (_userStats != null && _userStats!.totalStudyTime > 0)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildAchievementItem(
context,
'${(_userStats!.totalStudyTime / 60).toStringAsFixed(1)}h estudadas',
(_userStats!.totalStudyTime ~/ 60) * 3,
Theme.of(context).colorScheme.tertiary,
Icons.schedule,
),
),
const SizedBox(width: 12),
_buildAchievementBadge(
icon: Icons.star,
label: '100%',
color: const Color(0xFF4CAF50),
),
],
),
],
const SizedBox(height: 20),
// Recent Activity Summary
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
children: [
const Icon(
Icon(
Icons.trending_up,
color: Color(0xFF82C9BD),
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 12),
@@ -175,19 +263,21 @@ class ProfileSectionWidget extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ótimo progresso!',
Text(
_getProgressMessage(),
style: TextStyle(
color: Color(0xFF2D3748),
color: Theme.of(context).colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Você está 15% acima da média esta semana',
style: const TextStyle(
color: Color(0xFF718096),
_getProgressComparison(),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
@@ -201,39 +291,125 @@ class ProfileSectionWidget extends StatelessWidget {
),
)
.animate()
.slideY(
duration: const Duration(milliseconds: 800),
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 400));
.then(delay: const Duration(milliseconds: 200));
}
Widget _buildAchievementBadge({
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3), width: 1),
),
child: Column(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(height: 4),
Text(
label,
Widget _buildAchievementItem(
BuildContext context,
String name,
int points,
Color color,
IconData icon,
) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(child: Icon(icon, color: color, size: 16)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$points pts',
style: TextStyle(
color: color,
fontSize: 10,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
);
}
IconData _getIconData(String iconName) {
switch (iconName) {
case 'emoji_events':
return Icons.emoji_events;
case 'school':
return Icons.school;
case 'local_fire_department':
return Icons.local_fire_department;
case 'schedule':
return Icons.schedule;
case 'trending_up':
return Icons.trending_up;
case 'military_tech':
return Icons.military_tech;
case 'workspace_premium':
return Icons.workspace_premium;
case 'psychology':
return Icons.psychology;
case 'lightbulb':
return Icons.lightbulb;
case 'star':
return Icons.star;
case 'speed':
return Icons.speed;
default:
return Icons.star;
}
}
Color _getRarityColor(String rarity) {
switch (rarity) {
case 'common':
return Colors.grey;
case 'rare':
return Colors.blue;
case 'epic':
return Colors.purple;
case 'legendary':
return Colors.orange;
default:
return Colors.grey;
}
}
String _getProgressMessage() {
if (_userStats == null) return 'Continue estudando!';
final streak = _userStats!.currentStreak;
final studyTime = _userStats!.totalStudyTime;
if (streak >= 7) return 'Incrível streak! 🔥';
if (studyTime >= 300) return 'Dedicação exemplar! 📚';
if (streak >= 3) return 'Bom progresso! 📈';
return 'Continue estudando! 💪';
}
String _getProgressComparison() {
if (_userStats == null) return 'Comece sua jornada de estudos';
final weeklyTime = _userStats!.weeklyStudyTime;
final concepts = _userStats!.masteredConcepts.length;
if (weeklyTime >= 180) return 'Você está 15% acima da média esta semana';
if (concepts >= 3) return 'Domine $concepts conceitos esta semana';
if (weeklyTime >= 60) return 'Bom tempo de estudo esta semana';
return 'Continue assim para subir no ranking!';
}
}

View File

@@ -1,29 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// Progress tracking hero section for student dashboard
class ProgressHeroWidget extends StatelessWidget {
final String userName;
final double overallProgress;
final List<String> masteredConcepts;
final int studyTimeMinutes;
final int streakDays;
import '../../../../core/theme/app_theme_extension.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/user_stats.dart';
import '../../../../core/services/auth_service.dart';
const ProgressHeroWidget({
super.key,
required this.userName,
this.overallProgress = 0.65,
this.masteredConcepts = const [
'Fundamentos de Programação',
'Algoritmos Básicos',
'Estruturas de Dados',
],
this.studyTimeMinutes = 245,
this.streakDays = 7,
});
/// Progress tracking hero section for student dashboard
class ProgressHeroWidget extends StatefulWidget {
final String userName;
const ProgressHeroWidget({super.key, required this.userName});
@override
State<ProgressHeroWidget> createState() => _ProgressHeroWidgetState();
}
class _ProgressHeroWidgetState extends State<ProgressHeroWidget> {
UserStats? _cachedUserStats;
bool _isFirstLoad = true;
@override
Widget build(BuildContext context) {
return FutureBuilder<UserStats?>(
future: _loadUserStats(),
builder: (context, snapshot) {
// Show cached data while loading to prevent flickering
if (snapshot.connectionState == ConnectionState.waiting &&
_cachedUserStats != null) {
return _buildContent(_cachedUserStats);
}
if (snapshot.connectionState == ConnectionState.waiting &&
_isFirstLoad) {
return _buildLoadingState();
}
if (snapshot.hasError) {
return _buildErrorState();
}
final userStats = snapshot.data;
if (userStats != null) {
_cachedUserStats = userStats;
_isFirstLoad = false;
}
return _buildContent(userStats);
},
);
}
Future<UserStats?> _loadUserStats() async {
try {
final user = AuthService.currentUser;
if (user != null) {
return await GamificationService.getUserStats(user.uid);
}
return null;
} catch (e) {
print('Error loading user stats: $e');
return null;
}
}
double _calculateOverallProgress(UserStats? userStats) {
if (userStats == null || userStats.masteredConcepts.isEmpty) {
return 0.0;
}
final totalMastery = userStats.masteredConcepts
.map((c) => c.masteryLevel)
.reduce((a, b) => a + b);
return totalMastery / (userStats.masteredConcepts.length * 100);
}
Widget _buildLoadingState() {
return const Center(child: CircularProgressIndicator());
}
Widget _buildErrorState() {
return const Center(child: Text('Erro ao carregar dados'));
}
Widget _buildContent(UserStats? userStats) {
final streakDays = userStats?.currentStreak ?? 0;
final overallProgress = _calculateOverallProgress(userStats);
final masteredConcepts =
userStats?.masteredConcepts.map((c) => c.conceptName).toList() ?? [];
final studyTimeMinutes = userStats?.totalStudyTime ?? 0;
return Container(
margin: const EdgeInsets.only(bottom: 24),
child: Column(
@@ -39,16 +103,16 @@ class ProgressHeroWidget extends StatelessWidget {
Text(
'Seu Progresso',
style: TextStyle(
color: const Color(0xFF2D3748),
color: Theme.of(context).colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Continue assim, $userName!',
'Continue assim, ${widget.userName}!',
style: TextStyle(
color: const Color(0xFF718096),
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
@@ -56,9 +120,12 @@ class ProgressHeroWidget extends StatelessWidget {
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF82C9BD),
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -93,8 +160,8 @@ class ProgressHeroWidget extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF82C9BD),
const Color(0xFF6BA8A0),
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primary.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(20),
@@ -132,23 +199,29 @@ class ProgressHeroWidget extends StatelessWidget {
],
),
const SizedBox(height: 16),
// Progress Bar
Container(
height: 12,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(6),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: overallProgress,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Colors.white, Color(0xFFF8F9FA)],
GestureDetector(
onTap: () => _showProgressExplanation(context),
child: Container(
height: 12,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(6),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: overallProgress,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppThemeExtras.of(context).heroProgressStart,
AppThemeExtras.of(context).heroProgressEnd,
],
),
borderRadius: BorderRadius.circular(6),
),
borderRadius: BorderRadius.circular(6),
),
),
),
@@ -159,10 +232,14 @@ class ProgressHeroWidget extends StatelessWidget {
Row(
children: [
Expanded(
child: _buildStatCard(
icon: Icons.access_time,
value: '${(studyTimeMinutes / 60).toStringAsFixed(1)}h',
label: 'Tempo de Estudo',
child: GestureDetector(
onTap: () => _showStudyTimeDetails(context, userStats),
child: _buildStatCard(
icon: Icons.access_time,
value:
'${(studyTimeMinutes / 60).toStringAsFixed(1)}h',
label: 'Tempo de Estudo',
),
),
),
const SizedBox(width: 12),
@@ -177,86 +254,96 @@ class ProgressHeroWidget extends StatelessWidget {
),
],
),
).animate().scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
),
const SizedBox(height: 20),
// Mastered Concepts
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.school,
color: Color(0xFFF68D2D),
size: 20,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
const SizedBox(width: 8),
const Text(
'Conceitos Dominados',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 16,
fontWeight: FontWeight.bold,
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.school,
color: Theme.of(context).colorScheme.secondary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Conceitos Dominados',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
...masteredConcepts.map(
(concept) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
concept,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
),
Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
size: 16,
),
],
),
),
),
],
),
const SizedBox(height: 12),
...masteredConcepts.map((concept) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF82C9BD),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
concept,
style: const TextStyle(
color: Color(0xFF4A5568),
fontSize: 14,
),
),
),
const Icon(
Icons.check_circle,
color: Color(0xFF82C9BD),
size: 16,
),
],
),
)),
],
),
).animate().slideX(
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
).then(delay: const Duration(milliseconds: 200)),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 200)),
],
),
);
@@ -272,10 +359,7 @@ class ProgressHeroWidget extends StatelessWidget {
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
border: Border.all(color: Colors.white.withOpacity(0.3), width: 1),
),
child: Column(
children: [
@@ -292,14 +376,118 @@ class ProgressHeroWidget extends StatelessWidget {
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
style: const TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
),
],
),
);
}
void _showProgressExplanation(BuildContext context) {
final cs = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.info_outline, color: cs.primary),
const SizedBox(width: 8),
const Text('Progresso Geral'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'O Progresso Geral representa a média dos níveis de domínio dos conceitos que já dominaste.',
style: TextStyle(fontSize: 14),
),
SizedBox(height: 12),
Text(
'Como é calculado:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
SizedBox(height: 8),
Text(
'• Cada conceito tem um nível de 0 a 100\n'
'• O progresso é a média de todos os conceitos dominados\n'
'• Quanto mais alto, melhor o teu domínio',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Entendi'),
),
],
),
);
}
void _showStudyTimeDetails(BuildContext context, UserStats? userStats) {
final cs = Theme.of(context).colorScheme;
final totalMinutes = userStats?.totalStudyTime ?? 0;
final weeklyMinutes = userStats?.weeklyStudyTime ?? 0;
final monthlyMinutes = userStats?.monthlyStudyTime ?? 0;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.access_time, color: cs.primary),
const SizedBox(width: 8),
const Text('Tempo de Estudo'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTimeRow('Total', totalMinutes, cs),
const SizedBox(height: 12),
_buildTimeRow('Esta semana', weeklyMinutes, cs),
const SizedBox(height: 12),
_buildTimeRow('Este mês', monthlyMinutes, cs),
const SizedBox(height: 16),
const Text(
'O tempo é contado automaticamente quando completas quizzes.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Fechar'),
),
],
),
);
}
Widget _buildTimeRow(String label, int minutes, ColorScheme cs) {
final hours = minutes ~/ 60;
final mins = minutes % 60;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 14, color: cs.onSurface)),
Text(
hours > 0 ? '${hours}h ${mins}min' : '${mins}min',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: cs.primary,
),
),
],
);
}
}

View File

@@ -1,238 +1,444 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import '../../../classes/presentation/pages/join_class_page.dart';
import '../../../materials/presentation/pages/content_management_page.dart';
import 'dashboard_action_card.dart';
/// Quick access cards for Tutor IA and Quiz with fixed overflow
/// Quick access cards for Student Dashboard with horizontal scrollable row
class QuickAccessWidget extends StatelessWidget {
const QuickAccessWidget({super.key});
/// Mesmas dimensões dos cards em "Ações Rápidas" do professor.
static const double _scrollCardWidth = 200;
static const double _scrollRowHeight = 150;
static const double _cardMinHeight = 150;
static const EdgeInsets _cardPadding = EdgeInsets.all(16);
static const double _titleFontSize = 16;
static const double _subtitleFontSize = 13;
static const double _iconSize = 24;
static const double _iconPadding = 10;
@override
Widget build(BuildContext context) {
final cards = [
_buildTutorIACard(context),
_buildContentManagementCard(context),
_buildQuizCard(context),
_buildAchievementsCard(context),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Acesso Rápido',
style: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
// Tutor IA Card (Primary)
Expanded(flex: 3, child: _buildTutorIACard(context)),
const SizedBox(width: 16),
// Quiz Card (Secondary)
Expanded(flex: 2, child: _buildQuizCard(context)),
],
),
],
)
.animate()
.slideY(
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 200));
}
Widget _buildTutorIACard(BuildContext context) {
return Container(
height: 150,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF82C9BD), Color(0xFF6BA8A0)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF82C9BD).withOpacity(0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
print('DEBUG: AI Tutor card clicked!');
try {
context.go('/ai-tutor');
print('DEBUG: Navigation to AI Tutor successful');
} catch (e) {
print('DEBUG: Navigation error: $e');
}
},
InkWell(
onTap: () => _showQuickAccessList(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.psychology,
color: Colors.white,
size: 24,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'NOVO',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
Text(
'Acesso Rápido',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tutor IA',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
//SizedBox(height: 4),
Text(
'Assistente de estudos',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.2,
),
),
],
const SizedBox(width: 8),
Icon(
Icons.expand_more,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
),
),
),
const SizedBox(height: 12),
IntrinsicHeight(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.only(right: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: _scrollCardWidth, child: cards[0]),
const SizedBox(width: 12),
SizedBox(width: _scrollCardWidth, child: cards[1]),
const SizedBox(width: 12),
SizedBox(width: _scrollCardWidth, child: cards[2]),
const SizedBox(width: 12),
SizedBox(width: _scrollCardWidth, child: cards[3]),
],
),
),
),
const SizedBox(height: 16),
// Entrar numa Disciplina (full width)
_buildJoinClassCard(context),
],
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 100));
}
Widget _buildQuizCard(BuildContext context) {
return Container(
height: 150,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
print('DEBUG: AI Tutor card clicked!');
try {
context.go('/ai-tutor');
print('DEBUG: Navigation to AI Tutor successful');
} catch (e) {
print('DEBUG: Navigation error: $e');
}
},
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.quiz,
color: Color(0xFFF68D2D),
size: 24,
),
Widget _buildTutorIACard(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child:
DashboardActionCard(
title: 'Tutor IA',
subtitle: 'Assistente de estudos',
icon: Icons.psychology,
useGradient: true,
minHeight: _cardMinHeight,
iconSize: _iconSize,
iconPadding: _iconPadding,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
padding: _cardPadding,
leadingIcon: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
const SizedBox(height: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quiz',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
'Teste conhecimentos',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0xFF718096),
fontSize: 12,
height: 1.2,
),
),
],
),
],
),
),
),
),
),
onTap: () => context.go('/ai-tutor'),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 100)),
);
}
Widget _buildContentManagementCard(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child:
DashboardActionCardSurface(
title: 'Gerenciamento Conteúdo',
subtitle: 'Ver por disciplinas',
icon: Icons.folder_open,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContentManagementPage(),
),
);
},
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 150)),
);
}
Widget _buildQuizCard(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child:
DashboardActionCardSurface(
title: 'Quiz',
subtitle: 'Testa os teus conhecimentos',
icon: Icons.quiz,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
onTap: () => context.go('/quiz'),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 150)),
);
}
Widget _buildAchievementsCard(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child:
DashboardActionCardSurface(
title: 'Conquistas',
subtitle: 'Ver medals',
icon: Icons.emoji_events,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
iconColor: Colors.amber,
onTap: () => context.go('/student/achievements'),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 200)),
);
}
Widget _buildJoinClassCard(BuildContext context) {
return DashboardActionCard(
title: 'Adicionar uma Disciplina',
subtitle: 'Junta-te a uma disciplina com o código',
icon: Icons.group_add,
layout: DashboardActionCardLayout.horizontal,
minHeight: 0,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const JoinClassPage()),
);
},
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 200));
.then(delay: const Duration(milliseconds: 300));
}
void _showQuickAccessList(BuildContext context) {
final items = [
_QuickAccessItem(
title: 'Tutor IA',
subtitle: 'Assistente de estudos',
icon: Icons.psychology,
useGradient: true,
useCustomIcon: true,
onTap: () {
Navigator.pop(context);
context.go('/ai-tutor');
},
),
_QuickAccessItem(
title: 'Gerenciamento Conteúdo',
subtitle: 'Ver por disciplinas',
icon: Icons.folder_open,
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ContentManagementPage()),
);
},
),
_QuickAccessItem(
title: 'Quiz',
subtitle: 'Testa os teus conhecimentos',
icon: Icons.quiz,
onTap: () {
Navigator.pop(context);
context.go('/quiz');
},
),
_QuickAccessItem(
title: 'Conquistas',
subtitle: 'Ver medals',
icon: Icons.emoji_events,
iconColor: Colors.amber,
onTap: () {
Navigator.pop(context);
context.go('/student/achievements');
},
),
_QuickAccessItem(
title: 'Adicionar uma Disciplina',
subtitle: 'Junta-te a uma disciplina com o código',
icon: Icons.group_add,
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const JoinClassPage()),
);
},
),
];
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'Acesso Rápido',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
child: ListTile(
leading: item.useCustomIcon
? Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
),
)
: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: item.useGradient
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.1)
: (item.iconColor ??
Theme.of(
context,
).colorScheme.secondary)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: item.useGradient
? Theme.of(context).colorScheme.primary
: (item.iconColor ??
Theme.of(
context,
).colorScheme.secondary),
size: 24,
),
),
title: Text(
item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Text(
item.subtitle,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
trailing: Icon(
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.primary,
size: 16,
),
onTap: item.onTap,
),
);
},
),
),
const SizedBox(height: 20),
],
);
},
),
);
}
}
class _QuickAccessItem {
final String title;
final String subtitle;
final IconData icon;
final bool useGradient;
final Color? iconColor;
final bool useCustomIcon;
final VoidCallback onTap;
_QuickAccessItem({
required this.title,
required this.subtitle,
required this.icon,
this.useGradient = false,
this.iconColor,
this.useCustomIcon = false,
required this.onTap,
});
}

View File

@@ -0,0 +1,582 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_service.dart';
/// Widget para listar as disciplinas onde o aluno está inscrito
class StudentClassesListWidget extends StatefulWidget {
const StudentClassesListWidget({super.key});
@override
State<StudentClassesListWidget> createState() =>
_StudentClassesListWidgetState();
}
class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
List<DocumentSnapshot>? _enrollments;
@override
Widget build(BuildContext context) {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
return const SizedBox.shrink();
}
return StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('enrollments')
.where('studentId', isEqualTo: currentUser.uid)
.orderBy('joinedAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting &&
_enrollments == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
if (snapshot.hasError && _enrollments == null) {
return const SizedBox.shrink();
}
final enrollments = snapshot.data?.docs ?? _enrollments ?? [];
if (snapshot.data?.docs != null) {
_enrollments = snapshot.data?.docs;
}
if (enrollments.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
'Ainda não entraste em nenhuma disciplina.',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => _showClassesList(context, enrollments),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'As Minhas Disciplinas',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_more,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
),
),
const SizedBox(height: 16),
SizedBox(
height: 330,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(right: 16),
itemCount: (enrollments.length + 1) ~/ 2,
itemBuilder: (context, index) {
final firstIndex = index * 2;
final secondIndex = firstIndex + 1;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
_buildClassCard(enrollments[firstIndex]),
const SizedBox(height: 12),
if (secondIndex < enrollments.length)
_buildClassCard(enrollments[secondIndex]),
],
),
);
},
),
),
],
);
},
);
}
Widget _buildClassCard(DocumentSnapshot enrollmentDoc) {
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
final classId = enrollmentData['classId'] as String? ?? '';
final enrollmentId = enrollmentDoc.id;
final customClassName = enrollmentData['customClassName'] as String?;
if (classId.isEmpty) {
return const SizedBox.shrink();
}
return FutureBuilder<DocumentSnapshot>(
future: FirebaseFirestore.instance
.collection('classes')
.doc(classId)
.get(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!.exists) {
return Container(
width: 200,
constraints: const BoxConstraints(minHeight: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
strokeWidth: 2,
),
),
);
}
final classData = snapshot.data!.data() as Map<String, dynamic>;
final className =
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
final classCode = classData['code'] as String? ?? '----';
return GestureDetector(
onTap: () => _showEditNameDialog(context, enrollmentId, className),
child: Container(
width: 200,
constraints: const BoxConstraints(minHeight: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.school,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.primary,
size: 18,
),
],
),
const SizedBox(height: 12),
Text(
className,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Código: $classCode',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
);
},
);
}
void _showClassesList(
BuildContext context,
List<DocumentSnapshot> enrollments,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'As Minhas Disciplinas',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: enrollments.length,
itemBuilder: (context, index) {
return _buildClassListTile(context, enrollments[index]);
},
),
),
const SizedBox(height: 20),
],
);
},
),
);
}
Widget _buildClassListTile(
BuildContext context,
DocumentSnapshot enrollmentDoc,
) {
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
final classId = enrollmentData['classId'] as String? ?? '';
final enrollmentId = enrollmentDoc.id;
final customClassName = enrollmentData['customClassName'] as String?;
if (classId.isEmpty) {
return const SizedBox.shrink();
}
return FutureBuilder<DocumentSnapshot>(
future: FirebaseFirestore.instance
.collection('classes')
.doc(classId)
.get(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!.exists) {
return const Card(
child: ListTile(
leading: CircularProgressIndicator(),
title: Text('Carregando...'),
),
);
}
final classData = snapshot.data!.data() as Map<String, dynamic>;
final className =
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
final classCode = classData['code'] as String? ?? '----';
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.school,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
title: Text(
className,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Text(
'Código: $classCode',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.edit,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
onPressed: () =>
_showEditNameDialog(context, enrollmentId, className),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
size: 20,
),
onPressed: () =>
_showRemoveClassDialog(context, enrollmentId, className),
),
],
),
onTap: () => _showEditNameDialog(context, enrollmentId, className),
),
);
},
);
}
void _showRemoveClassDialog(
BuildContext context,
String enrollmentId,
String className,
) {
final textController = TextEditingController();
bool isConfirmEnabled = false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Sair da disciplina'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Para confirmar que queres sair da disciplina "$className", escreve o nome desta disciplina:',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(height: 16),
TextField(
controller: textController,
onChanged: (value) {
setDialogState(() {
isConfirmEnabled = value.trim() == className.trim();
});
},
decoration: InputDecoration(
hintText: 'Nome da disciplina',
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
),
const SizedBox(height: 8),
Text(
'Esta ação não pode ser desfeita.',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
ElevatedButton(
onPressed: isConfirmEnabled
? () async {
Navigator.pop(context);
await _removeEnrollment(enrollmentId);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
disabledBackgroundColor: Theme.of(
context,
).colorScheme.error.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Sair'),
),
],
);
},
),
);
}
Future<void> _removeEnrollment(String enrollmentId) async {
try {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentId)
.delete();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Saíste da disciplina com sucesso'),
backgroundColor: Theme.of(context).colorScheme.primary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao sair da disciplina: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
void _showEditNameDialog(
BuildContext context,
String enrollmentId,
String currentName,
) {
final controller = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Editar nome da disciplina'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Nome da disciplina'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () async {
final newName = controller.text.trim();
if (newName.isNotEmpty) {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentId)
.update({'customClassName': newName});
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nome atualizado com sucesso'),
behavior: SnackBarBehavior.floating,
),
);
}
}
},
child: const Text('Salvar'),
),
],
),
);
}
}

View File

@@ -1,219 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/class_stats.dart';
/// Analytics preview section for teacher dashboard
class TeacherAnalyticsPreviewWidget extends StatelessWidget {
class TeacherAnalyticsPreviewWidget extends StatefulWidget {
const TeacherAnalyticsPreviewWidget({super.key});
@override
Widget build(BuildContext context) {
final user = AuthService.currentUser;
final userName = user?.displayName ?? 'Professor';
final userEmail = user?.email ?? '';
State<TeacherAnalyticsPreviewWidget> createState() =>
_TeacherAnalyticsPreviewWidgetState();
}
return Container(
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile Header
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF82C9BD), Color(0xFF6BA8A0)],
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(
Icons.school,
color: Colors.white,
size: 24,
),
class _TeacherAnalyticsPreviewWidgetState
extends State<TeacherAnalyticsPreviewWidget> {
List<StudentRanking> _topStudents = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadTopStudents();
}
Future<void> _loadTopStudents() async {
try {
final user = AuthService.currentUser;
if (user == null) return;
// Obter turmas do professor
final classesSnapshot = await FirebaseFirestore.instance
.collection('classes')
.where('teacherId', isEqualTo: user.uid)
.get();
List<StudentRanking> allStudents = [];
// Buscar ranking de cada turma
for (final classDoc in classesSnapshot.docs) {
final classId = classDoc.id;
// Forçar atualização para obter dados mais recentes
final rankings = await GamificationService.getClassRanking(classId);
allStudents.addAll(rankings);
}
// Ordenar por score e pegar os top 4
allStudents.sort((a, b) => b.overallScore.compareTo(a.overallScore));
final top4 = allStudents.take(4).toList();
if (mounted) {
setState(() {
_topStudents = top4;
_loading = false;
});
}
} catch (e) {
print('Error loading top students: $e');
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildQuickStat(
icon: Icons.check_circle,
label: 'Alunos Matriculados',
value: '18/24',
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
_buildQuickStat(
icon: Icons.warning_amber,
label: 'Precisam Apoio',
value: '3',
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 12),
_buildQuickStat(
icon: Icons.emoji_events,
label: 'Média Turma',
value: '72%',
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
),
],
),
const SizedBox(height: 20),
// Top Performing Students Preview
Row(
children: [
Icon(
Icons.leaderboard,
color: Theme.of(context).colorScheme.secondary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Melhores Desempenhos',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
const SizedBox(width: 16),
),
],
),
const SizedBox(height: 12),
// Student List Preview
if (_loading)
const Center(child: CircularProgressIndicator())
else if (_topStudents.isEmpty)
const Center(
child: Text(
'Nenhum aluno encontrado',
style: TextStyle(fontSize: 14),
),
)
else
..._topStudents.asMap().entries.map((entry) {
final index = entry.key;
final student = entry.value;
final color = _getStudentColor(context, index);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildStudentPerformanceItem(
context,
student.studentName,
student.overallScore.toInt(),
color,
),
);
}).toList(),
const SizedBox(height: 20),
// Content Quality Alert
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName,
style: const TextStyle(
color: Color(0xFF2D3748),
fontSize: 18,
'Qualidade do Conteúdo',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
userEmail,
style: const TextStyle(
color: Color(0xFF718096),
fontSize: 14,
),
),
if (userEmail.length > 20) ...[
const SizedBox(width: 8),
const Icon(
Icons.more_horiz,
color: Color(0xFF718096),
size: 16,
),
],
],
Text(
'12 conteúdos verificados • 2 pendentes de revisão',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.settings,
color: Color(0xFFF68D2D),
size: 20,
),
),
],
),
const SizedBox(height: 20),
// Quick Stats Row
Row(
children: [
_buildQuickStat(
icon: Icons.check_circle,
label: 'Alunos Ativos',
value: '18/24',
color: const Color(0xFF82C9BD),
),
const SizedBox(width: 12),
_buildQuickStat(
icon: Icons.warning_amber,
label: 'Precisam Apoio',
value: '3',
color: const Color(0xFFF68D2D),
),
const SizedBox(width: 12),
_buildQuickStat(
icon: Icons.emoji_events,
label: 'Média Turma',
value: '72%',
color: const Color(0xFF6BA8A0),
),
],
),
const SizedBox(height: 20),
// Top Performing Students Preview
Row(
children: [
const Icon(
Icons.leaderboard,
color: Color(0xFFF68D2D),
size: 20,
),
const SizedBox(width: 8),
const Text(
'Melhores Desempenhos',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Student List Preview
_buildStudentPerformanceItem('Ana Silva', 95, const Color(0xFF4CAF50)),
const SizedBox(height: 8),
_buildStudentPerformanceItem('João Costa', 88, const Color(0xFF82C9BD)),
const SizedBox(height: 8),
_buildStudentPerformanceItem('Maria Santos', 82, const Color(0xFF82C9BD)),
const SizedBox(height: 8),
_buildStudentPerformanceItem('Pedro Lima', 45, const Color(0xFFF68D2D)),
const SizedBox(height: 20),
// Content Quality Alert
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: Color(0xFF82C9BD),
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Qualidade do Conteúdo',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'12 conteúdos verificados • 2 pendentes de revisão',
style: const TextStyle(
color: Color(0xFF718096),
fontSize: 12,
),
),
],
),
),
],
),
),
],
),
)
.animate()
.slideY(
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 400));
),
],
);
}
Widget _buildQuickStat({
@@ -245,11 +224,9 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
color: color.withOpacity(0.8),
fontSize: 10,
),
style: TextStyle(color: color.withOpacity(0.8), fontSize: 10),
textAlign: TextAlign.center,
maxLines: 3,
),
],
),
@@ -257,7 +234,22 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
);
}
Widget _buildStudentPerformanceItem(String name, int score, Color color) {
Color _getStudentColor(BuildContext context, int index) {
final colors = [
Theme.of(context).colorScheme.tertiary,
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
];
return colors[index % colors.length];
}
Widget _buildStudentPerformanceItem(
BuildContext context,
String name,
int score,
Color color,
) {
return Row(
children: [
Container(
@@ -282,8 +274,8 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
Expanded(
child: Text(
name,
style: const TextStyle(
color: Color(0xFF4A5568),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),

View File

@@ -0,0 +1,300 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import '../../../../core/services/auth_service.dart';
import '../../../classes/presentation/pages/class_students_page.dart';
/// Widget para listar as turmas criadas pelo professor
class TeacherClassesListWidget extends StatelessWidget {
const TeacherClassesListWidget({super.key});
@override
Widget build(BuildContext context) {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
return const SizedBox.shrink();
}
return StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('classes')
.where('teacherId', isEqualTo: currentUser.uid)
.orderBy('createdAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
if (snapshot.hasError) {
return const SizedBox.shrink();
}
final classes = snapshot.data?.docs ?? [];
if (classes.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
'Ainda não criaste nenhuma turma.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => _showClassesList(context, classes),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'As Minhas Turmas',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_more,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 22,
),
],
),
),
),
const SizedBox(height: 16),
SizedBox(
height: 330,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(right: 16),
itemCount: (classes.length + 1) ~/ 2,
itemBuilder: (context, index) {
final firstIndex = index * 2;
final secondIndex = firstIndex + 1;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
_buildClassCard(classes[firstIndex], context),
const SizedBox(height: 12),
if (secondIndex < classes.length)
_buildClassCard(classes[secondIndex], context),
],
),
);
},
),
),
],
);
},
);
}
void _showClassesList(
BuildContext context,
List<QueryDocumentSnapshot> classes,
) {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'As Minhas Turmas',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: classes.length,
itemBuilder: (context, index) {
final data = classes[index].data() as Map<String, dynamic>;
final classId = classes[index].id;
final className = data['name'] as String? ?? 'Sem nome';
final classCode = data['code'] as String? ?? '----';
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: cs.outline.withOpacity(0.2)),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.school,
color: cs.primary,
size: 24,
),
),
title: Text(
className,
style: TextStyle(
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
subtitle: Text(
'Código: $classCode',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
trailing: Icon(
Icons.arrow_forward_ios,
color: cs.primary,
size: 16,
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ClassStudentsPage(
classId: classId,
className: className,
),
),
);
},
),
);
},
),
),
const SizedBox(height: 20),
],
);
},
),
);
}
Widget _buildClassCard(DocumentSnapshot doc, BuildContext context) {
final data = doc.data() as Map<String, dynamic>;
final classId = doc.id;
final className = data['name'] as String? ?? 'Sem nome';
final classCode = data['code'] as String? ?? '----';
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ClassStudentsPage(classId: classId, className: className),
),
);
},
child: Container(
width: 200,
constraints: const BoxConstraints(minHeight: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.school,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
const SizedBox(height: 12),
Text(
className,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Código: $classCode',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
);
}
}

View File

@@ -1,25 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/class_stats.dart';
import '../../../../core/services/auth_service.dart';
/// Hero section for teacher dashboard showing class overview
class TeacherHeroWidget extends StatelessWidget {
class TeacherHeroWidget extends StatefulWidget {
final String userName;
final int totalStudents;
final int activeQuizzes;
final int uploadedContent;
final double classAverageProgress;
final VoidCallback? onRefresh;
const TeacherHeroWidget({
super.key,
required this.userName,
this.totalStudents = 24,
this.activeQuizzes = 3,
this.uploadedContent = 12,
this.classAverageProgress = 0.72,
});
const TeacherHeroWidget({super.key, required this.userName, this.onRefresh});
@override
State<TeacherHeroWidget> createState() => _TeacherHeroWidgetState();
}
class _TeacherHeroWidgetState extends State<TeacherHeroWidget>
with AutomaticKeepAliveClientMixin {
List<ClassStats> _classStats = [];
List<ClassStats> _cachedClassStats = [];
bool _loading = true;
bool _isFirstLoad = true;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_loadClassStats();
}
Future<void> _loadClassStats({bool forceRefresh = false}) async {
try {
final user = AuthService.currentUser;
if (user == null) return;
// Show cached data immediately if available and not forcing refresh
if (_cachedClassStats.isNotEmpty && !forceRefresh) {
setState(() {
_classStats = _cachedClassStats;
_loading = false;
});
return; // Don't reload if we have cached data
}
setState(() {
_loading = _isFirstLoad;
});
// Obter turmas do professor
final classesSnapshot = await FirebaseFirestore.instance
.collection('classes')
.where('teacherId', isEqualTo: user.uid)
.get();
final classStatsList = <ClassStats>[];
for (final classDoc in classesSnapshot.docs) {
final classId = classDoc.id;
final stats = await GamificationService.getClassStats(
classId,
forceRefresh: forceRefresh,
);
if (stats != null) {
classStatsList.add(stats);
}
}
if (mounted) {
setState(() {
_classStats = classStatsList;
_cachedClassStats = classStatsList;
_loading = false;
_isFirstLoad = false;
});
}
} catch (e) {
print('Error loading class stats: $e');
if (mounted) {
setState(() {
_loading = false;
_isFirstLoad = false;
});
}
}
}
/// Public method to refresh data
Future<void> refresh() async {
await _loadClassStats(forceRefresh: true);
}
int get totalStudents =>
_classStats.fold(0, (sum, stats) => sum + stats.totalStudents);
int get activeQuizzes =>
_classStats.fold(0, (sum, stats) => sum + stats.activeQuizzes);
int get uploadedContent =>
_classStats.fold(0, (sum, stats) => sum + stats.totalContent);
int get studentsNeedingSupport => _classStats.fold(
0,
(sum, stats) => sum + stats.studentsNeedingSupport.length,
);
double get classAverageProgress {
if (_classStats.isEmpty) return 0.0;
final totalProgress = _classStats.fold(
0.0,
(sum, stats) => sum + stats.averageProgress,
);
final average = totalProgress / _classStats.length;
print('=== UI PROGRESS DEBUG ===');
print('Number of classes: ${_classStats.length}');
for (int i = 0; i < _classStats.length; i++) {
print(
'Class ${i + 1}: ${_classStats[i].className} - ${_classStats[i].averageProgress} (${(_classStats[i].averageProgress * 100).toInt()}%)',
);
}
print('Total progress sum: $totalProgress');
print('Calculated average: $average (${(average * 100).toInt()}%)');
print('=== END UI PROGRESS DEBUG ===');
return average;
}
Widget build(BuildContext context) {
super.build(context);
if (_loading) {
return Container(
margin: const EdgeInsets.only(bottom: 24),
child: const Center(child: CircularProgressIndicator()),
);
}
return Container(
margin: const EdgeInsets.only(bottom: 24),
child: Column(
@@ -35,8 +149,8 @@ class TeacherHeroWidget extends StatelessWidget {
Text(
'Visão Geral da Turma',
style: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
@@ -44,27 +158,26 @@ class TeacherHeroWidget extends StatelessWidget {
Text(
'Acompanhe o progresso dos seus alunos',
style: TextStyle(
color: const Color(0xFF718096),
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D),
color: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 16,
),
const Icon(Icons.people, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
'$totalStudents alunos',
@@ -89,14 +202,14 @@ class TeacherHeroWidget extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF82C9BD),
const Color(0xFF6BA8A0),
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primary.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Theme.of(context).colorScheme.shadow.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
@@ -105,77 +218,31 @@ class TeacherHeroWidget extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Overall Progress
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Progresso Médio da Turma',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'${(classAverageProgress * 100).toInt()}%',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
// Progress Bar
Container(
height: 12,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(6),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: classAverageProgress,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Colors.white, Color(0xFFF8F9FA)],
),
borderRadius: BorderRadius.circular(6),
),
),
),
),
const SizedBox(height: 20),
// Stats Grid
Row(
children: [
Expanded(
child: _buildStatCard(
icon: Icons.quiz,
value: '$activeQuizzes',
label: 'Quizzes Ativos',
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _buildStatCard(
icon: Icons.quiz,
value: '$activeQuizzes',
label: 'Quizzes Ativos',
),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
icon: Icons.upload_file,
value: '$uploadedContent',
label: 'Conteúdos',
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
icon: Icons.upload_file,
value: '$uploadedContent',
label: 'Conteúdos',
),
),
),
],
],
),
),
],
),
).animate().scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
),
const SizedBox(height: 20),
@@ -184,12 +251,14 @@ class TeacherHeroWidget extends StatelessWidget {
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Theme.of(context).colorScheme.shadow.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -200,16 +269,16 @@ class TeacherHeroWidget extends StatelessWidget {
children: [
Row(
children: [
const Icon(
Icon(
Icons.trending_up,
color: Color(0xFFF68D2D),
color: Theme.of(context).colorScheme.secondary,
size: 20,
),
const SizedBox(width: 8),
const Text(
Text(
'Atividade Recente',
style: TextStyle(
color: Color(0xFF2D3748),
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -217,29 +286,10 @@ class TeacherHeroWidget extends StatelessWidget {
],
),
const SizedBox(height: 12),
_buildActivityItem(
'15 alunos completaram o quiz de Derivadas',
'Hoje, 14:30',
const Color(0xFF82C9BD),
),
const SizedBox(height: 8),
_buildActivityItem(
'Novo conteúdo: Regra da Cadeia',
'Ontem, 09:15',
const Color(0xFFF68D2D),
),
const SizedBox(height: 8),
_buildActivityItem(
'3 alunos precisam de apoio em Limites',
'Ontem, 16:45',
const Color(0xFFE53E3E),
),
..._buildRecentActivities(),
],
),
).animate().slideX(
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
).then(delay: const Duration(milliseconds: 200)),
),
],
),
);
@@ -255,10 +305,7 @@ class TeacherHeroWidget extends StatelessWidget {
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
border: Border.all(color: Colors.white.withOpacity(0.3), width: 1),
),
child: Column(
children: [
@@ -275,27 +322,99 @@ class TeacherHeroWidget extends StatelessWidget {
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
style: const TextStyle(color: Colors.white, fontSize: 11),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildActivityItem(String text, String time, Color color) {
List<Widget> _buildRecentActivities() {
final activities = <Widget>[];
if (_classStats.isEmpty) {
activities.add(
_buildActivityItem(
context,
'Nenhuma atividade recente',
'Comece criando turmas e conteúdos',
Theme.of(context).colorScheme.onSurfaceVariant,
),
);
return activities;
}
// Adicionar atividades baseadas nas estatísticas das turmas
for (final stats in _classStats.take(3)) {
if (stats.activeQuizzes > 0) {
activities.add(
_buildActivityItem(
context,
'${stats.activeQuizzes} quizzes ativos em ${stats.className}',
'Recente',
Theme.of(context).colorScheme.primary,
),
);
activities.add(const SizedBox(height: 8));
}
if (stats.studentsNeedingSupport.isNotEmpty) {
activities.add(
_buildActivityItem(
context,
'${stats.studentsNeedingSupport.length} alunos precisam de apoio em ${stats.className}',
'Ver analytics',
Theme.of(context).colorScheme.error,
),
);
activities.add(const SizedBox(height: 8));
}
if (stats.totalContent > 0) {
activities.add(
_buildActivityItem(
context,
'${stats.totalContent} conteúdos disponíveis em ${stats.className}',
'Atualizado',
Theme.of(context).colorScheme.secondary,
),
);
activities.add(const SizedBox(height: 8));
}
}
// Remover o último SizedBox se existir
if (activities.isNotEmpty && activities.last is SizedBox) {
activities.removeLast();
}
return activities.isEmpty
? [
_buildActivityItem(
context,
'Nenhuma atividade recente',
'Comece criando turmas e conteúdos',
Theme.of(context).colorScheme.onSurfaceVariant,
),
]
: activities;
}
Widget _buildActivityItem(
BuildContext context,
String text,
String time,
Color color,
) {
return Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 12),
Expanded(
@@ -304,16 +423,18 @@ class TeacherHeroWidget extends StatelessWidget {
children: [
Text(
text,
style: const TextStyle(
color: Color(0xFF4A5568),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
time,
style: const TextStyle(
color: Color(0xFF718096),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 12,
),
),

View File

@@ -1,376 +1,619 @@
import 'dart:math';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
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';
import 'dashboard_action_card.dart';
/// Quick access cards for teacher actions
class TeacherQuickActionsWidget extends StatelessWidget {
class TeacherQuickActionsWidget extends StatefulWidget {
const TeacherQuickActionsWidget({super.key});
@override
State<TeacherQuickActionsWidget> createState() =>
_TeacherQuickActionsWidgetState();
}
class _TeacherQuickActionsWidgetState extends State<TeacherQuickActionsWidget> {
bool _isCreatingClass = false;
/// Mesmas dimensões dos cards em "As Minhas Turmas".
static const double _scrollCardWidth = 200;
static const double _scrollRowHeight = 156;
static const double _cardMinHeight = 156;
static const EdgeInsets _cardPadding = EdgeInsets.all(16);
static const double _titleFontSize = 16;
static const double _subtitleFontSize = 13;
static const double _iconSize = 24;
static const double _iconPadding = 10;
@override
Widget build(BuildContext context) {
final cards = [
_buildUploadContentCard(context),
_buildCreateClassCard(context),
_buildCreateQuizCard(context),
_buildViewAnalyticsCard(context),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ações Rápidas',
style: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 20,
fontWeight: FontWeight.bold,
InkWell(
onTap: () => _showQuickActionsList(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Ações Rápidas',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_more,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 22,
),
],
),
),
),
const SizedBox(height: 16),
// Primary Actions Row
Row(
children: [
// Upload Content Card (Primary)
Expanded(flex: 3, child: _buildUploadContentCard(context)),
const SizedBox(width: 16),
// Create Quiz Card (Secondary)
Expanded(flex: 2, child: _buildCreateQuizCard(context)),
],
),
const SizedBox(height: 16),
// Secondary Actions Row
Row(
children: [
// Manage Students Card
Expanded(child: _buildManageStudentsCard(context)),
const SizedBox(width: 16),
// View Analytics Card
Expanded(child: _buildViewAnalyticsCard(context)),
],
const SizedBox(height: 12),
SizedBox(
height: _scrollRowHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.only(right: 16),
itemCount: cards.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
SizedBox(width: _scrollCardWidth, child: cards[index]),
),
),
],
)
.animate()
.slideY(
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 200));
);
}
Widget _buildUploadContentCard(BuildContext context) {
return Container(
height: 150,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF82C9BD), Color(0xFF6BA8A0)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF82C9BD).withOpacity(0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.go('/teacher/upload'),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.upload_file,
color: Colors.white,
size: 24,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'NOVO',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Upload Conteúdo',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'PDFs, textos, imagens',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.2,
),
),
],
),
],
),
),
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: DashboardActionCard(
title: 'Gerenciamento de Conteúdo',
subtitle: 'PDFs, textos, imagens',
icon: Icons.upload_file,
useGradient: true,
minHeight: _cardMinHeight,
iconSize: _iconSize,
iconPadding: _iconPadding,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
padding: _cardPadding,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const TeacherMaterialsPage()),
),
),
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
)
.then(delay: const Duration(milliseconds: 100));
);
}
Widget _buildCreateQuizCard(BuildContext context) {
return Container(
height: 150,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: DashboardActionCardSurface(
title: 'Criar Quiz',
subtitle: 'Avaliações interativas',
icon: Icons.quiz,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
onTap: () => context.go('/teacher/quiz/create'),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.go('/teacher/quiz/create'),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFF68D2D).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.quiz,
color: Color(0xFFF68D2D),
size: 24,
),
),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Criar Quiz',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
'Avaliações',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0xFF718096),
fontSize: 12,
height: 1.2,
),
),
],
),
],
),
),
),
),
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
)
.then(delay: const Duration(milliseconds: 200));
}
Widget _buildManageStudentsCard(BuildContext context) {
return Container(
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.go('/teacher/students'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF82C9BD).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.people,
color: Color(0xFF82C9BD),
size: 20,
),
),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gerir Alunos',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
'Acesso e permissões',
style: TextStyle(
color: Color(0xFF718096),
fontSize: 11,
),
),
],
),
],
),
),
),
),
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
)
.then(delay: const Duration(milliseconds: 300));
);
}
Widget _buildViewAnalyticsCard(BuildContext context) {
return Container(
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
final cs = Theme.of(context).colorScheme;
return DashboardActionCardSurface(
title: 'Analytics',
subtitle: 'Desempenho da turma',
icon: Icons.analytics,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
iconColor: cs.primary,
leadingWidget: Container(
padding: const EdgeInsets.all(_iconPadding),
decoration: BoxDecoration(
color: cs.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.analytics, color: Colors.blue, size: 28),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.go('/teacher/analytics'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF6BA8A0).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.analytics,
color: Color(0xFF6BA8A0),
size: 20,
),
),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Analytics',
style: TextStyle(
color: Color(0xFF2D3748),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
'Desempenho da turma',
style: TextStyle(
color: Color(0xFF718096),
fontSize: 11,
),
),
],
),
],
),
onTap: () => context.go('/teacher/analytics'),
);
}
Widget _buildCreateClassCard(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: DashboardActionCardSurface(
title: 'Criar Turma',
subtitle: 'Gerar código de acesso',
icon: Icons.school,
minHeight: _cardMinHeight,
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
iconSize: _iconSize,
padding: _cardPadding,
iconColor: cs.primary,
onTapDisabled: _isCreatingClass,
onTap: () => _showCreateClassDialog(context),
leadingWidget: Container(
padding: const EdgeInsets.all(_iconPadding),
decoration: BoxDecoration(
color: cs.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: _isCreatingClass
? SizedBox(
width: _iconSize,
height: _iconSize,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
)
: Icon(Icons.school, color: cs.primary, size: _iconSize),
),
),
)
.animate()
.scale(
duration: const Duration(milliseconds: 600),
curve: Curves.elasticOut,
)
.then(delay: const Duration(milliseconds: 400));
);
}
void _showQuickActionsList(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final items = [
_TeacherActionItem(
title: 'Gerenciamento de Conteúdo',
subtitle: 'PDFs, textos, imagens',
icon: Icons.upload_file,
useGradient: true,
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const TeacherMaterialsPage()),
);
},
),
_TeacherActionItem(
title: 'Criar Turma',
subtitle: 'Gerar código de acesso',
icon: Icons.school,
onTap: () {
Navigator.pop(context);
_showCreateClassDialog(context);
},
),
_TeacherActionItem(
title: 'Criar Quiz',
subtitle: 'Avaliações interativas',
icon: Icons.quiz,
onTap: () {
Navigator.pop(context);
context.go('/teacher/quiz/create');
},
),
_TeacherActionItem(
title: 'Analytics',
subtitle: 'Desempenho da turma',
icon: Icons.analytics,
onTap: () {
Navigator.pop(context);
context.go('/teacher/analytics');
},
),
];
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'Ações Rápidas',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: cs.outline.withOpacity(0.2)),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: item.useGradient
? cs.primary.withOpacity(0.1)
: cs.secondary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: item.useGradient ? cs.primary : cs.secondary,
size: 24,
),
),
title: Text(
item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
subtitle: Text(
item.subtitle,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
trailing: Icon(
Icons.arrow_forward_ios,
color: cs.primary,
size: 16,
),
onTap: item.onTap,
),
);
},
),
),
const SizedBox(height: 20),
],
);
},
),
);
}
void _showCreateClassDialog(BuildContext context) {
final TextEditingController nameController = TextEditingController();
String? selectedSchoolClassId;
List<Map<String, String>> schoolClasses = [];
bool isLoadingClasses = true;
// Carregar school_classes antes de mostrar o dialog
FirebaseFirestore.instance
.collection('school_classes')
.where('active', isEqualTo: true)
.orderBy('year')
.orderBy('section')
.get()
.then((snapshot) {
schoolClasses = snapshot.docs.map((doc) {
final data = doc.data();
return {'id': doc.id, 'name': (data['name'] as String? ?? doc.id)};
}).toList();
isLoadingClasses = false;
})
.catchError((_) {
isLoadingClasses = false;
});
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
// Atualizar estado do dialog quando as classes carregarem
if (isLoadingClasses) {
Future.delayed(const Duration(milliseconds: 300), () {
if (dialogContext.mounted) setDialogState(() {});
});
}
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Text(
'Criar Nova Turma',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nome da turma:',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(height: 12),
TextField(
controller: nameController,
decoration: InputDecoration(
hintText: 'Ex: Matemática, Física...',
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
),
),
const SizedBox(height: 16),
Text(
'Ano letivo:',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(height: 8),
isLoadingClasses
? Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 10),
Text(
'A carregar turmas...',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
)
: DropdownButtonFormField<String>(
value: selectedSchoolClassId,
isExpanded: true,
decoration: InputDecoration(
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
hint: Text(
'Seleciona o ano letivo',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
items: schoolClasses
.map(
(c) => DropdownMenuItem<String>(
value: c['id'],
child: Text(c['name']!),
),
)
.toList(),
onChanged: (value) => setDialogState(
() => selectedSchoolClassId = value,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(
'Cancelar',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
ElevatedButton(
onPressed: () {
final className = nameController.text.trim();
if (className.isNotEmpty) {
Navigator.of(dialogContext).pop();
_createClass(
className,
schoolClassId: selectedSchoolClassId,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Criar'),
),
],
);
},
);
},
);
}
String _generateClassCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final random = Random();
return String.fromCharCodes(
Iterable.generate(
6,
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
),
);
}
Future<void> _createClass(String className, {String? schoolClassId}) async {
setState(() {
_isCreatingClass = true;
});
try {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
throw Exception('Utilizador não autenticado');
}
final classCode = _generateClassCode();
final firestore = FirebaseFirestore.instance;
final Map<String, dynamic> classData = {
'name': className,
'teacherId': currentUser.uid,
'code': classCode,
'createdAt': FieldValue.serverTimestamp(),
};
if (schoolClassId != null && schoolClassId.isNotEmpty) {
classData['schoolClassId'] = schoolClassId;
}
await firestore.collection('classes').add(classData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Turma "$className" criada com sucesso! Código: $classCode',
),
backgroundColor: Theme.of(context).colorScheme.primary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao criar turma: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} finally {
setState(() {
_isCreatingClass = false;
});
}
}
}
class _TeacherActionItem {
final String title;
final String subtitle;
final IconData icon;
final bool useGradient;
final VoidCallback onTap;
_TeacherActionItem({
required this.title,
required this.subtitle,
required this.icon,
this.useGradient = false,
required this.onTap,
});
}

Some files were not shown because too many files have changed in this diff Show More