diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7276882..fddc9e9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:icon="@mipmap/launcher_icon"> + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..1f74d2d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..9502d9a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..aaecbdf Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..f1f16c6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..8d0050b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ad9f1d3 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #F9EEE8 + \ No newline at end of file diff --git a/assets/images/epvc.png b/assets/images/epvc.png new file mode 100644 index 0000000..1bd64b8 Binary files /dev/null and b/assets/images/epvc.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..ab8b0b3 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c94c179..4ce92a6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -488,7 +488,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..1ea2710 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..4635213 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..71feec4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..6a55d96 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..3218bdd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..f131597 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..855f1a6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..71feec4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..1f5e951 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..8bd1a76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..cad691f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..002d3ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..f31df5e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..fb4e7ff Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..8bd1a76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..89b8034 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..1f74d2d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..f1f16c6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..fc9175a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..2bd0421 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..6c9d48b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/core/services/gamification_service.dart b/lib/core/services/gamification_service.dart index 0a60042..c41c86f 100644 --- a/lib/core/services/gamification_service.dart +++ b/lib/core/services/gamification_service.dart @@ -15,7 +15,7 @@ class GamificationService { try { final userStatsRef = _firestore.collection('users').doc(userId); final userStatsDoc = await userStatsRef.get(); - + if (!userStatsDoc.exists) { // Criar estatísticas iniciais await userStatsRef.set({ @@ -35,14 +35,14 @@ class GamificationService { 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'); @@ -51,7 +51,11 @@ class GamificationService { } 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 lastDay = DateTime( + lastActivity.year, + lastActivity.month, + lastActivity.day, + ); final difference = today.difference(lastDay).inDays; if (difference == 0) { @@ -68,20 +72,22 @@ class GamificationService { }); } else { Logger.info('Already active today, streak unchanged: $newStreak'); - await userStatsRef.update({ - 'lastActivityDate': Timestamp.now(), - }); + 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; + newLongestStreak = newStreak > newLongestStreak + ? newStreak + : newLongestStreak; } else { // Quebrou o streak newStreak = 1; - newLongestStreak = newStreak > newLongestStreak ? newStreak : newLongestStreak; + newLongestStreak = newStreak > newLongestStreak + ? newStreak + : newLongestStreak; } } @@ -128,39 +134,46 @@ class GamificationService { } /// Registrar atividade de quiz - static Future recordQuizActivity(String userId, { + static Future 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 + // Tempo de estudo agora é calculado em tempo real no quiz sheet + // Não adicionamos tempo fixo aqui // Verificar conquistas de quiz await _checkQuizAchievements(userId, score, totalQuestions); // Incrementar contador de quizzes completos - await userStatsRef.update({ - 'completedQuizzes': FieldValue.increment(1), - }); + 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}'); + 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'); + 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( + 'Score too low for mastered concept: $score/$totalQuestions', + ); } - Logger.info('Quiz activity recorded for user $userId: $score/$totalQuestions'); + Logger.info( + 'Quiz activity recorded for user $userId: $score/$totalQuestions', + ); } catch (e) { Logger.error('Error recording quiz activity: $e'); } @@ -170,15 +183,17 @@ class GamificationService { static Future 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 + return await getUserStats( + userId, + ); // Chamada recursiva após inicialização } final data = doc.data() as Map; - + // Garantir que completedQuizzes exista if (!data.containsKey('completedQuizzes')) { await _firestore.collection('users').doc(userId).update({ @@ -186,7 +201,7 @@ class GamificationService { }); data['completedQuizzes'] = 0; } - + return UserStats.fromFirestore(data, userId); } catch (e) { Logger.error('Error getting user stats: $e'); @@ -195,26 +210,32 @@ class GamificationService { } /// Obter estatísticas da turma - static Future getClassStats(String classId, {bool forceRefresh = false}) async { + static Future getClassStats( + String classId, { + bool forceRefresh = false, + }) async { try { if (forceRefresh) { // Forçar recálculo completo return await _calculateClassStats(classId); } - - final classStatsDoc = await _firestore.collection('classStats').doc(classId).get(); + + final classStatsDoc = await _firestore + .collection('classStats') + .doc(classId) + .get(); if (!classStatsDoc.exists) { return await _calculateClassStats(classId); } - + // Verificar se os dados estão desatualizados (mais de 1 hora) final data = classStatsDoc.data()!; final lastUpdated = data['lastUpdated'] as Timestamp?; - if (lastUpdated == null || + if (lastUpdated == null || DateTime.now().difference(lastUpdated.toDate()).inHours > 1) { return await _calculateClassStats(classId); } - + return ClassStats.fromFirestore(data, classId); } catch (e) { Logger.error('Error getting class stats: $e'); @@ -229,11 +250,11 @@ class GamificationService { .collection('classes') .where('teacherId', isEqualTo: teacherId) .get(); - + for (final classDoc in classesSnapshot.docs) { await _calculateClassStats(classDoc.id); } - + Logger.info('Refreshed stats for ${classesSnapshot.docs.length} classes'); } catch (e) { Logger.error('Error refreshing class stats: $e'); @@ -254,7 +275,9 @@ class GamificationService { return []; } - final studentIds = enrollmentsSnapshot.docs.map((doc) => doc['studentId'] as String).toList(); + final studentIds = enrollmentsSnapshot.docs + .map((doc) => doc['studentId'] as String) + .toList(); final rankings = []; // Obter número real de quizzes disponíveis na turma @@ -270,50 +293,71 @@ class GamificationService { final userStats = await getUserStats(studentId); if (userStats != null) { // Obter informações do usuário - final userDoc = await _firestore.collection('users').doc(studentId).get(); + final userDoc = await _firestore + .collection('users') + .doc(studentId) + .get(); final userData = userDoc.data() as Map?; // Calcular estatísticas para o ranking final completedQuizzes = userStats.completedQuizzes; - final totalQuizzes = totalAvailableQuizzes > 0 ? totalAvailableQuizzes : 1; + final totalQuizzes = totalAvailableQuizzes > 0 + ? totalAvailableQuizzes + : 1; final quizCompletionRate = completedQuizzes / totalQuizzes; - + Logger.info('=== RANKING SCORE DEBUG ==='); Logger.info('Student ID: $studentId'); Logger.info('Completed quizzes: $completedQuizzes'); Logger.info('Total quizzes: $totalQuizzes'); - Logger.info('Quiz completion rate: $quizCompletionRate (${(quizCompletionRate * 100).toInt()}%)'); + Logger.info( + 'Quiz completion rate: $quizCompletionRate (${(quizCompletionRate * 100).toInt()}%)', + ); Logger.info('Current streak: ${userStats.currentStreak}'); - Logger.info('Total study time: ${userStats.totalStudyTime} minutes'); - Logger.info('Mastered concepts: ${userStats.masteredConcepts.length}'); - Logger.info('Unlocked achievements: ${userStats.unlockedAchievements.length}'); - + Logger.info( + 'Total study time: ${userStats.totalStudyTime} minutes', + ); + Logger.info( + 'Mastered concepts: ${userStats.masteredConcepts.length}', + ); + Logger.info( + 'Unlocked achievements: ${userStats.unlockedAchievements.length}', + ); + // Calcular score geral baseado em múltiplos fatores - final overallScore = _calculateOverallScore(userStats, quizCompletionRate); - - Logger.info('Overall score calculated: $overallScore (${overallScore.toInt()}%)'); + final overallScore = _calculateOverallScore( + userStats, + quizCompletionRate, + ); + + Logger.info( + 'Overall score calculated: $overallScore (${overallScore.toInt()}%)', + ); Logger.info('=== END RANKING SCORE DEBUG ==='); // Tentar obter um nome melhor para o aluno String studentName = 'Aluno $studentId'; if (userData != null) { - studentName = userData['displayName'] ?? - userData['email']?.split('@')[0] ?? - 'Aluno ${studentId.substring(0, 8)}...'; + studentName = + userData['displayName'] ?? + userData['email']?.split('@')[0] ?? + 'Aluno ${studentId.substring(0, 8)}...'; } - rankings.add(StudentRanking( - studentId: studentId, - studentName: studentName, - studentEmail: userData?['email'] ?? '', - overallScore: overallScore, - completedQuizzes: completedQuizzes, - totalQuizzes: totalQuizzes, - quizCompletionRate: quizCompletionRate, - currentStreak: userStats.currentStreak, - studyTimeMinutes: userStats.totalStudyTime, - lastActivity: userStats.lastActivityDate ?? DateTime.now(), - )); + rankings.add( + StudentRanking( + studentId: studentId, + studentName: studentName, + studentEmail: userData?['email'] ?? '', + overallScore: overallScore, + completedQuizzes: completedQuizzes, + totalQuizzes: totalQuizzes, + quizCompletionRate: quizCompletionRate, + currentStreak: userStats.currentStreak, + studyTimeMinutes: userStats.totalStudyTime, + lastActivity: userStats.lastActivityDate ?? DateTime.now(), + ), + ); } } catch (e) { Logger.error('Error getting stats for student $studentId: $e'); @@ -323,7 +367,7 @@ class GamificationService { // Ordenar por score geral rankings.sort((a, b) => b.overallScore.compareTo(a.overallScore)); - + return rankings; } catch (e) { Logger.error('Error getting class ranking: $e'); @@ -332,27 +376,33 @@ class GamificationService { } /// Calcular score geral para ranking - static double _calculateOverallScore(UserStats userStats, double quizCompletionRate) { + static double _calculateOverallScore( + UserStats userStats, + double quizCompletionRate, + ) { // Se completou 100% dos quizzes, score é 100% if (quizCompletionRate >= 1.0) { return 100.0; } - + // Para completion < 100%, calcular proporcionalmente double baseScore = quizCompletionRate * 90; // 90% baseado em completion - + // Bônus adicionais (máximo 10% extra) double bonusScore = 0.0; - + // 5% para conceitos dominados bonusScore += (userStats.masteredConcepts.length / 5.0 * 5).clamp(0.0, 5.0); - + // 3% para streak bonusScore += (userStats.currentStreak / 7.0 * 3).clamp(0.0, 3.0); - + // 2% para conquistas - bonusScore += (userStats.unlockedAchievements.length / 10.0 * 2).clamp(0.0, 2.0); - + bonusScore += (userStats.unlockedAchievements.length / 10.0 * 2).clamp( + 0.0, + 2.0, + ); + final totalScore = baseScore + bonusScore; return totalScore.clamp(0.0, 100.0); } @@ -391,14 +441,20 @@ class GamificationService { } /// Obter conquistas disponíveis - static Future> getAvailableAchievements({String? teacherId}) async { + static Future> getAvailableAchievements({ + String? teacherId, + }) async { try { // Sempre incluir conquistas do sistema - List achievements = List.from(SystemAchievements.defaultAchievements); - + List achievements = List.from( + SystemAchievements.defaultAchievements, + ); + // Adicionar conquistas personalizadas do professor - Query query = _firestore.collection('achievements').where('isActive', isEqualTo: true); - + Query query = _firestore + .collection('achievements') + .where('isActive', isEqualTo: true); + if (teacherId != null) { query = query.where('createdBy', isEqualTo: teacherId); } @@ -406,10 +462,15 @@ class GamificationService { final snapshot = await query.get(); achievements.addAll( snapshot.docs - .map((doc) => Achievement.fromFirestore(doc.data() as Map, doc.id)) + .map( + (doc) => Achievement.fromFirestore( + doc.data() as Map, + doc.id, + ), + ) .toList(), ); - + Logger.info('Total achievements loaded: ${achievements.length}'); return achievements; } catch (e) { @@ -418,7 +479,6 @@ class GamificationService { } } - /// Métodos privados static Future _createInitialUserStats(String userId) async { @@ -427,7 +487,8 @@ class GamificationService { 'currentStreak': 0, 'longestStreak': 0, 'totalStudyTime': 0, - 'lastActivityDate': null, // null para que primeira atividade inicie streak + 'lastActivityDate': + null, // null para que primeira atividade inicie streak 'weeklyStudyTime': 0, 'monthlyStudyTime': 0, 'masteredConcepts': [], @@ -435,15 +496,19 @@ class GamificationService { }); } - static Future _addMasteredConcept(String userId, String conceptName, int score) async { + static Future _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) @@ -464,7 +529,9 @@ class GamificationService { }).toList(); await userStatsRef.update({ - 'masteredConcepts': updatedConcepts.map((c) => c.toFirestore()).toList(), + 'masteredConcepts': updatedConcepts + .map((c) => c.toFirestore()) + .toList(), }); } } else { @@ -487,7 +554,10 @@ class GamificationService { static Future _calculateClassStats(String classId) async { try { // Obter informações da turma - final classDoc = await _firestore.collection('classes').doc(classId).get(); + final classDoc = await _firestore + .collection('classes') + .doc(classId) + .get(); if (!classDoc.exists) { throw Exception('Class not found'); } @@ -501,7 +571,9 @@ class GamificationService { .where('classId', isEqualTo: classId) .get(); - final studentIds = enrollmentsSnapshot.docs.map((doc) => doc.data()['studentId'] as String).toList(); + final studentIds = enrollmentsSnapshot.docs + .map((doc) => doc.data()['studentId'] as String) + .toList(); // Calcular estatísticas int activeStudents = 0; @@ -510,13 +582,15 @@ class GamificationService { for (final studentId in studentIds) { final userStats = await getUserStats(studentId); - + // Verificar se está ativo (atividade nos últimos 30 dias - mais realista) int daysSinceLastActivity = 999; // Valor alto para inatividade bool hasStats = userStats != null; - + if (hasStats && userStats!.lastActivityDate != null) { - daysSinceLastActivity = DateTime.now().difference(userStats.lastActivityDate!).inDays; + daysSinceLastActivity = DateTime.now() + .difference(userStats.lastActivityDate!) + .inDays; if (daysSinceLastActivity <= 30) { activeStudents++; } @@ -527,59 +601,78 @@ class GamificationService { if (hasStats) { final completedQuizzes = userStats!.completedQuizzes; final masteredConcepts = userStats.masteredConcepts.length; - + Logger.info('=== PROGRESS CALCULATION DEBUG ==='); Logger.info('Student ID: $studentId'); Logger.info('Completed quizzes: $completedQuizzes'); Logger.info('Mastered concepts: $masteredConcepts'); - + // Progresso mais representativo: 60% quizzes + 40% conceitos // Primeiro quiz já dá 30% de progresso (incentivo inicial) - final quizProgress = completedQuizzes > 0 ? - (0.3 + (completedQuizzes - 1) * 0.15).clamp(0.0, 0.6) : 0.0; + final quizProgress = completedQuizzes > 0 + ? (0.3 + (completedQuizzes - 1) * 0.15).clamp(0.0, 0.6) + : 0.0; // Primeiro conceito já dá 15% de progresso final conceptProgress = (masteredConcepts * 0.15).clamp(0.0, 0.4); progress = quizProgress + conceptProgress; - - Logger.info('Quiz progress: $quizProgress (${(quizProgress * 100).toInt()}%)'); - Logger.info('Concept progress: $conceptProgress (${(conceptProgress * 100).toInt()}%)'); - Logger.info('Total progress: $progress (${(progress * 100).toInt()}%)'); + + Logger.info( + 'Quiz progress: $quizProgress (${(quizProgress * 100).toInt()}%)', + ); + Logger.info( + 'Concept progress: $conceptProgress (${(conceptProgress * 100).toInt()}%)', + ); + Logger.info( + 'Total progress: $progress (${(progress * 100).toInt()}%)', + ); Logger.info('=== END PROGRESS CALCULATION DEBUG ==='); } else { Logger.info('Student $studentId has no stats - progress = 0.0'); } - + totalProgress += progress; // Verificar se precisa de apoio (ajustado para nova fórmula) if (progress < 0.25 || daysSinceLastActivity > 30) { - final userDoc = await _firestore.collection('users').doc(studentId).get(); + final userDoc = await _firestore + .collection('users') + .doc(studentId) + .get(); final userData = userDoc.data(); - + // Tentar obter um nome melhor para o aluno String studentName = 'Aluno ${studentId.substring(0, 8)}...'; if (userData != null) { - studentName = userData['displayName'] ?? - userData['email']?.split('@')[0] ?? - 'Aluno ${studentId.substring(0, 8)}...'; + studentName = + userData['displayName'] ?? + userData['email']?.split('@')[0] ?? + 'Aluno ${studentId.substring(0, 8)}...'; } - - needingSupport.add(StudentNeedingSupport( - studentId: studentId, - studentName: studentName, - reason: progress < 0.3 ? 'low_scores' : 'inactivity', - lastActivity: hasStats ? userStats!.lastActivityDate ?? DateTime.now() : DateTime.now().subtract(const Duration(days: 45)), - averageScore: progress * 100, - )); + + needingSupport.add( + StudentNeedingSupport( + studentId: studentId, + studentName: studentName, + reason: progress < 0.3 ? 'low_scores' : 'inactivity', + lastActivity: hasStats + ? userStats!.lastActivityDate ?? DateTime.now() + : DateTime.now().subtract(const Duration(days: 45)), + averageScore: progress * 100, + ), + ); } } - final averageProgress = studentIds.isEmpty ? 0.0 : totalProgress / studentIds.length; - + final averageProgress = studentIds.isEmpty + ? 0.0 + : totalProgress / studentIds.length; + Logger.info('=== AVERAGE PROGRESS DEBUG ==='); Logger.info('Total students: ${studentIds.length}'); Logger.info('Total progress sum: $totalProgress'); - Logger.info('Average progress: $averageProgress (${(averageProgress * 100).toInt()}%)'); + Logger.info( + 'Average progress: $averageProgress (${(averageProgress * 100).toInt()}%)', + ); Logger.info('=== END AVERAGE PROGRESS DEBUG ==='); // Obter estatísticas de quizzes e conteúdos @@ -618,7 +711,10 @@ class GamificationService { // Limpar cache primeiro e depois salvar estatísticas calculadas await _firestore.collection('classStats').doc(classId).delete(); - await _firestore.collection('classStats').doc(classId).set(classStats.toFirestore()); + await _firestore + .collection('classStats') + .doc(classId) + .set(classStats.toFirestore()); Logger.info('Class stats refreshed and saved for class $classId'); return classStats; @@ -628,9 +724,14 @@ class GamificationService { } } - static Future _checkStreakAchievements(String userId, int streakDays) async { + static Future _checkStreakAchievements( + String userId, + int streakDays, + ) async { final achievements = await getAvailableAchievements(); - final streakAchievements = achievements.where((a) => a.category == 'streak'); + final streakAchievements = achievements.where( + (a) => a.category == 'streak', + ); for (final achievement in streakAchievements) { if (achievement.requirements.checkCondition(streakDays)) { @@ -639,9 +740,14 @@ class GamificationService { } } - static Future _checkStudyTimeAchievements(String userId, int totalMinutes) async { + static Future _checkStudyTimeAchievements( + String userId, + int totalMinutes, + ) async { final achievements = await getAvailableAchievements(); - final studyAchievements = achievements.where((a) => a.category == 'study_time'); + final studyAchievements = achievements.where( + (a) => a.category == 'study_time', + ); for (final achievement in studyAchievements) { if (achievement.requirements.checkCondition(totalMinutes)) { @@ -650,13 +756,17 @@ class GamificationService { } } - static Future _checkQuizAchievements(String userId, int score, int totalQuestions) async { + static Future _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; @@ -665,41 +775,51 @@ class GamificationService { 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' && + 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)) { + } else if (achievement.category == 'quiz_count' && + achievement.requirements.type == 'quiz_completion' && + achievement.requirements.checkCondition(completedQuizzes)) { await _unlockAchievement(userId, achievement.id); - } else if (achievement.category == 'quiz' && achievement.requirements.type == 'quiz_completion' && - achievement.id == 'first_quiz' && - achievement.requirements.checkCondition(1)) { + } else if (achievement.category == 'quiz' && + achievement.requirements.type == 'quiz_completion' && + achievement.id == 'first_quiz' && + achievement.requirements.checkCondition(1)) { await _unlockAchievement(userId, achievement.id); - } else if (achievement.category == 'concept' && achievement.requirements.type == 'concepts_mastered' && - achievement.requirements.checkCondition(userStats.masteredConcepts.length)) { + } 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( + 'Achievement not matched: ${achievement.id} - category: ${achievement.category}, type: ${achievement.requirements.type}', + ); } } - + Logger.info('=== END CHECKING QUIZ ACHIEVEMENTS ==='); } - - static Future _unlockAchievement(String userId, String achievementId) async { + static Future _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(); @@ -709,11 +829,14 @@ class GamificationService { } final userStats = UserStats.fromFirestore(userStatsDoc.data()!, userId); - Logger.info('Current unlocked achievements count: ${userStats.unlockedAchievements.length}'); - + Logger.info( + 'Current unlocked achievements count: ${userStats.unlockedAchievements.length}', + ); + // Verificar se já desbloqueou - final alreadyUnlocked = userStats.unlockedAchievements - .any((a) => a.achievementId == achievementId); + final alreadyUnlocked = userStats.unlockedAchievements.any( + (a) => a.achievementId == achievementId, + ); Logger.info('Already unlocked: $alreadyUnlocked'); @@ -726,12 +849,18 @@ class GamificationService { Logger.info('Adding achievement to Firestore...'); await userStatsRef.update({ - 'unlockedAchievements': FieldValue.arrayUnion([unlockedAchievement.toFirestore()]), + 'unlockedAchievements': FieldValue.arrayUnion([ + unlockedAchievement.toFirestore(), + ]), }); - Logger.info('Achievement unlocked successfully: $achievementId for user $userId'); + Logger.info( + 'Achievement unlocked successfully: $achievementId for user $userId', + ); } else { - Logger.info('Achievement $achievementId already unlocked for user $userId'); + Logger.info( + 'Achievement $achievementId already unlocked for user $userId', + ); } } catch (e) { Logger.error('Error unlocking achievement: $e'); @@ -742,16 +871,14 @@ class GamificationService { static Future _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; if (!data.containsKey('completedQuizzes')) { - await userStatsRef.update({ - 'completedQuizzes': 0, - }); + await userStatsRef.update({'completedQuizzes': 0}); } } else { // Criar documento inicial @@ -766,7 +893,7 @@ class GamificationService { 'unlockedAchievements': [], }); } - + Logger.info('User stats initialized for user $userId'); } catch (e) { Logger.error('Error initializing user stats: $e'); diff --git a/lib/features/analytics/presentation/pages/analytics_page.dart b/lib/features/analytics/presentation/pages/analytics_page.dart index a49ab14..6e62083 100644 --- a/lib/features/analytics/presentation/pages/analytics_page.dart +++ b/lib/features/analytics/presentation/pages/analytics_page.dart @@ -23,6 +23,8 @@ class AnalyticsPage extends StatefulWidget { class _AnalyticsPageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final _classSearchController = TextEditingController(); + String _classSearchQuery = ''; List _classStats = []; bool _loading = true; String? _selectedClassId; @@ -38,6 +40,7 @@ class _AnalyticsPageState extends State @override void dispose() { _tabController.dispose(); + _classSearchController.dispose(); super.dispose(); } @@ -46,7 +49,7 @@ class _AnalyticsPageState extends State final user = AuthService.currentUser; if (user == null) return; - // Obter disciplinas do professor + // Obter turmas do professor final classesSnapshot = await FirebaseFirestore.instance .collection('classes') .where('teacherId', isEqualTo: user.uid) @@ -85,7 +88,6 @@ class _AnalyticsPageState extends State @override Widget build(BuildContext context) { final themeExtras = AppThemeExtras.of(context); - final cs = Theme.of(context).colorScheme; return PopScope( canPop: false, @@ -94,9 +96,9 @@ class _AnalyticsPageState extends State context.go('/teacher-dashboard'); }, child: Scaffold( - backgroundColor: cs.surface, + backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, - body: Container( + body: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -106,6 +108,7 @@ class _AnalyticsPageState extends State ), ), child: SafeArea( + bottom: false, child: Column( children: [ // Header @@ -137,7 +140,7 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 4), Text( - 'Acompanhe o desempenho das disciplinas', + 'Acompanhe o desempenho das turmas', style: TextStyle( color: Colors.white.withValues(alpha: 0.8), fontSize: 16, @@ -163,7 +166,7 @@ class _AnalyticsPageState extends State indicatorColor: Colors.white, indicatorWeight: 2, tabs: const [ - Tab(text: 'Disciplinas'), + Tab(text: 'Turmas'), Tab(text: 'Alunos'), ], ), @@ -205,7 +208,7 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 16), Text( - 'Nenhuma disciplina encontrada', + 'Nenhuma turma encontrada', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 18, @@ -213,7 +216,7 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 8), Text( - 'Crie disciplinas para ver as analytics aqui', + 'Crie turmas para ver as analytics aqui', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), fontSize: 14, @@ -224,47 +227,131 @@ class _AnalyticsPageState extends State ); } - 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), + final filtered = _classSearchQuery.isEmpty + ? _classStats + : _classStats + .where( + (s) => s.className.toLowerCase().contains(_classSearchQuery), + ) + .toList(); - // Class Cards - ..._classStats.map( - (stats) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ClassAnalyticsCard( - classStats: stats, - onTap: () => _showClassStudents(stats), + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + // Overview Cards + Center( + child: _buildOverviewCard( + 'Total de Alunos', + '${_classStats.fold(0, (sum, s) => sum + s.totalStudents)}', + Icons.people, + Colors.blue, + ), + ), + const SizedBox(height: 20), + // Search bar + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.search, + color: Colors.white.withValues(alpha: 0.7), + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Theme( + data: ThemeData.dark().copyWith( + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.white, + selectionColor: Colors.white24, + selectionHandleColor: Colors.white, + ), + ), + child: TextField( + controller: _classSearchController, + onChanged: (v) => setState( + () => _classSearchQuery = v.trim().toLowerCase(), + ), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: 'Pesquisar turma…', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ), + if (_classSearchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _classSearchController.clear(); + setState(() => _classSearchQuery = ''); + }, + child: Icon( + Icons.close, + color: Colors.white.withValues(alpha: 0.7), + size: 18, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + if (filtered.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Nenhuma turma encontrada', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 15, + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ClassAnalyticsCard( + classStats: filtered[index], + onTap: () => _showClassStudents(filtered[index]), + ), + childCount: filtered.length, ), ), ), - ], - ), + ], ); } @@ -281,7 +368,7 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 16), Text( - 'Seleciona uma disciplina', + 'Seleciona uma turma', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 18, @@ -289,7 +376,7 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 8), Text( - 'Clica numa disciplina no separador "Disciplinas" para ver os alunos', + 'Clica numa turma no separador "Turmas" para ver os alunos', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), fontSize: 14, diff --git a/lib/features/analytics/presentation/widgets/class_analytics_card.dart b/lib/features/analytics/presentation/widgets/class_analytics_card.dart index 807f654..88dcea5 100644 --- a/lib/features/analytics/presentation/widgets/class_analytics_card.dart +++ b/lib/features/analytics/presentation/widgets/class_analytics_card.dart @@ -76,7 +76,10 @@ class ClassAnalyticsCard extends StatelessWidget { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16), @@ -84,7 +87,11 @@ class ClassAnalyticsCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.trending_up, color: Colors.white, size: 16), + const Icon( + Icons.trending_up, + color: Colors.white, + size: 16, + ), const SizedBox(width: 4), Text( '${(classStats.averageProgress * 100).toInt()}%', @@ -190,39 +197,47 @@ class ClassAnalyticsCard extends StatelessWidget { ], ), 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, + ...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, + ), + ), + ], ), ), - 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', @@ -242,7 +257,7 @@ class ClassAnalyticsCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Ver ranking detalhado', + 'Ver alunos da turma', style: TextStyle( color: Colors.white.withValues(alpha: 0.8), fontSize: 12, @@ -272,7 +287,7 @@ class ClassAnalyticsCard extends StatelessWidget { bool isWarning = false, }) { final cs = Theme.of(context).colorScheme; - + return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -285,11 +300,7 @@ class ClassAnalyticsCard extends StatelessWidget { ), child: Column( children: [ - Icon( - icon, - color: isWarning ? Colors.orange : Colors.white, - size: 20, - ), + Icon(icon, color: isWarning ? Colors.orange : Colors.white, size: 20), const SizedBox(height: 6), Text( value, diff --git a/lib/features/analytics/presentation/widgets/class_ranking_widget.dart b/lib/features/analytics/presentation/widgets/class_ranking_widget.dart index d95c994..ded8b4f 100644 --- a/lib/features/analytics/presentation/widgets/class_ranking_widget.dart +++ b/lib/features/analytics/presentation/widgets/class_ranking_widget.dart @@ -269,7 +269,7 @@ class _ClassRankingWidgetState extends State { ), const SizedBox(height: 16), Text( - 'Nenhum aluno na disciplina', + 'Nenhum aluno na turma', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 18, diff --git a/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart b/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart index ebbd95c..87623c5 100644 --- a/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart +++ b/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart @@ -3,8 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -/// Inline widget (no Scaffold) showing enrolled students for a class, -/// with search, real-time updates, and remove-student functionality. class ClassStudentsInlineWidget extends StatefulWidget { final String classId; final String className; @@ -21,6 +19,7 @@ class ClassStudentsInlineWidget extends StatefulWidget { } class _ClassStudentsInlineWidgetState extends State { + final _searchController = TextEditingController(); String _searchQuery = ''; String? _classCode; late Stream _enrollmentsStream; @@ -32,13 +31,6 @@ class _ClassStudentsInlineWidgetState extends State { _initStream(); } - void _initStream() { - _enrollmentsStream = FirebaseFirestore.instance - .collection('enrollments') - .where('classId', isEqualTo: widget.classId) - .snapshots(); - } - @override void didUpdateWidget(ClassStudentsInlineWidget oldWidget) { super.didUpdateWidget(oldWidget); @@ -48,20 +40,30 @@ class _ClassStudentsInlineWidgetState extends State { } } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _initStream() { + _enrollmentsStream = FirebaseFirestore.instance + .collection('enrollments') + .where('classId', isEqualTo: widget.classId) + .snapshots(); + } + Future _loadClassCode() async { final doc = await FirebaseFirestore.instance .collection('classes') .doc(widget.classId) .get(); if (mounted) { - setState(() { - _classCode = doc.data()?['code'] as String? ?? '—'; - }); + setState(() => _classCode = doc.data()?['code'] as String? ?? '—'); } } - Future _removeStudent( - BuildContext context, + Future _confirmRemove( String enrollmentDocId, String studentName, ) async { @@ -72,7 +74,7 @@ class _ClassStudentsInlineWidgetState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: const Text('Remover aluno'), content: Text( - 'Tens a certeza que queres remover "$studentName" desta disciplina?', + 'Tens a certeza que queres remover "$studentName" desta turma?', ), actions: [ TextButton( @@ -87,15 +89,187 @@ class _ClassStudentsInlineWidgetState extends State { ], ), ); - if (confirmed != true) return; + await _deleteEnrollment(enrollmentDocId, studentName); + } + Future _deleteEnrollment( + String enrollmentDocId, + String studentName, + ) async { + final cs = Theme.of(context).colorScheme; try { await FirebaseFirestore.instance .collection('enrollments') .doc(enrollmentDocId) .delete(); - if (mounted) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + backgroundColor: cs.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + content: Text( + '"$studentName" foi removido da turma.', + style: const TextStyle(color: Colors.white), + ), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Erro ao remover: $e'))); + } + } + + // ── Build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return StreamBuilder( + stream: _enrollmentsStream, + builder: (context, snapshot) { + final docs = snapshot.data?.docs ?? []; + final enrollments = List.from(docs) + ..sort((a, b) { + final aTs = (a.data() as Map)['joinedAt'] as Timestamp?; + final bTs = (b.data() as Map)['joinedAt'] as Timestamp?; + if (aTs == null && bTs == null) return 0; + if (aTs == null) return 1; + if (bTs == null) return -1; + return aTs.compareTo(bTs); + }); + + final filtered = _searchQuery.isEmpty + ? enrollments + : enrollments.where((doc) { + final d = doc.data() as Map; + final name = (d['studentName'] as String? ?? '').toLowerCase(); + final email = (d['studentEmail'] as String? ?? '') + .toLowerCase(); + return name.contains(_searchQuery) || + email.contains(_searchQuery); + }).toList(); + + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + sliver: SliverToBoxAdapter( + child: _buildHeader(cs, enrollments.length, snapshot), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + sliver: SliverToBoxAdapter(child: _buildSearchBar()), + ), + if (snapshot.connectionState == ConnectionState.waiting && + snapshot.data == null) + const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ) + else if (filtered.isEmpty) ...[ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: _buildEmpty(cs), + ), + ), + ] else + SliverPadding( + padding: EdgeInsets.fromLTRB(20, 14, 20, 20 + bottomInset), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final doc = filtered[index]; + final data = doc.data() as Map; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _buildStudentCard( + cs: cs, + enrollmentDocId: doc.id, + studentName: + data['studentName'] as String? ?? 'Aluno sem nome', + studentEmail: data['studentEmail'] as String? ?? '', + joinedAt: data['joinedAt'] as Timestamp?, + ), + ); + }, childCount: filtered.length), + ), + ), + ], + ); + }, + ); + } + + // ── Sub-widgets ──────────────────────────────────────────────────────────── + + Widget _buildHeader( + ColorScheme cs, + int count, + AsyncSnapshot snapshot, + ) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.primary, cs.primary.withValues(alpha: 0.8)], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.className, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + snapshot.connectionState == ConnectionState.waiting && + snapshot.data == null + ? 'A carregar…' + : '$count aluno${count == 1 ? '' : 's'} inscrito${count == 1 ? '' : 's'}', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 13, + ), + ), + ], + ), + ), + _buildCodeBadge(cs), + ], + ), + ); + } + + Widget _buildCodeBadge(ColorScheme cs) { + return GestureDetector( + onTap: () { + if (_classCode == null || _classCode == '—') return; + Clipboard.setData(ClipboardData(text: _classCode!)); ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar( @@ -106,287 +280,124 @@ class _ClassStudentsInlineWidgetState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - content: Text( - '"$studentName" foi removido da disciplina.', - style: const TextStyle(color: Colors.white), + duration: const Duration(seconds: 2), + content: const Text( + 'Código copiado!', + style: TextStyle(color: Colors.white), ), ), ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Erro ao remover aluno: $e'))); - } - } - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return StreamBuilder( - stream: _enrollmentsStream, - builder: (context, snapshot) { - // Keep showing previous data while new data loads (prevents flash) - final docs = snapshot.data?.docs ?? []; - // Sort client-side by joinedAt ascending - final enrollments = List.from(docs) - ..sort((a, b) { - final aData = a.data() as Map; - final bData = b.data() as Map; - final aTs = aData['joinedAt'] as Timestamp?; - final bTs = bData['joinedAt'] as Timestamp?; - if (aTs == null && bTs == null) return 0; - if (aTs == null) return 1; - if (bTs == null) return -1; - return aTs.compareTo(bTs); - }); - final filtered = _searchQuery.isEmpty - ? enrollments - : enrollments.where((doc) { - final data = doc.data() as Map; - final name = (data['studentName'] as String? ?? '') - .toLowerCase(); - final email = (data['studentEmail'] as String? ?? '') - .toLowerCase(); - final q = _searchQuery.toLowerCase(); - return name.contains(q) || email.contains(q); - }).toList(); - - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: Container( - margin: const EdgeInsets.all(20), - child: Column( + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.3)), + ), + child: Column( + children: [ + const Text( + 'Código', + style: TextStyle( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _classCode ?? '…', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 2, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, children: [ - // ── Header ────────────────────────────────────────────────── - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [cs.primary, cs.primary.withValues(alpha: 0.8)], - ), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.className, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - snapshot.connectionState == - ConnectionState.waiting - ? 'A carregar…' - : '${enrollments.length} aluno${enrollments.length == 1 ? '' : 's'} inscrito${enrollments.length == 1 ? '' : 's'}', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.85), - fontSize: 13, - ), - ), - ], - ), - ), - // Código da disciplina - GestureDetector( - onTap: () { - if (_classCode != null && _classCode != '—') { - Clipboard.setData(ClipboardData(text: _classCode!)); - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - backgroundColor: cs.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - duration: const Duration(seconds: 2), - content: const Text( - 'Código copiado!', - style: TextStyle(color: Colors.white), - ), - ), - ); - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - 'Código', - style: TextStyle( - color: Colors.white70, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - _classCode ?? '…', - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - letterSpacing: 2, - ), - ), - const SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.copy, - color: Colors.white.withValues(alpha: 0.7), - size: 10, - ), - const SizedBox(width: 3), - Text( - 'copiar', - style: TextStyle( - color: Colors.white.withValues( - alpha: 0.7, - ), - fontSize: 9, - ), - ), - ], - ), - ], - ), - ), - ), - ], - ), + Icon( + Icons.copy, + color: Colors.white.withValues(alpha: 0.7), + size: 10, ), - const SizedBox(height: 14), - - // ── Search bar ────────────────────────────────────────────── - Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, + const SizedBox(width: 3), + Text( + 'copiar', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 9, ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon( - Icons.search, - color: Colors.white.withValues(alpha: 0.7), - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - onChanged: (v) => - setState(() => _searchQuery = v.trim()), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - cursorColor: Colors.white, - decoration: InputDecoration( - hintText: 'Pesquisar aluno…', - hintStyle: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 14, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - ), - ), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () => setState(() => _searchQuery = ''), - child: Icon( - Icons.close, - color: Colors.white.withValues(alpha: 0.7), - size: 18, - ), - ), - ], - ), - ), - const SizedBox(height: 14), - - // ── List ──────────────────────────────────────────────────── - Expanded( - child: - snapshot.connectionState == ConnectionState.waiting && - snapshot.data == null - ? const Center( - child: CircularProgressIndicator(color: Colors.white), - ) - : filtered.isEmpty - ? _buildEmpty(cs) - : ListView.separated( - padding: EdgeInsets.zero, - itemCount: filtered.length, - separatorBuilder: (_, __) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final doc = filtered[index]; - final data = doc.data() as Map; - final studentName = - data['studentName'] as String? ?? - 'Aluno sem nome'; - final studentEmail = - data['studentEmail'] as String? ?? ''; - final joinedAt = data['joinedAt'] as Timestamp?; - final enrollmentDocId = doc.id; - - return _buildStudentCard( - cs: cs, - enrollmentDocId: enrollmentDocId, - studentName: studentName, - studentEmail: studentEmail, - joinedAt: joinedAt, - index: index, - ); - }, - ), ), ], ), + ], + ), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon( + Icons.search, + color: Colors.white.withValues(alpha: 0.7), + size: 20, ), - ); - }, + const SizedBox(width: 10), + Expanded( + child: Theme( + data: ThemeData.dark().copyWith( + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.white, + selectionColor: Colors.white24, + selectionHandleColor: Colors.white, + ), + ), + child: TextField( + controller: _searchController, + onChanged: (v) => + setState(() => _searchQuery = v.trim().toLowerCase()), + style: const TextStyle(color: Colors.white, fontSize: 14), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: 'Pesquisar aluno…', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: Icon( + Icons.close, + color: Colors.white.withValues(alpha: 0.7), + size: 18, + ), + ), + ], + ), ); } @@ -396,7 +407,6 @@ class _ClassStudentsInlineWidgetState extends State { required String studentName, required String studentEmail, required Timestamp? joinedAt, - required int index, }) { return Dismissible( key: Key(enrollmentDocId), @@ -410,7 +420,7 @@ class _ClassStudentsInlineWidgetState extends State { ), title: const Text('Remover aluno'), content: Text( - 'Tens a certeza que queres remover "$studentName" desta disciplina?', + 'Tens a certeza que queres remover "$studentName" desta turma?', ), actions: [ TextButton( @@ -427,41 +437,7 @@ class _ClassStudentsInlineWidgetState extends State { ); return confirmed ?? false; }, - onDismissed: (_) async { - try { - await FirebaseFirestore.instance - .collection('enrollments') - .doc(enrollmentDocId) - .delete(); - if (mounted) { - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - backgroundColor: cs.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - content: Text( - '"$studentName" foi removido da disciplina.', - style: const TextStyle(color: Colors.white), - ), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Erro ao remover: $e'))); - } - } - }, + onDismissed: (_) => _deleteEnrollment(enrollmentDocId, studentName), background: Container( decoration: BoxDecoration( color: cs.error.withValues(alpha: 0.85), @@ -480,7 +456,6 @@ class _ClassStudentsInlineWidgetState extends State { ), child: Row( children: [ - // Avatar with initial Container( width: 42, height: 42, @@ -500,7 +475,6 @@ class _ClassStudentsInlineWidgetState extends State { ), ), const SizedBox(width: 14), - // Info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -536,7 +510,6 @@ class _ClassStudentsInlineWidgetState extends State { ], ), ), - // Remove button IconButton( icon: Icon( Icons.person_remove_outlined, @@ -544,8 +517,7 @@ class _ClassStudentsInlineWidgetState extends State { size: 20, ), tooltip: 'Remover aluno', - onPressed: () => - _removeStudent(context, enrollmentDocId, studentName), + onPressed: () => _confirmRemove(enrollmentDocId, studentName), ), ], ), @@ -595,7 +567,7 @@ class _ClassStudentsInlineWidgetState extends State { ), const SizedBox(height: 6), Text( - 'Partilha o código da disciplina com os alunos.', + 'Partilha o código da turma com os alunos.', style: TextStyle( color: Colors.white.withValues(alpha: 0.45), fontSize: 13, diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 57fd31d..23d33b3 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -217,10 +217,12 @@ class _LoginPageState extends State { ? AppThemeExtras.of(context).authBackgroundGradient : [ Theme.of(context).colorScheme.background, - Theme.of(context).colorScheme.primary - .withOpacity(0.1), - Theme.of(context).colorScheme.secondary - .withOpacity(0.05), + Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + Theme.of( + context, + ).colorScheme.secondary.withOpacity(0.05), Theme.of(context).colorScheme.background, ], ), @@ -239,55 +241,26 @@ class _LoginPageState extends State { // Logo/Title Container( - padding: const EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withOpacity(0.9), - borderRadius: BorderRadius.circular(16.0), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10.0, - offset: const Offset(0, 4), - ), - ], + width: double.infinity, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFF9EEE8), + borderRadius: BorderRadius.all( + Radius.circular(20), + ), ), - child: Column( - children: [ - Text( - 'EPVC', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - foreground: Paint() - ..shader = - LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary, - Theme.of( - context, - ).colorScheme.secondary, - ], - ).createShader( - Rect.fromLTWH(0, 0, 200, 20), - ), + child: Center( + child: SizedBox( + width: 140, + height: 140, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/logo.png', + fit: BoxFit.cover, ), ), - const SizedBox(height: 8), - Text( - 'Escola Profissional de Vila do Conde', - style: TextStyle( - fontSize: 14, - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), ).animate().fadeIn( duration: const Duration(milliseconds: 800), diff --git a/lib/features/auth/presentation/pages/role_selection_page.dart b/lib/features/auth/presentation/pages/role_selection_page.dart index 6396446..12b607e 100644 --- a/lib/features/auth/presentation/pages/role_selection_page.dart +++ b/lib/features/auth/presentation/pages/role_selection_page.dart @@ -2,7 +2,6 @@ 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 '../../../../l10n/app_localizations.dart'; class RoleSelectionPage extends StatefulWidget { const RoleSelectionPage({super.key}); @@ -43,115 +42,61 @@ class _RoleSelectionPageState extends State { padding: const EdgeInsets.all(24.0), child: Column( children: [ - const SizedBox(height: 60), + const Spacer(flex: 1), - // Logo and title - Center( - child: Column( - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppThemeExtras.of(context) - .actionCardGradientStart, - AppThemeExtras.of(context) - .actionCardGradientEnd, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(25), - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.3), - blurRadius: 25, - offset: const Offset(0, 10), - ), - ], - ), - child: const Icon( - Icons.school, - size: 50, - color: Colors.white, - ), - ) - .animate() - .scale( - duration: const Duration(milliseconds: 800), - curve: Curves.elasticOut, - ) - .then() - .shimmer( - duration: const Duration(milliseconds: 2000), - color: Colors.white.withOpacity(0.4), - ), - - const SizedBox(height: 32), - - Text( - AppLocalizations.of(context)!.appTitle, - style: Theme.of(context).textTheme.headlineLarge - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ) - .animate() - .fadeIn( - duration: const Duration(milliseconds: 800), - delay: const Duration(milliseconds: 200), - ) - .slideY( - duration: const Duration(milliseconds: 600), - delay: const Duration(milliseconds: 200), - begin: -0.3, - ), - - const SizedBox(height: 12), - - ShaderMask( - shaderCallback: (bounds) => LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.secondary, - ], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ).createShader(bounds), - child: Text( - AppLocalizations.of(context)!.schoolName, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ) - .animate() - .fadeIn( - duration: const Duration(milliseconds: 800), - delay: const Duration(milliseconds: 400), - ) - .slideY( - duration: const Duration(milliseconds: 600), - delay: const Duration(milliseconds: 400), - begin: -0.2, - ), - ], + // Wide rectangle with image at top + Container( + width: double.infinity, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFF9EEE8), + borderRadius: BorderRadius.all(Radius.circular(20)), ), + child: Center( + child: SizedBox( + width: 140, + height: 140, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/logo.png', + fit: BoxFit.cover, + ), + ), + ), + ), + ).animate().fadeIn( + duration: const Duration(milliseconds: 800), + delay: const Duration(milliseconds: 200), ), - const Spacer(), + const SizedBox(height: 24), - // Role selection title + ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).createShader(bounds), + child: Text( + 'Assistente de estudo IA', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ).animate().fadeIn( + duration: const Duration(milliseconds: 800), + delay: const Duration(milliseconds: 300), + ), + + const SizedBox(height: 32), + + // Title Text( 'Quem é você?', style: Theme.of(context).textTheme.headlineMedium @@ -239,15 +184,16 @@ class _RoleSelectionPageState extends State { ? _handleContinue : null, style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: - Theme.of(context).colorScheme.onPrimary, + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, elevation: 4, - shadowColor: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.3), + shadowColor: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), @@ -290,6 +236,8 @@ class _RoleSelectionPageState extends State { ), const SizedBox(height: 32), + + const Spacer(flex: 1), ], ), ), @@ -338,7 +286,9 @@ class _RoleSelectionPageState extends State { border: Border.all( color: isSelected ? gradientColor - : Theme.of(context).colorScheme.primary.withOpacity(0.2), + : Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), width: isSelected ? 2 : 1, ), boxShadow: [ diff --git a/lib/features/auth/presentation/pages/signup_page.dart b/lib/features/auth/presentation/pages/signup_page.dart index 8319237..bc69205 100644 --- a/lib/features/auth/presentation/pages/signup_page.dart +++ b/lib/features/auth/presentation/pages/signup_page.dart @@ -270,57 +270,26 @@ class _SignupPageState extends State { // Logo/Title Container( - padding: const EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withOpacity(0.9), - borderRadius: BorderRadius.circular(16.0), - boxShadow: [ - BoxShadow( - color: Theme.of( - context, - ).colorScheme.shadow.withOpacity(0.1), - blurRadius: 10.0, - offset: const Offset(0, 4), - ), - ], + width: double.infinity, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFF9EEE8), + borderRadius: BorderRadius.all( + Radius.circular(20), + ), ), - child: Column( - children: [ - Text( - 'EPVC', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - foreground: Paint() - ..shader = - LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary, - Theme.of( - context, - ).colorScheme.secondary, - ], - ).createShader( - Rect.fromLTWH(0, 0, 200, 20), - ), + child: Center( + child: SizedBox( + width: 140, + height: 140, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/logo.png', + fit: BoxFit.cover, ), ), - const SizedBox(height: 8), - Text( - 'Escola Profissional de Vila do Conde', - style: TextStyle( - fontSize: 14, - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), ).animate().fadeIn( duration: const Duration(milliseconds: 800), diff --git a/lib/features/classes/presentation/pages/class_students_page.dart b/lib/features/classes/presentation/pages/class_students_page.dart index 878f800..cba3d7e 100644 --- a/lib/features/classes/presentation/pages/class_students_page.dart +++ b/lib/features/classes/presentation/pages/class_students_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../../core/services/auth_service.dart'; -/// Página para visualizar os alunos de uma disciplina específica +/// Página para visualizar os alunos de uma turma específica class ClassStudentsPage extends StatefulWidget { final String classId; final String className; @@ -97,7 +97,7 @@ class _ClassStudentsPageState extends State { ), const SizedBox(height: 12), Text( - 'Só podes ver os alunos das tuas próprias disciplinas.', + 'Só podes ver os alunos das tuas próprias turmas.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), textAlign: TextAlign.center, ), @@ -183,7 +183,7 @@ class _ClassStudentsPageState extends State { ), const SizedBox(height: 24), Text( - 'Nenhum aluno entrou nesta disciplina ainda.', + 'Nenhum aluno entrou nesta turma ainda.', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 16, @@ -192,7 +192,7 @@ class _ClassStudentsPageState extends State { ), const SizedBox(height: 8), Text( - 'Partilha o código da disciplina para os alunos se juntarem.', + 'Partilha o código da turma para os alunos se juntarem.', style: TextStyle( color: cs.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 13, @@ -215,6 +215,7 @@ class _ClassStudentsPageState extends State { final studentName = enrollment['studentName'] as String? ?? 'Aluno sem nome'; final joinedAt = enrollment['joinedAt'] as Timestamp?; + final enrollmentId = enrollments[index].id; return Container( decoration: BoxDecoration( @@ -263,6 +264,15 @@ class _ClassStudentsPageState extends State { ), ), ), + trailing: IconButton( + icon: Icon(Icons.delete_outline, color: cs.error), + onPressed: () => _showRemoveStudentDialog( + context, + enrollmentId, + studentName, + ), + tooltip: 'Remover aluno', + ), ), ); }, @@ -275,4 +285,82 @@ class _ClassStudentsPageState extends State { String _formatDate(DateTime date) { return DateFormat('dd/MM/yyyy').format(date); } + + Future _showRemoveStudentDialog( + BuildContext context, + String enrollmentId, + String studentName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remover Aluno'), + content: Text( + 'Tem a certeza que deseja remover $studentName desta turma?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Remover'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + await FirebaseFirestore.instance + .collection('enrollments') + .doc(enrollmentId) + .delete(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 12), + const Text('Aluno removido com sucesso'), + ], + ), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Text('Erro ao remover aluno: $e'), + ], + ), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 4), + ), + ); + } + } + } + } } diff --git a/lib/features/classes/presentation/pages/teacher_all_classes_page.dart b/lib/features/classes/presentation/pages/teacher_all_classes_page.dart index 323d2d6..0262a6f 100644 --- a/lib/features/classes/presentation/pages/teacher_all_classes_page.dart +++ b/lib/features/classes/presentation/pages/teacher_all_classes_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../dashboard/presentation/widgets/teacher_classes_list_widget.dart'; -/// Página dedicada para o professor ver todas as suas disciplinas +/// Página dedicada para o professor ver todas as suas turmas /// Reutiliza o TeacherClassesListWidget existente class TeacherAllClassesPage extends StatelessWidget { const TeacherAllClassesPage({super.key}); @@ -18,7 +18,7 @@ class TeacherAllClassesPage extends StatelessWidget { onPressed: () => Navigator.of(context).pop(), ), title: const Text( - 'As Minhas Disciplinas', + 'As Minhas Turmas', style: TextStyle( color: Colors.white, fontSize: 18, diff --git a/lib/features/dashboard/presentation/pages/teacher_dashboard_page.dart b/lib/features/dashboard/presentation/pages/teacher_dashboard_page.dart index ce66b6e..dc93c86 100644 --- a/lib/features/dashboard/presentation/pages/teacher_dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/teacher_dashboard_page.dart @@ -6,7 +6,6 @@ import '../../../../core/routing/app_router.dart'; import '../widgets/teacher_hero_widget.dart'; import '../widgets/teacher_quick_actions_widget.dart'; import '../widgets/teacher_classes_list_widget.dart'; -import '../widgets/teacher_analytics_preview_widget.dart'; class TeacherDashboardPage extends StatefulWidget { const TeacherDashboardPage({super.key}); @@ -162,11 +161,6 @@ class _TeacherDashboardPageState extends State { // Classes List Section const TeacherClassesListWidget(), - const SizedBox(height: 24), - - // Analytics Preview Section - const TeacherAnalyticsPreviewWidget(), - const SizedBox(height: 40), ], ), diff --git a/lib/features/dashboard/presentation/widgets/dashboard_action_card.dart b/lib/features/dashboard/presentation/widgets/dashboard_action_card.dart index d97f8c7..f150a14 100644 --- a/lib/features/dashboard/presentation/widgets/dashboard_action_card.dart +++ b/lib/features/dashboard/presentation/widgets/dashboard_action_card.dart @@ -245,7 +245,7 @@ class DashboardActionCard extends StatelessWidget { } } -/// Surface-styled vertical card (Quiz, Criar Disciplina, etc.). +/// Surface-styled vertical card (Quiz, Criar Turma, etc.). class DashboardActionCardSurface extends StatelessWidget { const DashboardActionCardSurface({ super.key, diff --git a/lib/features/dashboard/presentation/widgets/progress_hero_widget.dart b/lib/features/dashboard/presentation/widgets/progress_hero_widget.dart index 5ce89d0..736b550 100644 --- a/lib/features/dashboard/presentation/widgets/progress_hero_widget.dart +++ b/lib/features/dashboard/presentation/widgets/progress_hero_widget.dart @@ -187,24 +187,27 @@ class _ProgressHeroWidgetState extends State { const SizedBox(height: 16), // Progress Bar - Container( - height: 12, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(6), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: overallProgress, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppThemeExtras.of(context).heroProgressStart, - AppThemeExtras.of(context).heroProgressEnd, - ], + GestureDetector( + onTap: () => _showProgressExplanation(context), + child: Container( + height: 12, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(6), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: overallProgress, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppThemeExtras.of(context).heroProgressStart, + AppThemeExtras.of(context).heroProgressEnd, + ], + ), + borderRadius: BorderRadius.circular(6), ), - borderRadius: BorderRadius.circular(6), ), ), ), @@ -215,10 +218,14 @@ class _ProgressHeroWidgetState extends State { Row( children: [ Expanded( - child: _buildStatCard( - icon: Icons.access_time, - value: '${(studyTimeMinutes / 60).toStringAsFixed(1)}h', - label: 'Tempo de Estudo', + child: GestureDetector( + onTap: () => _showStudyTimeDetails(context, userStats), + child: _buildStatCard( + icon: Icons.access_time, + value: + '${(studyTimeMinutes / 60).toStringAsFixed(1)}h', + label: 'Tempo de Estudo', + ), ), ), const SizedBox(width: 12), @@ -362,4 +369,111 @@ class _ProgressHeroWidgetState extends State { ), ); } + + void _showProgressExplanation(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Icon(Icons.info_outline, color: cs.primary), + const SizedBox(width: 8), + const Text('Progresso Geral'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'O Progresso Geral representa a média dos níveis de domínio dos conceitos que já dominaste.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Como é calculado:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + SizedBox(height: 8), + Text( + '• Cada conceito tem um nível de 0 a 100\n' + '• O progresso é a média de todos os conceitos dominados\n' + '• Quanto mais alto, melhor o teu domínio', + style: TextStyle(fontSize: 13, color: Colors.grey), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Entendi'), + ), + ], + ), + ); + } + + void _showStudyTimeDetails(BuildContext context, UserStats? userStats) { + final cs = Theme.of(context).colorScheme; + final totalMinutes = userStats?.totalStudyTime ?? 0; + final weeklyMinutes = userStats?.weeklyStudyTime ?? 0; + final monthlyMinutes = userStats?.monthlyStudyTime ?? 0; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Icon(Icons.access_time, color: cs.primary), + const SizedBox(width: 8), + const Text('Tempo de Estudo'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTimeRow('Total', totalMinutes, cs), + const SizedBox(height: 12), + _buildTimeRow('Esta semana', weeklyMinutes, cs), + const SizedBox(height: 12), + _buildTimeRow('Este mês', monthlyMinutes, cs), + const SizedBox(height: 16), + const Text( + 'O tempo é contado automaticamente quando completas quizzes.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fechar'), + ), + ], + ), + ); + } + + Widget _buildTimeRow(String label, int minutes, ColorScheme cs) { + final hours = minutes ~/ 60; + final mins = minutes % 60; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 14, color: cs.onSurface)), + Text( + hours > 0 ? '${hours}h ${mins}min' : '${mins}min', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: cs.primary, + ), + ), + ], + ); + } } diff --git a/lib/features/dashboard/presentation/widgets/teacher_analytics_preview_widget.dart b/lib/features/dashboard/presentation/widgets/teacher_analytics_preview_widget.dart index 25df017..4c96fc8 100644 --- a/lib/features/dashboard/presentation/widgets/teacher_analytics_preview_widget.dart +++ b/lib/features/dashboard/presentation/widgets/teacher_analytics_preview_widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.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'; @@ -11,10 +10,12 @@ class TeacherAnalyticsPreviewWidget extends StatefulWidget { const TeacherAnalyticsPreviewWidget({super.key}); @override - State createState() => _TeacherAnalyticsPreviewWidgetState(); + State createState() => + _TeacherAnalyticsPreviewWidgetState(); } -class _TeacherAnalyticsPreviewWidgetState extends State { +class _TeacherAnalyticsPreviewWidgetState + extends State { List _topStudents = []; bool _loading = true; @@ -36,7 +37,7 @@ class _TeacherAnalyticsPreviewWidgetState extends State allStudents = []; - + // Buscar ranking de cada turma for (final classDoc in classesSnapshot.docs) { final classId = classDoc.id; @@ -67,232 +68,130 @@ class _TeacherAnalyticsPreviewWidgetState extends State 20) ...[ - const SizedBox(width: 8), - Icon( - Icons.more_horiz, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - size: 16, - ), - ], - ], + Text( + '12 conteúdos verificados • 2 pendentes de revisão', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, ), ), ], ), ), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.secondary.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.settings, - color: Theme.of(context).colorScheme.secondary, - size: 20, - ), - ), ], ), - const SizedBox(height: 20), - - // Quick Stats Row - Row( - children: [ - _buildQuickStat( - icon: Icons.check_circle, - label: 'Alunos Ativos', - value: '18/24', - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 12), - _buildQuickStat( - icon: Icons.warning_amber, - label: 'Precisam Apoio', - value: '3', - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: 12), - _buildQuickStat( - icon: Icons.emoji_events, - label: 'Média Disciplina', - value: '72%', - color: Theme.of(context).colorScheme.primary.withOpacity(0.8), - ), - ], - ), - const SizedBox(height: 20), - - // Top Performing Students Preview - Row( - children: [ - Icon( - Icons.leaderboard, - color: Theme.of(context).colorScheme.secondary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Melhores Desempenhos', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - - // Student List Preview - if (_loading) - const Center(child: CircularProgressIndicator()) - else if (_topStudents.isEmpty) - const Center( - child: Text( - 'Nenhum aluno encontrado', - style: TextStyle(fontSize: 14), - ), - ) - else - ..._topStudents.asMap().entries.map((entry) { - final index = entry.key; - final student = entry.value; - final color = _getStudentColor(context, index); - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _buildStudentPerformanceItem( - context, - student.studentName, - student.overallScore.toInt(), - color, - ), - ); - }).toList(), - - const SizedBox(height: 20), - - // Content Quality Alert - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.2), - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).colorScheme.primary, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Qualidade do Conteúdo', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - Text( - '12 conteúdos verificados • 2 pendentes de revisão', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), + ), + ], ); } diff --git a/lib/features/dashboard/presentation/widgets/teacher_classes_list_widget.dart b/lib/features/dashboard/presentation/widgets/teacher_classes_list_widget.dart index de53412..67b0c6a 100644 --- a/lib/features/dashboard/presentation/widgets/teacher_classes_list_widget.dart +++ b/lib/features/dashboard/presentation/widgets/teacher_classes_list_widget.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../../../../core/services/auth_service.dart'; import '../../../classes/presentation/pages/class_students_page.dart'; -/// Widget para listar as disciplinas criadas pelo professor +/// Widget para listar as turmas criadas pelo professor class TeacherClassesListWidget extends StatelessWidget { const TeacherClassesListWidget({super.key}); @@ -44,7 +44,7 @@ class TeacherClassesListWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Text( - 'Ainda não criaste nenhuma disciplina.', + 'Ainda não criaste nenhuma turma.', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14, @@ -65,7 +65,7 @@ class TeacherClassesListWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - 'As Minhas Disciplinas', + 'As Minhas Turmas', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 20, @@ -144,7 +144,7 @@ class TeacherClassesListWidget extends StatelessWidget { Padding( padding: const EdgeInsets.all(20), child: Text( - 'As Minhas Disciplinas', + 'As Minhas Turmas', style: TextStyle( color: cs.onSurface, fontSize: 20, diff --git a/lib/features/dashboard/presentation/widgets/teacher_hero_widget.dart b/lib/features/dashboard/presentation/widgets/teacher_hero_widget.dart index fc26c6b..0b57cc9 100644 --- a/lib/features/dashboard/presentation/widgets/teacher_hero_widget.dart +++ b/lib/features/dashboard/presentation/widgets/teacher_hero_widget.dart @@ -31,7 +31,7 @@ class _TeacherHeroWidgetState extends State { final user = AuthService.currentUser; if (user == null) return; - // Obter disciplinas do professor + // Obter turmas do professor final classesSnapshot = await FirebaseFirestore.instance .collection('classes') .where('teacherId', isEqualTo: user.uid) @@ -116,7 +116,7 @@ class _TeacherHeroWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Visão Geral da Disciplina', + 'Visão Geral da Turma', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 20, @@ -193,7 +193,7 @@ class _TeacherHeroWidgetState extends State { children: [ const Flexible( child: Text( - 'Progresso Médio da Disciplina', + 'Progresso Médio da Turma', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -226,24 +226,27 @@ class _TeacherHeroWidgetState extends State { const SizedBox(height: 16), // Progress Bar - Container( - height: 12, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(6), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: classAverageProgress, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppThemeExtras.of(context).heroProgressStart, - AppThemeExtras.of(context).heroProgressEnd, - ], + GestureDetector( + onTap: () => _showProgressExplanation(context), + child: Container( + height: 12, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(6), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: classAverageProgress, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppThemeExtras.of(context).heroProgressStart, + AppThemeExtras.of(context).heroProgressEnd, + ], + ), + borderRadius: BorderRadius.circular(6), ), - borderRadius: BorderRadius.circular(6), ), ), ), @@ -369,14 +372,14 @@ class _TeacherHeroWidgetState extends State { _buildActivityItem( context, 'Nenhuma atividade recente', - 'Comece criando disciplinas e conteúdos', + 'Comece criando turmas e conteúdos', Theme.of(context).colorScheme.onSurfaceVariant, ), ); return activities; } - // Adicionar atividades baseadas nas estatísticas das disciplinas + // Adicionar atividades baseadas nas estatísticas das turmas for (final stats in _classStats.take(3)) { if (stats.activeQuizzes > 0) { activities.add( @@ -425,7 +428,7 @@ class _TeacherHeroWidgetState extends State { _buildActivityItem( context, 'Nenhuma atividade recente', - 'Comece criando disciplinas e conteúdos', + 'Comece criando turmas e conteúdos', Theme.of(context).colorScheme.onSurfaceVariant, ), ] @@ -473,4 +476,24 @@ class _TeacherHeroWidgetState extends State { ], ); } + + void _showProgressExplanation(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Progresso Médio da Turma'), + content: const Text( + 'O progresso médio da turma é calculado com base no domínio dos conceitos por cada aluno. ' + 'Cada aluno tem um nível de domínio para cada conceito (0-100%), e o progresso médio ' + 'é a média de todos esses níveis de domínio em toda a turma.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Entendi'), + ), + ], + ), + ); + } } diff --git a/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart b/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart index fced2cb..c88672e 100644 --- a/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart +++ b/lib/features/dashboard/presentation/widgets/teacher_quick_actions_widget.dart @@ -20,7 +20,7 @@ class TeacherQuickActionsWidget extends StatefulWidget { class _TeacherQuickActionsWidgetState extends State { bool _isCreatingClass = false; - /// Mesmas dimensões dos cards em "As Minhas Disciplinas". + /// Mesmas dimensões dos cards em "As Minhas Turmas". static const double _scrollCardWidth = 200; static const double _scrollRowHeight = 156; static const double _cardMinHeight = 156; @@ -128,7 +128,7 @@ class _TeacherQuickActionsWidgetState extends State { final cs = Theme.of(context).colorScheme; return DashboardActionCardSurface( title: 'Analytics', - subtitle: 'Desempenho da disciplina', + subtitle: 'Desempenho da turma', icon: Icons.analytics, minHeight: _cardMinHeight, titleFontSize: _titleFontSize, @@ -142,7 +142,7 @@ class _TeacherQuickActionsWidgetState extends State { color: cs.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), - child: Icon(Icons.analytics, color: cs.primary, size: _iconSize), + child: const Icon(Icons.analytics, color: Colors.blue, size: 28), ), onTap: () => context.go('/teacher/analytics'), ); @@ -153,7 +153,7 @@ class _TeacherQuickActionsWidgetState extends State { return ClipRRect( borderRadius: BorderRadius.circular(16), child: DashboardActionCardSurface( - title: 'Criar Disciplina', + title: 'Criar Turma', subtitle: 'Gerar código de acesso', icon: Icons.school, minHeight: _cardMinHeight, @@ -202,7 +202,7 @@ class _TeacherQuickActionsWidgetState extends State { }, ), _TeacherActionItem( - title: 'Criar Disciplina', + title: 'Criar Turma', subtitle: 'Gerar código de acesso', icon: Icons.school, onTap: () { @@ -221,7 +221,7 @@ class _TeacherQuickActionsWidgetState extends State { ), _TeacherActionItem( title: 'Analytics', - subtitle: 'Desempenho da disciplina', + subtitle: 'Desempenho da turma', icon: Icons.analytics, onTap: () { Navigator.pop(context); @@ -367,7 +367,7 @@ class _TeacherQuickActionsWidgetState extends State { borderRadius: BorderRadius.circular(16), ), title: Text( - 'Criar Nova Disciplina', + 'Criar Nova Turma', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, @@ -378,7 +378,7 @@ class _TeacherQuickActionsWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Nome da disciplina:', + 'Nome da turma:', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14, @@ -432,7 +432,7 @@ class _TeacherQuickActionsWidgetState extends State { ), const SizedBox(width: 10), Text( - 'A carregar disciplinas...', + 'A carregar turmas...', style: TextStyle( color: Theme.of( context, @@ -571,7 +571,7 @@ class _TeacherQuickActionsWidgetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Disciplina "$className" criada com sucesso! Código: $classCode', + 'Turma "$className" criada com sucesso! Código: $classCode', ), backgroundColor: Theme.of(context).colorScheme.primary, behavior: SnackBarBehavior.floating, @@ -585,7 +585,7 @@ class _TeacherQuickActionsWidgetState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Erro ao criar disciplina: $e'), + content: Text('Erro ao criar turma: $e'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index 6c5835c..f79e214 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -2374,11 +2374,13 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { late List _chosen; bool _submitted = false; bool _saving = false; + DateTime? _startTime; @override void initState() { super.initState(); _chosen = List.filled(widget.questions.length, -1); + _startTime = DateTime.now(); } void _selectOption(int idx) { @@ -2427,6 +2429,19 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { totalQuestions: widget.questions.length, materialName: widget.title, ); + + // Registrar tempo de estudo real + if (_startTime != null) { + final elapsedDuration = DateTime.now().difference(_startTime!); + final elapsedMinutes = elapsedDuration.inMinutes; + final elapsedSeconds = elapsedDuration.inSeconds % 60; + if (elapsedMinutes > 0 || elapsedSeconds > 0) { + Logger.info( + 'Quiz study time recorded: ${elapsedMinutes}m ${elapsedSeconds}s', + ); + await GamificationService.recordStudyTime(user.uid, elapsedMinutes); + } + } } } catch (e) { Logger.error('Error saving quiz result: $e'); @@ -2837,11 +2852,13 @@ class _TeacherQuizInteractiveSheetState late List _chosen; bool _submitted = false; bool _saving = false; + DateTime? _startTime; @override void initState() { super.initState(); _chosen = List.filled(widget.questions.length, -1); + _startTime = DateTime.now(); } void _selectOption(int idx) { @@ -2914,6 +2931,19 @@ class _TeacherQuizInteractiveSheetState totalQuestions: total, materialName: matName, ); + + // Registrar tempo de estudo real + if (_startTime != null) { + final elapsedDuration = DateTime.now().difference(_startTime!); + final elapsedMinutes = elapsedDuration.inMinutes; + final elapsedSeconds = elapsedDuration.inSeconds % 60; + if (elapsedMinutes > 0 || elapsedSeconds > 0) { + Logger.info( + 'Quiz study time recorded: ${elapsedMinutes}m ${elapsedSeconds}s', + ); + await GamificationService.recordStudyTime(user.uid, elapsedMinutes); + } + } } } catch (e) { Logger.error('Error submitting teacher quiz result: $e'); diff --git a/lib/features/splash/presentation/pages/splash_page.dart b/lib/features/splash/presentation/pages/splash_page.dart index eb5d716..63d26af 100644 --- a/lib/features/splash/presentation/pages/splash_page.dart +++ b/lib/features/splash/presentation/pages/splash_page.dart @@ -231,10 +231,12 @@ class _SplashPageState extends State { ), ], ), - child: const Icon( - Icons.school, - size: 40, - color: Colors.white, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + 'assets/images/epvc.png', + fit: BoxFit.cover, + ), ), ) .animate() diff --git a/pubspec.lock b/pubspec.lock index 2674898..2161b40 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -587,6 +595,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -871,6 +887,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" image_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ea386b2..2bdb79c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,9 @@ dependencies: shimmer: ^3.0.0 flutter_staggered_animations: ^1.1.1 + # App Launcher Icons + flutter_launcher_icons: ^0.13.1 + # HTTP & Networking dio: ^5.4.0 http: ^1.1.2 @@ -148,3 +151,10 @@ flutter: # weight: 600 # - asset: assets/fonts/Inter-Bold.ttf # weight: 700 + +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "assets/images/epvc.png" + adaptive_icon_background: "#F9EEE8" + adaptive_icon_foreground: "assets/images/epvc.png"