placeholders removidos e todos os dados reais colocados, com conquistas e tudo
This commit is contained in:
334
lib/core/models/achievement.dart
Normal file
334
lib/core/models/achievement.dart
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
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: '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: '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(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
195
lib/core/models/class_stats.dart
Normal file
195
lib/core/models/class_stats.dart
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import '../../features/quiz/presentation/pages/teacher_quiz_page.dart';
|
|||||||
import '../../features/profile/presentation/pages/profile_page.dart';
|
import '../../features/profile/presentation/pages/profile_page.dart';
|
||||||
import '../../features/splash/presentation/pages/splash_page.dart';
|
import '../../features/splash/presentation/pages/splash_page.dart';
|
||||||
import '../../features/auth/presentation/pages/role_selection_page.dart';
|
import '../../features/auth/presentation/pages/role_selection_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
|
/// App Router Configuration
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
@@ -29,6 +32,9 @@ class AppRouter {
|
|||||||
static const String quiz = '/quiz/:quizId';
|
static const String quiz = '/quiz/:quizId';
|
||||||
static const String profile = '/profile';
|
static const String profile = '/profile';
|
||||||
static const String settings = '/settings';
|
static const String settings = '/settings';
|
||||||
|
static const String teacherAnalytics = '/teacher/analytics';
|
||||||
|
static const String studentAchievements = '/student/achievements';
|
||||||
|
static const String quizManagement = '/quiz-management';
|
||||||
|
|
||||||
// Nested route paths (without leading slash)
|
// Nested route paths (without leading slash)
|
||||||
static const String tutorNested = 'tutor';
|
static const String tutorNested = 'tutor';
|
||||||
@@ -141,6 +147,27 @@ class AppRouter {
|
|||||||
builder: (context, state) => const TutorChatPageSimple(),
|
builder: (context, state) => const TutorChatPageSimple(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 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)
|
// Quiz List Route (independent — student access)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: quizList,
|
path: quizList,
|
||||||
|
|||||||
734
lib/core/services/gamification_service.dart
Normal file
734
lib/core/services/gamification_service.dart
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
// Registrar tempo de estudo (estimado)
|
||||||
|
await recordStudyTime(userId, 15); // 15 minutos por quiz
|
||||||
|
|
||||||
|
// 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) async {
|
||||||
|
try {
|
||||||
|
final classStatsDoc = await _firestore.collection('classStats').doc(classId).get();
|
||||||
|
if (!classStatsDoc.exists) {
|
||||||
|
return await _calculateClassStats(classId);
|
||||||
|
}
|
||||||
|
return ClassStats.fromFirestore(classStatsDoc.data()!, classId);
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Error getting class stats: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>[];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Usar contador real de quizzes completos
|
||||||
|
final completedQuizzes = userStats.completedQuizzes;
|
||||||
|
final totalQuizzes = completedQuizzes + 5; // Estimativa de quizzes disponíveis
|
||||||
|
final quizCompletionRate = totalQuizzes > 0 ? completedQuizzes / totalQuizzes : 0.0;
|
||||||
|
|
||||||
|
// Calcular score geral baseado em múltiplos fatores
|
||||||
|
final overallScore = _calculateOverallScore(userStats, quizCompletionRate);
|
||||||
|
|
||||||
|
rankings.add(StudentRanking(
|
||||||
|
studentId: studentId,
|
||||||
|
studentName: userData?['displayName'] ?? 'Aluno $studentId',
|
||||||
|
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) {
|
||||||
|
double score = 0.0;
|
||||||
|
|
||||||
|
// Peso de 40% para completion rate de quizzes
|
||||||
|
score += quizCompletionRate * 40;
|
||||||
|
|
||||||
|
// Peso de 20% para streak (máximo 20 pontos)
|
||||||
|
score += (userStats.currentStreak / 30.0 * 20).clamp(0.0, 20.0);
|
||||||
|
|
||||||
|
// Peso de 20% para tempo de estudo (máximo 20 pontos para 10 horas)
|
||||||
|
score += (userStats.totalStudyTime / 600.0 * 20).clamp(0.0, 20.0);
|
||||||
|
|
||||||
|
// Peso de 20% para conceitos dominados (máximo 20 pontos para 10 conceitos)
|
||||||
|
score += (userStats.masteredConcepts.length / 10.0 * 20).clamp(0.0, 20.0);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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étodo público para inicializar dados de gamificação (para testes)
|
||||||
|
static Future<void> initializeGamificationData(String userId) async {
|
||||||
|
try {
|
||||||
|
final userStats = await getUserStats(userId);
|
||||||
|
if (userStats == null) {
|
||||||
|
await _createInitialUserStats(userId);
|
||||||
|
Logger.info('Gamification data initialized for user $userId');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Error initializing gamification data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Método público para simular quiz completion (para testes)
|
||||||
|
static Future<void> simulateQuizCompletion(String userId, {
|
||||||
|
required int score,
|
||||||
|
required int totalQuestions,
|
||||||
|
required String materialName,
|
||||||
|
}) async {
|
||||||
|
Logger.info('=== SIMULATING QUIZ COMPLETION ===');
|
||||||
|
Logger.info('User: $userId');
|
||||||
|
Logger.info('Score: $score/$totalQuestions');
|
||||||
|
Logger.info('Material: $materialName');
|
||||||
|
|
||||||
|
await recordQuizActivity(
|
||||||
|
userId,
|
||||||
|
score: score,
|
||||||
|
totalQuestions: totalQuestions,
|
||||||
|
materialName: materialName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verificar estado após simulação
|
||||||
|
await debugUserStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Método de debugging para verificar estado completo do usuário
|
||||||
|
static Future<void> debugUserStats(String userId) async {
|
||||||
|
try {
|
||||||
|
Logger.info('=== DEBUGGING USER STATS ===');
|
||||||
|
|
||||||
|
final userStats = await getUserStats(userId);
|
||||||
|
if (userStats == null) {
|
||||||
|
Logger.error('User stats not found for $userId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info('Current Streak: ${userStats.currentStreak}');
|
||||||
|
Logger.info('Longest Streak: ${userStats.longestStreak}');
|
||||||
|
Logger.info('Total Study Time: ${userStats.totalStudyTime}');
|
||||||
|
Logger.info('Weekly Study Time: ${userStats.weeklyStudyTime}');
|
||||||
|
Logger.info('Monthly Study Time: ${userStats.monthlyStudyTime}');
|
||||||
|
Logger.info('Mastered Concepts: ${userStats.masteredConcepts.length}');
|
||||||
|
Logger.info('Unlocked Achievements: ${userStats.unlockedAchievements.length}');
|
||||||
|
|
||||||
|
for (final concept in userStats.masteredConcepts) {
|
||||||
|
Logger.info(' - Concept: ${concept.conceptName}, Level: ${concept.masteryLevel}');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final achievement in userStats.unlockedAchievements) {
|
||||||
|
Logger.info(' - Achievement: ${achievement.achievementId}, Unlocked: ${achievement.unlockedAt}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info('=== END DEBUG ===');
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Error debugging user stats: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
if (userStats != null) {
|
||||||
|
// Verificar se está ativo (atividade nos últimos 7 dias)
|
||||||
|
int daysSinceLastActivity = 999; // Valor alto para inatividade
|
||||||
|
if (userStats.lastActivityDate != null) {
|
||||||
|
daysSinceLastActivity = DateTime.now().difference(userStats.lastActivityDate!).inDays;
|
||||||
|
if (daysSinceLastActivity <= 7) {
|
||||||
|
activeStudents++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular progresso baseado em conceitos dominados
|
||||||
|
final progress = userStats.masteredConcepts.isEmpty
|
||||||
|
? 0.0
|
||||||
|
: userStats.masteredConcepts.map((c) => c.masteryLevel).reduce((a, b) => a + b) / userStats.masteredConcepts.length / 100;
|
||||||
|
totalProgress += progress;
|
||||||
|
|
||||||
|
// Verificar se precisa de apoio
|
||||||
|
if (progress < 0.5 || daysSinceLastActivity > 14) {
|
||||||
|
final userDoc = await _firestore.collection('users').doc(studentId).get();
|
||||||
|
final userName = userDoc.data()?['displayName'] ?? 'Unknown';
|
||||||
|
|
||||||
|
needingSupport.add(StudentNeedingSupport(
|
||||||
|
studentId: studentId,
|
||||||
|
studentName: userName,
|
||||||
|
reason: progress < 0.5 ? 'low_scores' : 'inactivity',
|
||||||
|
lastActivity: userStats.lastActivityDate ?? DateTime.now(),
|
||||||
|
averageScore: progress * 100,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final averageProgress = studentIds.isEmpty ? 0.0 : totalProgress / studentIds.length;
|
||||||
|
|
||||||
|
// Obter estatísticas de quizzes
|
||||||
|
final quizzesSnapshot = await _firestore
|
||||||
|
.collection('teacherQuizzes')
|
||||||
|
.where('classIds', arrayContains: classId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final classStats = ClassStats(
|
||||||
|
classId: classId,
|
||||||
|
teacherId: teacherId,
|
||||||
|
className: className,
|
||||||
|
totalStudents: studentIds.length,
|
||||||
|
activeStudents: activeStudents,
|
||||||
|
averageProgress: averageProgress,
|
||||||
|
totalQuizzes: quizzesSnapshot.docs.length,
|
||||||
|
activeQuizzes: quizzesSnapshot.docs.length, // Simplificado
|
||||||
|
totalContent: 0, // Implementar depois
|
||||||
|
weeklyStats: [],
|
||||||
|
studentsNeedingSupport: needingSupport,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Salvar estatísticas calculadas
|
||||||
|
await _firestore.collection('classStats').doc(classId).set(classStats.toFirestore());
|
||||||
|
|
||||||
|
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.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 ===');
|
||||||
|
// Verificar conquistas genéricas de número de quizzes
|
||||||
|
await _checkQuizCountAchievements(userId, completedQuizzes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _checkQuizCountAchievements(String userId, int completedQuizzes) async {
|
||||||
|
final achievements = await getAvailableAchievements();
|
||||||
|
final quizCountAchievements = achievements.where((a) => a.category == 'quiz_count');
|
||||||
|
|
||||||
|
for (final achievement in quizCountAchievements) {
|
||||||
|
if (achievement.requirements.type == 'quiz_completion' &&
|
||||||
|
achievement.requirements.checkCondition(completedQuizzes)) {
|
||||||
|
await _unlockAchievement(userId, achievement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
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,
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
cs.primary.withValues(alpha: 0.05),
|
||||||
|
cs.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => context.go('/student-dashboard'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Minhas Conquistas',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_unlockedAchievements.length}/${_allAchievements.length} desbloqueadas',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [cs.primary, cs.primary.withValues(alpha: 0.8)],
|
||||||
|
),
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Debug buttons (remover em produção)
|
||||||
|
if (!const bool.fromEnvironment('dart.vm.product')) ...[
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
await GamificationService.initializeGamificationData(user.uid);
|
||||||
|
_loadAchievements();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Inicializar Dados'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
await GamificationService.simulateQuizCompletion(
|
||||||
|
user.uid,
|
||||||
|
score: 8,
|
||||||
|
totalQuestions: 10,
|
||||||
|
materialName: 'Matemática Básica',
|
||||||
|
);
|
||||||
|
_loadAchievements();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Simular Quiz'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
await GamificationService.debugUserStats(user.uid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
|
||||||
|
child: const Text('Debug Stats'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
.slideX(duration: const Duration(milliseconds: 300))
|
||||||
|
.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()
|
||||||
|
.slideX(duration: const Duration(milliseconds: 300))
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
lib/features/analytics/presentation/pages/analytics_page.dart
Normal file
343
lib/features/analytics/presentation/pages/analytics_page.dart
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
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_ranking_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;
|
||||||
|
List<ClassStats> _classStats = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _selectedClassId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_loadClassStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.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;
|
||||||
|
final stats = await GamificationService.getClassStats(classId);
|
||||||
|
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);
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: themeExtras.dashboardBackgroundGradient,
|
||||||
|
stops: themeExtras.dashboardGradientStops,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
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: 'Rankings'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_buildClassesTab(),
|
||||||
|
_buildRankingsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Overview Cards
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildOverviewCard(
|
||||||
|
'Total de Alunos',
|
||||||
|
'${_classStats.fold(0, (sum, stats) => sum + stats.totalStudents)}',
|
||||||
|
Icons.people,
|
||||||
|
Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildOverviewCard(
|
||||||
|
'Alunos Ativos',
|
||||||
|
'${_classStats.fold(0, (sum, stats) => sum + stats.activeStudents)}',
|
||||||
|
Icons.trending_up,
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Class Cards
|
||||||
|
..._classStats.map((stats) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: ClassAnalyticsCard(
|
||||||
|
classStats: stats,
|
||||||
|
onTap: () => _showClassRanking(stats),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRankingsTab() {
|
||||||
|
if (_selectedClassId == null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.leaderboard,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Selecione uma turma',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Clique em uma turma na aba "Turmas" para ver o ranking',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClassRankingWidget(classId: _selectedClassId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _showClassRanking(ClassStats stats) {
|
||||||
|
setState(() {
|
||||||
|
_selectedClassId = stats.classId;
|
||||||
|
});
|
||||||
|
_tabController.animateTo(1); // Mudar para aba de rankings
|
||||||
|
}
|
||||||
|
|
||||||
|
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,317 @@
|
|||||||
|
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 ativos',
|
||||||
|
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 ranking detalhado',
|
||||||
|
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,432 @@
|
|||||||
|
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,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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,75 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../../core/theme/app_theme_extension.dart';
|
import '../../../../core/theme/app_theme_extension.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import '../../../../core/services/auth_service.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
|
/// Profile section with user info and achievements
|
||||||
class ProfileSectionWidget extends StatelessWidget {
|
class ProfileSectionWidget extends StatefulWidget {
|
||||||
const ProfileSectionWidget({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
final user = AuthService.currentUser;
|
final user = AuthService.currentUser;
|
||||||
final userName = user?.displayName ?? 'Estudante';
|
final userName = user?.displayName ?? 'Estudante';
|
||||||
final userEmail = user?.email ?? '';
|
final userEmail = user?.email ?? '';
|
||||||
@@ -124,36 +186,55 @@ class ProfileSectionWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Achievement Badges
|
// Achievement List (Teacher-style design)
|
||||||
Row(
|
if (_recentAchievements.isNotEmpty) ...[
|
||||||
children: [
|
..._recentAchievements.map((achievement) {
|
||||||
_buildAchievementBadge(
|
return Padding(
|
||||||
icon: Icons.local_fire_department,
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
label: '7 dias',
|
child: _buildAchievementItem(
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_buildAchievementBadge(
|
|
||||||
icon: Icons.school,
|
|
||||||
label: '3 conceitos',
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_buildAchievementBadge(
|
|
||||||
icon: Icons.speed,
|
|
||||||
label: 'Rápido',
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary.withOpacity(0.8),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: Theme.of(context).colorScheme.tertiary,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Recent Activity Summary
|
// Recent Activity Summary
|
||||||
@@ -183,7 +264,7 @@ class ProfileSectionWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Ótimo progresso!',
|
_getProgressMessage(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -192,7 +273,7 @@ class ProfileSectionWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Você está 15% acima da média esta semana',
|
_getProgressComparison(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -217,32 +298,103 @@ class ProfileSectionWidget extends StatelessWidget {
|
|||||||
.then(delay: const Duration(milliseconds: 400));
|
.then(delay: const Duration(milliseconds: 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAchievementBadge({
|
Widget _buildAchievementItem(
|
||||||
required IconData icon,
|
BuildContext context,
|
||||||
required String label,
|
String name,
|
||||||
required Color color,
|
int points,
|
||||||
}) {
|
Color color,
|
||||||
return Container(
|
IconData icon,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
) {
|
||||||
decoration: BoxDecoration(
|
return Row(
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: color.withOpacity(0.3), width: 1),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color, size: 16),
|
Container(
|
||||||
const SizedBox(height: 4),
|
width: 32,
|
||||||
Text(
|
height: 32,
|
||||||
label,
|
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(
|
style: TextStyle(
|
||||||
color: color,
|
color: color,
|
||||||
fontSize: 10,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
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!';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,81 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
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';
|
||||||
|
|
||||||
/// Progress tracking hero section for student dashboard
|
/// Progress tracking hero section for student dashboard
|
||||||
class ProgressHeroWidget extends StatelessWidget {
|
class ProgressHeroWidget extends StatefulWidget {
|
||||||
final String userName;
|
final String userName;
|
||||||
final double overallProgress;
|
|
||||||
final List<String> masteredConcepts;
|
|
||||||
final int studyTimeMinutes;
|
|
||||||
final int streakDays;
|
|
||||||
|
|
||||||
const ProgressHeroWidget({
|
const ProgressHeroWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.userName,
|
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProgressHeroWidget> createState() => _ProgressHeroWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProgressHeroWidgetState extends State<ProgressHeroWidget> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<UserStats?>(
|
||||||
|
future: _loadUserStats(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return _buildLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _buildErrorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final userStats = snapshot.data;
|
||||||
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 24),
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -48,7 +99,7 @@ class ProgressHeroWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Continue assim, $userName!',
|
'Continue assim, ${widget.userName}!',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
IntrinsicHeight(
|
IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 2,
|
||||||
child: _buildTutorIACard(context),
|
child: _buildTutorIACard(context),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -35,9 +37,18 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
flex: 2,
|
flex: 2,
|
||||||
child: _buildQuizCard(context),
|
child: _buildQuizCard(context),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _buildAchievementsCard(context),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildQuizManagementCard(context),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildJoinClassCard(context),
|
_buildJoinClassCard(context),
|
||||||
],
|
],
|
||||||
@@ -85,6 +96,42 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
.then(delay: const Duration(milliseconds: 200));
|
.then(delay: const Duration(milliseconds: 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAchievementsCard(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return DashboardActionCardSurface(
|
||||||
|
title: 'Conquistas',
|
||||||
|
subtitle: 'Ver medals',
|
||||||
|
icon: Icons.emoji_events,
|
||||||
|
minHeight: 150,
|
||||||
|
iconColor: Colors.amber,
|
||||||
|
onTap: () => context.go('/student/achievements'),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.scale(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
)
|
||||||
|
.then(delay: const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuizManagementCard(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return DashboardActionCardSurface(
|
||||||
|
title: 'Gerenciar Quizzes',
|
||||||
|
subtitle: 'Ver histórico ou eliminar',
|
||||||
|
icon: Icons.manage_history,
|
||||||
|
minHeight: 80,
|
||||||
|
iconColor: cs.tertiary,
|
||||||
|
onTap: () => context.go('/quiz-management'),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.slideY(
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
)
|
||||||
|
.then(delay: const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildJoinClassCard(BuildContext context) {
|
Widget _buildJoinClassCard(BuildContext context) {
|
||||||
return DashboardActionCard(
|
return DashboardActionCard(
|
||||||
title: 'Entrar numa Turma',
|
title: 'Entrar numa Turma',
|
||||||
|
|||||||
@@ -1,15 +1,95 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
import '../../../../core/theme/app_theme_extension.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import '../../../../core/services/auth_service.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
|
/// Analytics preview section for teacher dashboard
|
||||||
class TeacherAnalyticsPreviewWidget extends StatelessWidget {
|
class TeacherAnalyticsPreviewWidget extends StatefulWidget {
|
||||||
const TeacherAnalyticsPreviewWidget({super.key});
|
const TeacherAnalyticsPreviewWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TeacherAnalyticsPreviewWidget> createState() => _TeacherAnalyticsPreviewWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherAnalyticsPreviewWidgetState extends State<TeacherAnalyticsPreviewWidget> {
|
||||||
|
List<ClassStats> _classStats = [];
|
||||||
|
List<StudentRanking> _topStudents = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAnalyticsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAnalyticsData() 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;
|
||||||
|
final stats = await GamificationService.getClassStats(classId);
|
||||||
|
if (stats != null) {
|
||||||
|
classStatsList.add(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter melhores alunos de todas as turmas
|
||||||
|
final allTopStudents = <StudentRanking>[];
|
||||||
|
for (final classStats in classStatsList) {
|
||||||
|
final ranking = await GamificationService.getClassRanking(classStats.classId);
|
||||||
|
allTopStudents.addAll(ranking.take(3)); // Top 3 de cada turma
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por score e pegar os melhores
|
||||||
|
allTopStudents.sort((a, b) => b.overallScore.compareTo(a.overallScore));
|
||||||
|
final topStudents = allTopStudents.take(4).toList();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_classStats = classStatsList;
|
||||||
|
_topStudents = topStudents;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading analytics data: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get totalStudents => _classStats.fold(0, (sum, stats) => sum + stats.totalStudents);
|
||||||
|
int get activeStudents => _classStats.fold(0, (sum, stats) => sum + stats.activeStudents);
|
||||||
|
double get averageProgress {
|
||||||
|
if (_classStats.isEmpty) return 0.0;
|
||||||
|
final totalProgress = _classStats.fold(0.0, (sum, stats) => sum + stats.averageProgress);
|
||||||
|
return totalProgress / _classStats.length;
|
||||||
|
}
|
||||||
|
int get studentsNeedingSupport => _classStats.fold(0, (sum, stats) => sum + stats.studentsNeedingSupport.length);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
final user = AuthService.currentUser;
|
final user = AuthService.currentUser;
|
||||||
final userName = user?.displayName ?? 'Professor';
|
final userName = user?.displayName ?? 'Professor';
|
||||||
final userEmail = user?.email ?? '';
|
final userEmail = user?.email ?? '';
|
||||||
@@ -109,24 +189,24 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
|
|||||||
_buildQuickStat(
|
_buildQuickStat(
|
||||||
icon: Icons.check_circle,
|
icon: Icons.check_circle,
|
||||||
label: 'Alunos Ativos',
|
label: 'Alunos Ativos',
|
||||||
value: '18/24',
|
value: '$activeStudents/$totalStudents',
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_buildQuickStat(
|
_buildQuickStat(
|
||||||
icon: Icons.warning_amber,
|
icon: Icons.warning_amber,
|
||||||
label: 'Precisam Apoio',
|
label: 'Precisam Apoio',
|
||||||
value: '3',
|
value: '$studentsNeedingSupport',
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_buildQuickStat(
|
_buildQuickStat(
|
||||||
icon: Icons.emoji_events,
|
icon: Icons.emoji_events,
|
||||||
label: 'Média Turma',
|
label: 'Média Turma',
|
||||||
value: '72%',
|
value: '${(averageProgress * 100).toInt()}%',
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary.withOpacity(0.8),
|
).colorScheme.primary.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -154,33 +234,17 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Student List Preview
|
// Student List Preview
|
||||||
_buildStudentPerformanceItem(
|
..._topStudents.map((student) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: _buildStudentPerformanceItem(
|
||||||
context,
|
context,
|
||||||
'Ana Silva',
|
student.studentName,
|
||||||
95,
|
student.overallScore.toInt(),
|
||||||
Theme.of(context).colorScheme.tertiary,
|
_getScoreColor(student.overallScore),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStudentPerformanceItem(
|
|
||||||
context,
|
|
||||||
'João Costa',
|
|
||||||
88,
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStudentPerformanceItem(
|
|
||||||
context,
|
|
||||||
'Maria Santos',
|
|
||||||
82,
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStudentPerformanceItem(
|
|
||||||
context,
|
|
||||||
'Pedro Lima',
|
|
||||||
45,
|
|
||||||
Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
@@ -218,7 +282,7 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'12 conteúdos verificados • 2 pendentes de revisão',
|
'${_classStats.fold(0, (sum, stats) => sum + stats.totalContent)} conteúdos • $studentsNeedingSupport alunos precisam de apoio',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -336,4 +400,11 @@ class TeacherAnalyticsPreviewWidget extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _getScoreColor(double score) {
|
||||||
|
if (score >= 80) return Theme.of(context).colorScheme.tertiary;
|
||||||
|
if (score >= 60) return Theme.of(context).colorScheme.primary;
|
||||||
|
if (score >= 40) return Theme.of(context).colorScheme.secondary;
|
||||||
|
return Theme.of(context).colorScheme.error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,89 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/app_theme_extension.dart';
|
import '../../../../core/theme/app_theme_extension.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
|
/// Hero section for teacher dashboard showing class overview
|
||||||
class TeacherHeroWidget extends StatelessWidget {
|
class TeacherHeroWidget extends StatefulWidget {
|
||||||
final String userName;
|
final String userName;
|
||||||
final int totalStudents;
|
|
||||||
final int activeQuizzes;
|
|
||||||
final int uploadedContent;
|
|
||||||
final double classAverageProgress;
|
|
||||||
|
|
||||||
const TeacherHeroWidget({
|
const TeacherHeroWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.userName,
|
required this.userName,
|
||||||
this.totalStudents = 24,
|
|
||||||
this.activeQuizzes = 3,
|
|
||||||
this.uploadedContent = 12,
|
|
||||||
this.classAverageProgress = 0.72,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
State<TeacherHeroWidget> createState() => _TeacherHeroWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherHeroWidgetState extends State<TeacherHeroWidget> {
|
||||||
|
List<ClassStats> _classStats = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadClassStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
final stats = await GamificationService.getClassStats(classId);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
double get classAverageProgress {
|
||||||
|
if (_classStats.isEmpty) return 0.0;
|
||||||
|
final totalProgress = _classStats.fold(0.0, (sum, stats) => sum + stats.averageProgress);
|
||||||
|
return totalProgress / _classStats.length;
|
||||||
|
}
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 24),
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -231,26 +293,7 @@ class TeacherHeroWidget extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildActivityItem(
|
..._buildRecentActivities(),
|
||||||
context,
|
|
||||||
'15 alunos completaram o quiz de Derivadas',
|
|
||||||
'Hoje, 14:30',
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildActivityItem(
|
|
||||||
context,
|
|
||||||
'Novo conteúdo: Regra da Cadeia',
|
|
||||||
'Ontem, 09:15',
|
|
||||||
Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildActivityItem(
|
|
||||||
context,
|
|
||||||
'3 alunos precisam de apoio em Limites',
|
|
||||||
'Ontem, 16:45',
|
|
||||||
Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -302,6 +345,67 @@ class TeacherHeroWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _buildActivityItem(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String text,
|
String text,
|
||||||
@@ -333,7 +437,7 @@ class TeacherHeroWidget extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import '../../../../core/services/materials_rag_service.dart';
|
import '../../../../core/services/materials_rag_service.dart';
|
||||||
import '../../../../core/services/rag_ai_service.dart';
|
import '../../../../core/services/rag_ai_service.dart';
|
||||||
|
import '../../../../core/services/gamification_service.dart';
|
||||||
import '../../../../core/utils/logger.dart';
|
import '../../../../core/utils/logger.dart';
|
||||||
|
|
||||||
class QuizListPage extends StatefulWidget {
|
class QuizListPage extends StatefulWidget {
|
||||||
@@ -423,6 +424,7 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
title: name,
|
title: name,
|
||||||
quizId: quizId,
|
quizId: quizId,
|
||||||
questions: questions,
|
questions: questions,
|
||||||
|
materialName: name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1162,10 +1164,12 @@ class _TeacherQuizInteractiveSheet extends StatefulWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String quizId;
|
final String quizId;
|
||||||
final List<_QuizQuestion> questions;
|
final List<_QuizQuestion> questions;
|
||||||
|
final String? materialName;
|
||||||
const _TeacherQuizInteractiveSheet({
|
const _TeacherQuizInteractiveSheet({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.quizId,
|
required this.quizId,
|
||||||
required this.questions,
|
required this.questions,
|
||||||
|
this.materialName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1229,6 +1233,14 @@ class _TeacherQuizInteractiveSheetState
|
|||||||
'total': widget.questions.length,
|
'total': widget.questions.length,
|
||||||
'submittedAt': FieldValue.serverTimestamp(),
|
'submittedAt': FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Registrar atividade no sistema de gamificação
|
||||||
|
await GamificationService.recordQuizActivity(
|
||||||
|
user.uid,
|
||||||
|
score: _score,
|
||||||
|
totalQuestions: widget.questions.length,
|
||||||
|
materialName: widget.materialName ?? 'Quiz',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error submitting teacher quiz result: $e');
|
Logger.error('Error submitting teacher quiz result: $e');
|
||||||
|
|||||||
458
lib/features/quiz/presentation/pages/quiz_management_page.dart
Normal file
458
lib/features/quiz/presentation/pages/quiz_management_page.dart
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
import '../../../../core/utils/logger.dart';
|
||||||
|
|
||||||
|
/// Página para gerenciar quizzes (eliminar quizzes feitos)
|
||||||
|
class QuizManagementPage extends StatefulWidget {
|
||||||
|
const QuizManagementPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuizManagementPage> createState() => _QuizManagementPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuizManagementPageState extends State<QuizManagementPage> {
|
||||||
|
List<Map<String, dynamic>> _quizHistory = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String _userRole = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadUserRole();
|
||||||
|
_loadQuizHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUserRole() async {
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
final userDoc = await FirebaseFirestore.instance
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.uid)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (userDoc.exists) {
|
||||||
|
setState(() {
|
||||||
|
_userRole = userDoc.data()?['role'] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadQuizHistory() async {
|
||||||
|
try {
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user == null) return;
|
||||||
|
|
||||||
|
Query query;
|
||||||
|
|
||||||
|
if (_userRole == 'teacher') {
|
||||||
|
// Professor: ver todos os quizzes criados
|
||||||
|
query = FirebaseFirestore.instance
|
||||||
|
.collection('teacherQuizzes')
|
||||||
|
.where('teacherId', isEqualTo: user.uid)
|
||||||
|
.orderBy('createdAt', descending: true);
|
||||||
|
} else {
|
||||||
|
// Aluno: ver quizzes criados pelo próprio aluno + conceitos dominados
|
||||||
|
final results = await Future.wait([
|
||||||
|
FirebaseFirestore.instance.collection('userStats').doc(user.uid).get(),
|
||||||
|
FirebaseFirestore.instance
|
||||||
|
.collection('quizHistory')
|
||||||
|
.doc(user.uid)
|
||||||
|
.collection('quizzes')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final userStatsSnapshot = results[0] as DocumentSnapshot;
|
||||||
|
final studentQuizzesSnapshot = results[1] as QuerySnapshot;
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> quizList = [];
|
||||||
|
|
||||||
|
// Adicionar quizzes criados pelo aluno
|
||||||
|
for (final doc in studentQuizzesSnapshot.docs) {
|
||||||
|
final data = Map<String, dynamic>.from(doc.data() as Map);
|
||||||
|
data['id'] = doc.id;
|
||||||
|
data['title'] = data['materialName'] ?? 'Quiz sem nome';
|
||||||
|
data['type'] = 'created';
|
||||||
|
quizList.add(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar conceitos dominados
|
||||||
|
if (userStatsSnapshot.exists) {
|
||||||
|
final stats = userStatsSnapshot.data() as Map<String, dynamic>?;
|
||||||
|
if (stats != null) {
|
||||||
|
final masteredConcepts = (stats['masteredConcepts'] as List<dynamic>?)
|
||||||
|
?.map((c) => Map<String, dynamic>.from(c as Map))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
for (final concept in masteredConcepts) {
|
||||||
|
quizList.add({
|
||||||
|
'id': concept['conceptName'] ?? '',
|
||||||
|
'title': concept['conceptName'] ?? 'Conceito sem nome',
|
||||||
|
'score': concept['masteryLevel'] ?? 0,
|
||||||
|
'totalQuestions': 100, // Simulado
|
||||||
|
'completedAt': concept['masteredAt'] ?? Timestamp.now(),
|
||||||
|
'type': 'concept',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_quizHistory = quizList;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final snapshot = await query.get();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_quizHistory = snapshot.docs.map((doc) {
|
||||||
|
final data = Map<String, dynamic>.from(doc.data() as Map);
|
||||||
|
data['id'] = doc.id;
|
||||||
|
return data;
|
||||||
|
}).cast<Map<String, dynamic>>().toList();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Error loading quiz history: $e');
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteQuiz(String quizId, String quizTitle, String type) async {
|
||||||
|
try {
|
||||||
|
String confirmMessage;
|
||||||
|
String successMessage;
|
||||||
|
|
||||||
|
if (_userRole == 'teacher') {
|
||||||
|
confirmMessage = 'Tem certeza que deseja eliminar o quiz "$quizTitle"? Esta ação não pode ser desfeita.';
|
||||||
|
successMessage = 'Quiz eliminado com sucesso!';
|
||||||
|
} else {
|
||||||
|
if (type == 'created') {
|
||||||
|
confirmMessage = 'Tem certeza que deseja eliminar seu quiz "$quizTitle"?';
|
||||||
|
successMessage = 'Quiz eliminado com sucesso!';
|
||||||
|
} else {
|
||||||
|
confirmMessage = 'Tem certeza que deseja remover o conceito "$quizTitle" do seu histórico?';
|
||||||
|
successMessage = 'Conceito removido com sucesso!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await _showDeleteConfirmation(quizTitle, confirmMessage);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
if (_userRole == 'teacher') {
|
||||||
|
// Professor: eliminar quiz criado
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('teacherQuizzes')
|
||||||
|
.doc(quizId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Também eliminar do histórico de alunos
|
||||||
|
final historySnapshot = await FirebaseFirestore.instance
|
||||||
|
.collection('quizHistory')
|
||||||
|
.where('quizId', isEqualTo: quizId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final doc in historySnapshot.docs) {
|
||||||
|
await doc.reference.delete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Aluno: remover quiz criado ou conceito dominado
|
||||||
|
if (type == 'created') {
|
||||||
|
// Remover quiz criado pelo aluno
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('quizHistory')
|
||||||
|
.doc(user.uid)
|
||||||
|
.collection('quizzes')
|
||||||
|
.doc(quizId)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remover conceito dominado
|
||||||
|
final user = AuthService.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
final userStatsDoc = await FirebaseFirestore.instance
|
||||||
|
.collection('userStats')
|
||||||
|
.doc(user.uid)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (userStatsDoc.exists) {
|
||||||
|
final userStats = userStatsDoc.data() as Map<String, dynamic>;
|
||||||
|
final masteredConcepts = (userStats['masteredConcepts'] as List<dynamic>?)
|
||||||
|
?.map((c) => Map<String, dynamic>.from(c as Map))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
// Encontrar o conceito específico para remover
|
||||||
|
final conceptToRemove = masteredConcepts.firstWhere(
|
||||||
|
(c) => c['conceptName'] == quizId,
|
||||||
|
orElse: () => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conceptToRemove.isNotEmpty) {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('userStats')
|
||||||
|
.doc(user.uid)
|
||||||
|
.update({
|
||||||
|
'masteredConcepts': FieldValue.arrayRemove([conceptToRemove])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadQuizHistory(); // Recarregar lista
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(successMessage),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Error deleting quiz: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erro ao eliminar: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _showDeleteConfirmation(String quizTitle, String message) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(_userRole == 'teacher' ? 'Eliminar Quiz' : 'Confirmar Eliminação'),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancelar'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
child: const Text('Eliminar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_userRole == 'teacher' ? 'Gerenciar Quizzes' : 'Meu Histórico'),
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
foregroundColor: cs.onSurface,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.go(_userRole == 'teacher'
|
||||||
|
? '/teacher-dashboard'
|
||||||
|
: '/student-dashboard'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
cs.primary.withValues(alpha: 0.05),
|
||||||
|
cs.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _quizHistory.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _quizHistory.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final quiz = _quizHistory[index];
|
||||||
|
return _buildQuizCard(quiz)
|
||||||
|
.animate()
|
||||||
|
.slideX(duration: const Duration(milliseconds: 300))
|
||||||
|
.then(delay: Duration(milliseconds: index * 50));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_userRole == 'teacher' ? Icons.quiz_outlined : Icons.history_edu_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_userRole == 'teacher' ? 'Nenhum quiz criado' : 'Nenhum quiz no histórico',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_userRole == 'teacher'
|
||||||
|
? 'Crie seu primeiro quiz para começar!'
|
||||||
|
: 'Complete alguns quizzes para ver seu histórico aqui.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuizCard(Map<String, dynamic> quiz) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: cs.outline.withValues(alpha: 0.2)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: cs.shadow.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
quiz['title'] ?? 'Quiz sem título',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (_userRole == 'student' && quiz['score'] != null) ...[
|
||||||
|
Text(
|
||||||
|
'Pontuação: ${quiz['score']}/${quiz['totalQuestions'] ?? '?'}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
_userRole == 'teacher' || quiz['type'] == 'created'
|
||||||
|
? 'Criado em: ${_formatDate(quiz['createdAt'])}'
|
||||||
|
: 'Completado em: ${_formatDate(quiz['completedAt'])}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _deleteQuiz(quiz['id'], quiz['title'] ?? 'Quiz', quiz['type'] ?? 'unknown'),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
tooltip: _userRole == 'teacher' ? 'Eliminar Quiz' : 'Remover',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_userRole == 'teacher' && quiz['classIds'] != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: (quiz['classIds'] as List<dynamic>).map((classId) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Turma: $classId',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onPrimaryContainer,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(dynamic date) {
|
||||||
|
if (date == null) return 'Data desconhecida';
|
||||||
|
|
||||||
|
DateTime dateTime;
|
||||||
|
if (date is Timestamp) {
|
||||||
|
dateTime = date.toDate();
|
||||||
|
} else if (date is DateTime) {
|
||||||
|
dateTime = date;
|
||||||
|
} else {
|
||||||
|
return 'Data desconhecida';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '${dateTime.day}/${dateTime.month}/${dateTime.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user