Mudanças na aba de quiz
This commit is contained in:
@@ -21,7 +21,8 @@ class MaterialsRAGService {
|
||||
|
||||
/// Listar materiais disponíveis para o aluno autenticado
|
||||
/// Retorna apenas materiais cujo classId corresponde a uma turma onde o aluno está inscrito
|
||||
static Future<List<Map<String, String>>> getAvailableMaterialsForStudent() async {
|
||||
static Future<List<Map<String, String>>>
|
||||
getAvailableMaterialsForStudent() async {
|
||||
try {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return [];
|
||||
@@ -62,7 +63,13 @@ class MaterialsRAGService {
|
||||
final classId = data['classId'] as String?;
|
||||
if (classId == null || enrolledClassIds.contains(classId)) {
|
||||
final fileName = data['fileName'] as String? ?? 'Material';
|
||||
result.add({'id': doc.id, 'name': fileName});
|
||||
final teacherId = data['teacherId'] as String?;
|
||||
result.add({
|
||||
'id': doc.id,
|
||||
'name': fileName,
|
||||
if (classId != null) 'classId': classId,
|
||||
if (teacherId != null) 'teacherId': teacherId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +127,12 @@ class MaterialsRAGService {
|
||||
// Usar cache do texto completo se disponível (sufixo v2 invalida caches antigos)
|
||||
final cacheKey = '${fileName}_v6';
|
||||
String fullText;
|
||||
if (_chunksCache.containsKey(cacheKey) && _chunksCache[cacheKey]!.isNotEmpty) {
|
||||
if (_chunksCache.containsKey(cacheKey) &&
|
||||
_chunksCache[cacheKey]!.isNotEmpty) {
|
||||
fullText = _chunksCache[cacheKey]!.first;
|
||||
Logger.info('Using cached text for $fileName: ${fullText.length} chars');
|
||||
Logger.info(
|
||||
'Using cached text for $fileName: ${fullText.length} chars',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
final teacherId = data['teacherId'] as String?;
|
||||
@@ -164,7 +174,9 @@ class MaterialsRAGService {
|
||||
fullText = cleaned;
|
||||
// Guardar texto completo no cache com key versionada
|
||||
_chunksCache[cacheKey] = [fullText];
|
||||
Logger.info('PDF "$fileName" -> ${fullText.length} chars extracted');
|
||||
Logger.info(
|
||||
'PDF "$fileName" -> ${fullText.length} chars extracted',
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Error extracting text from $fileName: $e');
|
||||
continue;
|
||||
@@ -176,9 +188,15 @@ class MaterialsRAGService {
|
||||
final String context;
|
||||
if (fullText.length <= 10000) {
|
||||
context = fullText;
|
||||
Logger.info('Small PDF — sending full text (${fullText.length} chars)');
|
||||
Logger.info(
|
||||
'Small PDF — sending full text (${fullText.length} chars)',
|
||||
);
|
||||
} else {
|
||||
final windows = _extractKeywordWindows(fullText, userQuery, _maxRelevantChunks);
|
||||
final windows = _extractKeywordWindows(
|
||||
fullText,
|
||||
userQuery,
|
||||
_maxRelevantChunks,
|
||||
);
|
||||
context = windows.join('\n\n---\n\n');
|
||||
Logger.info('Large PDF — keyword windows: ${windows.length}');
|
||||
}
|
||||
@@ -205,7 +223,11 @@ class MaterialsRAGService {
|
||||
/// Método legacy - mantido para compatibilidade mas usa chunk retrieval
|
||||
@Deprecated('Use getRelevantChunks with userQuery instead')
|
||||
static Future<String> getMaterialsContext({int maxMaterials = 5}) async {
|
||||
return getRelevantChunks(userQuery: '', maxMaterials: maxMaterials, maxChunks: 3);
|
||||
return getRelevantChunks(
|
||||
userQuery: '',
|
||||
maxMaterials: maxMaterials,
|
||||
maxChunks: 3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get teacher IDs from student's enrolled classes
|
||||
@@ -239,11 +261,11 @@ class MaterialsRAGService {
|
||||
|
||||
// 3. Buscar turmas e extrair teacherIds
|
||||
final Set<String> teacherIds = {};
|
||||
|
||||
|
||||
// Firestore whereIn limit is 10, so process in batches if needed
|
||||
for (int i = 0; i < classIds.length; i += 10) {
|
||||
final batch = classIds.skip(i).take(10).toList();
|
||||
|
||||
|
||||
final classSnapshot = await _firestore
|
||||
.collection('classes')
|
||||
.where(FieldPath.documentId, whereIn: batch)
|
||||
@@ -273,7 +295,10 @@ class MaterialsRAGService {
|
||||
|
||||
/// Extrair texto real do PDF usando Firebase Storage SDK + syncfusion_flutter_pdf
|
||||
/// Usa getData() para descarregar o ficheiro completo (sem truncar a meio do stream)
|
||||
static Future<String> _extractFullText(String fileName, String teacherId) async {
|
||||
static Future<String> _extractFullText(
|
||||
String fileName,
|
||||
String teacherId,
|
||||
) async {
|
||||
PdfDocument? document;
|
||||
try {
|
||||
final ref = _storage
|
||||
@@ -306,12 +331,16 @@ class MaterialsRAGService {
|
||||
for (int i = startPage; i < totalPages; i++) {
|
||||
if (buffer.length >= _maxExtractedChars) break;
|
||||
try {
|
||||
final pageText = extractor.extractText(startPageIndex: i, endPageIndex: i).trim();
|
||||
final pageText = extractor
|
||||
.extractText(startPageIndex: i, endPageIndex: i)
|
||||
.trim();
|
||||
if (pageText.length < 80) continue;
|
||||
final lowerText = pageText.toLowerCase();
|
||||
final pipeCount = '|'.allMatches(pageText).length;
|
||||
final isStructurePage = pipeCount > 3 ||
|
||||
(lowerText.contains('table of contents') && pageText.length < 800) ||
|
||||
final isStructurePage =
|
||||
pipeCount > 3 ||
|
||||
(lowerText.contains('table of contents') &&
|
||||
pageText.length < 800) ||
|
||||
(lowerText.contains('copyright') && pageText.length < 400) ||
|
||||
(lowerText.contains('color insert') && pageText.length < 400) ||
|
||||
lowerText.contains('just light novels') ||
|
||||
@@ -355,8 +384,12 @@ class MaterialsRAGService {
|
||||
? fullText.substring(0, _maxExtractedChars)
|
||||
: fullText;
|
||||
|
||||
Logger.info('Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)');
|
||||
Logger.info('Text preview: ${result.length > 200 ? result.substring(0, 200) : result}');
|
||||
Logger.info(
|
||||
'Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)',
|
||||
);
|
||||
Logger.info(
|
||||
'Text preview: ${result.length > 200 ? result.substring(0, 200) : result}',
|
||||
);
|
||||
return result.trim();
|
||||
} catch (e) {
|
||||
Logger.error('Error extracting text from $fileName: $e');
|
||||
@@ -381,10 +414,9 @@ class MaterialsRAGService {
|
||||
|
||||
// Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars)
|
||||
// Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François")
|
||||
final properNouns = RegExp(r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b')
|
||||
.allMatches(userQuery)
|
||||
.map((m) => m.group(0)!.toLowerCase())
|
||||
.toSet();
|
||||
final properNouns = RegExp(
|
||||
r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b',
|
||||
).allMatches(userQuery).map((m) => m.group(0)!.toLowerCase()).toSet();
|
||||
final generalKeywords = userQuery
|
||||
.toLowerCase()
|
||||
.split(RegExp(r'[^\w]'))
|
||||
@@ -428,7 +460,9 @@ class MaterialsRAGService {
|
||||
lastEnd = end;
|
||||
}
|
||||
|
||||
Logger.info('Keyword windows found: ${windows.length} for query "$userQuery"');
|
||||
Logger.info(
|
||||
'Keyword windows found: ${windows.length} for query "$userQuery"',
|
||||
);
|
||||
return windows;
|
||||
}
|
||||
|
||||
@@ -436,15 +470,15 @@ class MaterialsRAGService {
|
||||
static List<String> _chunkText(String text, int chunkSize, int overlap) {
|
||||
final List<String> chunks = [];
|
||||
final int textLength = text.length;
|
||||
|
||||
|
||||
if (textLength <= chunkSize) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
|
||||
int start = 0;
|
||||
while (start < textLength) {
|
||||
int end = start + chunkSize;
|
||||
|
||||
|
||||
if (end >= textLength) {
|
||||
end = textLength;
|
||||
} else {
|
||||
@@ -456,68 +490,70 @@ class MaterialsRAGService {
|
||||
end = start + chunkSize; // Forçar quebra se não encontrar espaço
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
chunks.add(text.substring(start, end).trim());
|
||||
|
||||
|
||||
// Avançar com overlap
|
||||
start = end - overlap;
|
||||
if (start >= end) break; // Prevenir loop infinito
|
||||
}
|
||||
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/// Selecionar chunks mais relevantes usando keyword matching simples
|
||||
static List<String> _selectRelevantChunks(
|
||||
List<String> chunks,
|
||||
String userQuery,
|
||||
List<String> chunks,
|
||||
String userQuery,
|
||||
int maxChunks,
|
||||
) {
|
||||
if (userQuery.isEmpty || chunks.isEmpty) {
|
||||
// Se não há query, retornar primeiros chunks
|
||||
return chunks.take(maxChunks).toList();
|
||||
}
|
||||
|
||||
|
||||
// Extrair keywords da query (palavras com mais de 3 caracteres)
|
||||
final queryWords = userQuery
|
||||
.toLowerCase()
|
||||
.split(RegExp(r'[^\w]'))
|
||||
.where((w) => w.length > 3)
|
||||
.toSet();
|
||||
|
||||
|
||||
if (queryWords.isEmpty) {
|
||||
return chunks.take(maxChunks).toList();
|
||||
}
|
||||
|
||||
|
||||
// Calcular score para cada chunk
|
||||
final List<MapEntry<String, int>> scoredChunks = [];
|
||||
|
||||
|
||||
for (final chunk in chunks) {
|
||||
final chunkLower = chunk.toLowerCase();
|
||||
int score = 0;
|
||||
|
||||
|
||||
for (final word in queryWords) {
|
||||
// Contar ocorrências da palavra no chunk
|
||||
final matches = word.allMatches(chunkLower).length;
|
||||
score += matches * 10; // Peso por ocorrência
|
||||
|
||||
|
||||
// Bonus se a palavra estiver no início do chunk
|
||||
if (chunkLower.startsWith(word)) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bonus por tamanho do chunk (preferir chunks mais completos)
|
||||
score += (chunk.length / 100).floor();
|
||||
|
||||
|
||||
scoredChunks.add(MapEntry(chunk, score));
|
||||
}
|
||||
|
||||
|
||||
// Ordenar por score decrescente
|
||||
scoredChunks.sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
Logger.info('Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}');
|
||||
|
||||
|
||||
Logger.info(
|
||||
'Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}',
|
||||
);
|
||||
|
||||
// Retornar os N chunks mais relevantes
|
||||
return scoredChunks.take(maxChunks).map((e) => e.key).toList();
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
_buildTutorIACard(context),
|
||||
_buildQuizCard(context),
|
||||
_buildAchievementsCard(context),
|
||||
_buildQuizManagementCard(context),
|
||||
];
|
||||
|
||||
return Column(
|
||||
@@ -70,8 +69,6 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
SizedBox(width: _scrollCardWidth, child: cards[1]),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: _scrollCardWidth, child: cards[2]),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: _scrollCardWidth, child: cards[3]),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -166,32 +163,6 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuizManagementCard(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child:
|
||||
DashboardActionCardSurface(
|
||||
title: 'Gerenciar Quizzes',
|
||||
subtitle: 'Ver histórico ou eliminar',
|
||||
icon: Icons.manage_history,
|
||||
minHeight: _cardMinHeight,
|
||||
titleFontSize: _titleFontSize,
|
||||
subtitleFontSize: _subtitleFontSize,
|
||||
iconSize: _iconSize,
|
||||
padding: _cardPadding,
|
||||
iconColor: cs.tertiary,
|
||||
onTap: () => context.go('/quiz-management'),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
)
|
||||
.then(delay: const Duration(milliseconds: 250)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJoinClassCard(BuildContext context) {
|
||||
return DashboardActionCard(
|
||||
title: 'Entrar numa Disciplina',
|
||||
@@ -245,15 +216,6 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
context.go('/student/achievements');
|
||||
},
|
||||
),
|
||||
_QuickAccessItem(
|
||||
title: 'Gerenciar Quizzes',
|
||||
subtitle: 'Ver histórico ou eliminar',
|
||||
icon: Icons.manage_history,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/quiz-management');
|
||||
},
|
||||
),
|
||||
_QuickAccessItem(
|
||||
title: 'Entrar numa Disciplina',
|
||||
subtitle: 'Junta-te a uma disciplina com o código',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,10 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
||||
bool _loading = true;
|
||||
String _userRole = '';
|
||||
|
||||
// Disciplina seleccionada (null = vista de disciplinas)
|
||||
String? _selectedDisciplineId;
|
||||
Map<String, String> _classNames = {}; // classId → name
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -116,16 +120,37 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
||||
}
|
||||
|
||||
final snapshot = await query.get();
|
||||
final quizzes = snapshot.docs
|
||||
.map((doc) {
|
||||
final data = Map<String, dynamic>.from(doc.data() as Map);
|
||||
data['id'] = doc.id;
|
||||
return data;
|
||||
})
|
||||
.cast<Map<String, dynamic>>()
|
||||
.toList();
|
||||
|
||||
// Obter nomes das disciplinas
|
||||
final classIdSet = <String>{};
|
||||
for (final q in quizzes) {
|
||||
final ids = (q['classIds'] as List?)?.cast<String>() ?? [];
|
||||
classIdSet.addAll(ids);
|
||||
}
|
||||
final classNamesMap = <String, String>{};
|
||||
if (classIdSet.isNotEmpty) {
|
||||
final docs = await Future.wait(
|
||||
classIdSet.map(
|
||||
(id) =>
|
||||
FirebaseFirestore.instance.collection('classes').doc(id).get(),
|
||||
),
|
||||
);
|
||||
for (final doc in docs.where((d) => d.exists)) {
|
||||
classNamesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_quizHistory = snapshot.docs
|
||||
.map((doc) {
|
||||
final data = Map<String, dynamic>.from(doc.data() as Map);
|
||||
data['id'] = doc.id;
|
||||
return data;
|
||||
})
|
||||
.cast<Map<String, dynamic>>()
|
||||
.toList();
|
||||
_quizHistory = quizzes;
|
||||
_classNames = classNamesMap;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -277,61 +302,222 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> _groupByDiscipline() {
|
||||
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||
for (final quiz in _quizHistory) {
|
||||
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
|
||||
String? groupId = quizClassIds.cast<String?>().firstWhere(
|
||||
(cid) => cid != null && _classNames.containsKey(cid),
|
||||
orElse: () => null,
|
||||
);
|
||||
groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__';
|
||||
groups.putIfAbsent(groupId, () => []).add(quiz);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_userRole == 'teacher' ? 'Gerenciar Quizzes' : 'Meu Histórico',
|
||||
),
|
||||
return PopScope(
|
||||
canPop: _selectedDisciplineId == null,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && _selectedDisciplineId != null) {
|
||||
setState(() => _selectedDisciplineId = null);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
foregroundColor: cs.onSurface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
context.go(
|
||||
_userRole == 'teacher'
|
||||
? '/teacher-dashboard'
|
||||
: '/student-dashboard',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [cs.primary.withValues(alpha: 0.05), cs.surface],
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_userRole == 'teacher' ? 'Gerenciar Quizzes' : 'Meu Histórico',
|
||||
),
|
||||
backgroundColor: cs.surface,
|
||||
foregroundColor: cs.onSurface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
if (_userRole == 'teacher' && _selectedDisciplineId != null) {
|
||||
setState(() => _selectedDisciplineId = null);
|
||||
return;
|
||||
}
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
context.go(
|
||||
_userRole == 'teacher'
|
||||
? '/teacher-dashboard'
|
||||
: '/student-dashboard',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _quizHistory.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _quizHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final quiz = _quizHistory[index];
|
||||
return _buildQuizCard(quiz)
|
||||
.animate()
|
||||
.slideX(duration: const Duration(milliseconds: 300))
|
||||
.then(delay: Duration(milliseconds: index * 50));
|
||||
},
|
||||
),
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [cs.primary.withValues(alpha: 0.05), cs.surface],
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _quizHistory.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _userRole == 'teacher'
|
||||
? _buildTeacherBody(cs)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _quizHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final quiz = _quizHistory[index];
|
||||
return _buildQuizCard(quiz)
|
||||
.animate()
|
||||
.slideX(duration: const Duration(milliseconds: 300))
|
||||
.then(delay: Duration(milliseconds: index * 50));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeacherBody(ColorScheme cs) {
|
||||
final groups = _groupByDiscipline();
|
||||
|
||||
// Vista de quizzes de uma disciplina
|
||||
if (_selectedDisciplineId != null) {
|
||||
final quizzes = groups[_selectedDisciplineId] ?? [];
|
||||
final disciplineName =
|
||||
_classNames[_selectedDisciplineId] ??
|
||||
(_selectedDisciplineId == '__geral__'
|
||||
? 'Geral'
|
||||
: _selectedDisciplineId!);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
||||
onPressed: () => setState(() => _selectedDisciplineId = null),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
disciplineName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${quizzes.length} quiz${quizzes.length != 1 ? 'zes' : ''}',
|
||||
style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: quizzes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final quiz = quizzes[index];
|
||||
return _buildQuizCard(quiz)
|
||||
.animate()
|
||||
.slideX(duration: const Duration(milliseconds: 300))
|
||||
.then(delay: Duration(milliseconds: index * 50));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Vista de disciplinas
|
||||
final disciplineIds = groups.keys.toList();
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: disciplineIds.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final dId = disciplineIds[i];
|
||||
final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId);
|
||||
final count = groups[dId]!.length;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _selectedDisciplineId = dId),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cs.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.school, color: cs.primary, size: 26),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$count quiz${count != 1 ? 'zes' : ''} criado${count != 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
@@ -443,7 +629,9 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_userRole == 'teacher' && quiz['classIds'] != null) ...[
|
||||
if (_userRole == 'teacher' &&
|
||||
quiz['classIds'] != null &&
|
||||
_selectedDisciplineId == null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
|
||||
@@ -67,6 +67,9 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
||||
bool _loadingHistory = true;
|
||||
String? _generatingForId;
|
||||
|
||||
// Disciplina seleccionada no histórico (null = vista de disciplinas)
|
||||
String? _selectedHistoryDisciplineId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -256,7 +259,13 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/teacher-dashboard'),
|
||||
onPressed: () {
|
||||
if (_selectedHistoryDisciplineId != null) {
|
||||
setState(() => _selectedHistoryDisciplineId = null);
|
||||
} else {
|
||||
context.go('/teacher-dashboard');
|
||||
}
|
||||
},
|
||||
),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
@@ -330,6 +339,82 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> get _classNamesMap {
|
||||
return {for (final c in _teacherClasses) c['id']!: c['name'] ?? c['id']!};
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> _groupHistoryByDiscipline() {
|
||||
final classNames = _classNamesMap;
|
||||
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||
for (final quiz in _history) {
|
||||
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
|
||||
String? groupId = quizClassIds.cast<String?>().firstWhere(
|
||||
(cid) => cid != null && classNames.containsKey(cid),
|
||||
orElse: () => null,
|
||||
);
|
||||
groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__';
|
||||
groups.putIfAbsent(groupId, () => []).add(quiz);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
Widget _buildHistoryQuizTile(Map<String, dynamic> item, ColorScheme cs) {
|
||||
final name = (item['materialName'] as String? ?? 'Material')
|
||||
.replaceAll('.pdf', '')
|
||||
.replaceAll('_', ' ');
|
||||
final ts = item['createdAt'];
|
||||
String dateStr = '';
|
||||
if (ts is Timestamp) {
|
||||
final dt = ts.toDate();
|
||||
dateStr =
|
||||
'${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cs.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.quiz, color: cs.primary, size: 22),
|
||||
),
|
||||
title: Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: dateStr.isNotEmpty
|
||||
? Text(
|
||||
dateStr,
|
||||
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
||||
)
|
||||
: null,
|
||||
trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant),
|
||||
onTap: () => _showResultsPopup(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTab(ColorScheme cs) {
|
||||
if (_loadingHistory)
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -355,67 +440,131 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final groups = _groupHistoryByDiscipline();
|
||||
final classNames = _classNamesMap;
|
||||
|
||||
// Vista de quizzes de uma disciplina
|
||||
if (_selectedHistoryDisciplineId != null) {
|
||||
final quizzes = groups[_selectedHistoryDisciplineId] ?? [];
|
||||
final disciplineName =
|
||||
classNames[_selectedHistoryDisciplineId] ??
|
||||
(_selectedHistoryDisciplineId == '__geral__'
|
||||
? 'Geral'
|
||||
: _selectedHistoryDisciplineId!);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
||||
onPressed: () =>
|
||||
setState(() => _selectedHistoryDisciplineId = null),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
disciplineName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${quizzes.length} quiz${quizzes.length != 1 ? 'zes' : ''}',
|
||||
style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: quizzes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) =>
|
||||
_buildHistoryQuizTile(quizzes[i], cs),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Vista de disciplinas
|
||||
final disciplineIds = groups.keys.toList();
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _history.length,
|
||||
itemCount: disciplineIds.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final item = _history[i];
|
||||
final name = (item['materialName'] as String? ?? 'Material')
|
||||
.replaceAll('.pdf', '')
|
||||
.replaceAll('_', ' ');
|
||||
final ts = item['createdAt'];
|
||||
String dateStr = '';
|
||||
if (ts is Timestamp) {
|
||||
final dt = ts.toDate();
|
||||
dateStr =
|
||||
'${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cs.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
final dId = disciplineIds[i];
|
||||
final dName = classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId);
|
||||
final count = groups[dId]!.length;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _selectedHistoryDisciplineId = dId),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cs.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.quiz, color: cs.primary, size: 22),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.school, color: cs.primary, size: 26),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$count quiz${count != 1 ? 'zes' : ''} criado${count != 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: dateStr.isNotEmpty
|
||||
? Text(
|
||||
dateStr,
|
||||
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
||||
)
|
||||
: null,
|
||||
trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant),
|
||||
onTap: () => _showResultsPopup(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user