Compare commits
47 Commits
9faab9b74e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d7ae8afc | |||
| 3533d3436b | |||
| 0b5bf8fba7 | |||
| 3d3747d3a2 | |||
| c2fd663170 | |||
| 1a98fff5e8 | |||
| 43018c753c | |||
| 90837cc82b | |||
| 39637b2a62 | |||
| b3f6a5a0f0 | |||
| f1a094979f | |||
| 895ce64c6f | |||
| 7ee262f4c7 | |||
| 5bda59f7af | |||
| 2f411d08a4 | |||
| 98dcd621c7 | |||
| 80ed2b1346 | |||
| 54d7042b94 | |||
| 8043ee42fe | |||
| 7f12f3eb1f | |||
| c0ade9ef76 | |||
| 9b53eb06b6 | |||
| ad825f47d7 | |||
| 4a5209b239 | |||
| 058bbaaea2 | |||
| c979692fd9 | |||
| 2a2194699b | |||
| e388ca3b67 | |||
| 7a26223a01 | |||
| ba58228467 | |||
| 49a7a6fe02 | |||
| 6ba5c837ce | |||
| 5649f7d96a | |||
| 51ea446ae9 | |||
| 14509c04d3 | |||
| 27263e86ba | |||
| 3463b1f6cc | |||
| 728368b040 | |||
| 321df8bb1d | |||
| f8e3a7686f | |||
| 47aaa163fb | |||
| ba4bb7de88 | |||
| 2775205f9e | |||
| 62b9a107bc | |||
| 55ec2521cf | |||
| ad400a9c37 | |||
| b7988eb608 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 48 KiB |
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
4
android/app/src/main/res/values/colors.xml
Normal 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
|
After Width: | Height: | Size: 84 KiB |
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
@@ -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 │
|
||||
│ QUIZZES │ CONVERSATIONS │ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -7,6 +7,106 @@
|
||||
## [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
|
||||
@@ -28,6 +128,29 @@
|
||||
- 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
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,153 @@ 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
|
||||
@@ -529,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
|
||||
@@ -611,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**
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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++";
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1019 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
||||
318
lib/core/models/achievement.dart
Normal 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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
199
lib/core/models/class_stats.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
151
lib/core/models/user_stats.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
lib/core/providers/theme_provider.dart
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
487
lib/core/services/chat_memory_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
902
lib/core/services/gamification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
684
lib/core/services/materials_rag_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
95
lib/core/services/theme_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
106
lib/core/theme/app_theme_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
873
lib/features/ai_tutor/presentation/pages/chat_history_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>[],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
468
lib/features/analytics/presentation/pages/analytics_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
986
lib/features/classes/presentation/pages/class_students_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
631
lib/features/classes/presentation/pages/join_class_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -1,31 +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_classes_list_widget.dart';
|
||||
import '../widgets/teacher_analytics_preview_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 {
|
||||
@@ -59,6 +93,7 @@ class _TeacherDashboardPageState extends State<TeacherDashboardPage> {
|
||||
print('DEBUG: Final displayName to use: "$displayName"');
|
||||
setState(() {
|
||||
_userName = displayName;
|
||||
TeacherDashboardPage._cachedUserName = displayName;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,89 +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),
|
||||
|
||||
// Classes List Section
|
||||
const TeacherClassesListWidget(),
|
||||
// Classes List Section
|
||||
const TeacherClassesListWidget(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Analytics Preview Section
|
||||
const TeacherAnalyticsPreviewWidget(),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -23,11 +24,11 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
.snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF82C9BD),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -45,7 +46,7 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
child: Text(
|
||||
'Ainda não criaste nenhuma turma.',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
@@ -55,12 +56,31 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'As Minhas Turmas',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: const Color(0xFF2D3748),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
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(
|
||||
@@ -77,10 +97,10 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildClassCard(classes[firstIndex]),
|
||||
_buildClassCard(classes[firstIndex], context),
|
||||
const SizedBox(height: 12),
|
||||
if (secondIndex < classes.length)
|
||||
_buildClassCard(classes[secondIndex]),
|
||||
_buildClassCard(classes[secondIndex], context),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -93,62 +113,187 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClassCard(DocumentSnapshot doc) {
|
||||
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 Container(
|
||||
width: 200,
|
||||
height: 150,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ClassStudentsPage(classId: classId, className: className),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
);
|
||||
},
|
||||
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: const Icon(
|
||||
Icons.school,
|
||||
color: Color(0xFF82C9BD),
|
||||
size: 24,
|
||||
],
|
||||
),
|
||||
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: const TextStyle(
|
||||
color: Color(0xFF2D3748),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
className,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Código: $classCode',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 13,
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Código: $classCode',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||