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
|
/// Listar materiais disponíveis para o aluno autenticado
|
||||||
/// Retorna apenas materiais cujo classId corresponde a uma turma onde o aluno está inscrito
|
/// 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 {
|
try {
|
||||||
final user = _auth.currentUser;
|
final user = _auth.currentUser;
|
||||||
if (user == null) return [];
|
if (user == null) return [];
|
||||||
@@ -62,7 +63,13 @@ class MaterialsRAGService {
|
|||||||
final classId = data['classId'] as String?;
|
final classId = data['classId'] as String?;
|
||||||
if (classId == null || enrolledClassIds.contains(classId)) {
|
if (classId == null || enrolledClassIds.contains(classId)) {
|
||||||
final fileName = data['fileName'] as String? ?? 'Material';
|
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)
|
// Usar cache do texto completo se disponível (sufixo v2 invalida caches antigos)
|
||||||
final cacheKey = '${fileName}_v6';
|
final cacheKey = '${fileName}_v6';
|
||||||
String fullText;
|
String fullText;
|
||||||
if (_chunksCache.containsKey(cacheKey) && _chunksCache[cacheKey]!.isNotEmpty) {
|
if (_chunksCache.containsKey(cacheKey) &&
|
||||||
|
_chunksCache[cacheKey]!.isNotEmpty) {
|
||||||
fullText = _chunksCache[cacheKey]!.first;
|
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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
final teacherId = data['teacherId'] as String?;
|
final teacherId = data['teacherId'] as String?;
|
||||||
@@ -164,7 +174,9 @@ class MaterialsRAGService {
|
|||||||
fullText = cleaned;
|
fullText = cleaned;
|
||||||
// Guardar texto completo no cache com key versionada
|
// Guardar texto completo no cache com key versionada
|
||||||
_chunksCache[cacheKey] = [fullText];
|
_chunksCache[cacheKey] = [fullText];
|
||||||
Logger.info('PDF "$fileName" -> ${fullText.length} chars extracted');
|
Logger.info(
|
||||||
|
'PDF "$fileName" -> ${fullText.length} chars extracted',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error extracting text from $fileName: $e');
|
Logger.error('Error extracting text from $fileName: $e');
|
||||||
continue;
|
continue;
|
||||||
@@ -176,9 +188,15 @@ class MaterialsRAGService {
|
|||||||
final String context;
|
final String context;
|
||||||
if (fullText.length <= 10000) {
|
if (fullText.length <= 10000) {
|
||||||
context = fullText;
|
context = fullText;
|
||||||
Logger.info('Small PDF — sending full text (${fullText.length} chars)');
|
Logger.info(
|
||||||
|
'Small PDF — sending full text (${fullText.length} chars)',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
final windows = _extractKeywordWindows(fullText, userQuery, _maxRelevantChunks);
|
final windows = _extractKeywordWindows(
|
||||||
|
fullText,
|
||||||
|
userQuery,
|
||||||
|
_maxRelevantChunks,
|
||||||
|
);
|
||||||
context = windows.join('\n\n---\n\n');
|
context = windows.join('\n\n---\n\n');
|
||||||
Logger.info('Large PDF — keyword windows: ${windows.length}');
|
Logger.info('Large PDF — keyword windows: ${windows.length}');
|
||||||
}
|
}
|
||||||
@@ -205,7 +223,11 @@ class MaterialsRAGService {
|
|||||||
/// Método legacy - mantido para compatibilidade mas usa chunk retrieval
|
/// Método legacy - mantido para compatibilidade mas usa chunk retrieval
|
||||||
@Deprecated('Use getRelevantChunks with userQuery instead')
|
@Deprecated('Use getRelevantChunks with userQuery instead')
|
||||||
static Future<String> getMaterialsContext({int maxMaterials = 5}) async {
|
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
|
/// Get teacher IDs from student's enrolled classes
|
||||||
@@ -273,7 +295,10 @@ class MaterialsRAGService {
|
|||||||
|
|
||||||
/// Extrair texto real do PDF usando Firebase Storage SDK + syncfusion_flutter_pdf
|
/// 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)
|
/// 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;
|
PdfDocument? document;
|
||||||
try {
|
try {
|
||||||
final ref = _storage
|
final ref = _storage
|
||||||
@@ -306,12 +331,16 @@ class MaterialsRAGService {
|
|||||||
for (int i = startPage; i < totalPages; i++) {
|
for (int i = startPage; i < totalPages; i++) {
|
||||||
if (buffer.length >= _maxExtractedChars) break;
|
if (buffer.length >= _maxExtractedChars) break;
|
||||||
try {
|
try {
|
||||||
final pageText = extractor.extractText(startPageIndex: i, endPageIndex: i).trim();
|
final pageText = extractor
|
||||||
|
.extractText(startPageIndex: i, endPageIndex: i)
|
||||||
|
.trim();
|
||||||
if (pageText.length < 80) continue;
|
if (pageText.length < 80) continue;
|
||||||
final lowerText = pageText.toLowerCase();
|
final lowerText = pageText.toLowerCase();
|
||||||
final pipeCount = '|'.allMatches(pageText).length;
|
final pipeCount = '|'.allMatches(pageText).length;
|
||||||
final isStructurePage = pipeCount > 3 ||
|
final isStructurePage =
|
||||||
(lowerText.contains('table of contents') && pageText.length < 800) ||
|
pipeCount > 3 ||
|
||||||
|
(lowerText.contains('table of contents') &&
|
||||||
|
pageText.length < 800) ||
|
||||||
(lowerText.contains('copyright') && pageText.length < 400) ||
|
(lowerText.contains('copyright') && pageText.length < 400) ||
|
||||||
(lowerText.contains('color insert') && pageText.length < 400) ||
|
(lowerText.contains('color insert') && pageText.length < 400) ||
|
||||||
lowerText.contains('just light novels') ||
|
lowerText.contains('just light novels') ||
|
||||||
@@ -355,8 +384,12 @@ class MaterialsRAGService {
|
|||||||
? fullText.substring(0, _maxExtractedChars)
|
? fullText.substring(0, _maxExtractedChars)
|
||||||
: fullText;
|
: fullText;
|
||||||
|
|
||||||
Logger.info('Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)');
|
Logger.info(
|
||||||
Logger.info('Text preview: ${result.length > 200 ? result.substring(0, 200) : result}');
|
'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();
|
return result.trim();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error extracting text from $fileName: $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)
|
// 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")
|
// 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')
|
final properNouns = RegExp(
|
||||||
.allMatches(userQuery)
|
r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b',
|
||||||
.map((m) => m.group(0)!.toLowerCase())
|
).allMatches(userQuery).map((m) => m.group(0)!.toLowerCase()).toSet();
|
||||||
.toSet();
|
|
||||||
final generalKeywords = userQuery
|
final generalKeywords = userQuery
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(RegExp(r'[^\w]'))
|
.split(RegExp(r'[^\w]'))
|
||||||
@@ -428,7 +460,9 @@ class MaterialsRAGService {
|
|||||||
lastEnd = end;
|
lastEnd = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('Keyword windows found: ${windows.length} for query "$userQuery"');
|
Logger.info(
|
||||||
|
'Keyword windows found: ${windows.length} for query "$userQuery"',
|
||||||
|
);
|
||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +550,9 @@ class MaterialsRAGService {
|
|||||||
// Ordenar por score decrescente
|
// Ordenar por score decrescente
|
||||||
scoredChunks.sort((a, b) => b.value.compareTo(a.value));
|
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
|
// Retornar os N chunks mais relevantes
|
||||||
return scoredChunks.take(maxChunks).map((e) => e.key).toList();
|
return scoredChunks.take(maxChunks).map((e) => e.key).toList();
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
_buildTutorIACard(context),
|
_buildTutorIACard(context),
|
||||||
_buildQuizCard(context),
|
_buildQuizCard(context),
|
||||||
_buildAchievementsCard(context),
|
_buildAchievementsCard(context),
|
||||||
_buildQuizManagementCard(context),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -70,8 +69,6 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
SizedBox(width: _scrollCardWidth, child: cards[1]),
|
SizedBox(width: _scrollCardWidth, child: cards[1]),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(width: _scrollCardWidth, child: cards[2]),
|
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) {
|
Widget _buildJoinClassCard(BuildContext context) {
|
||||||
return DashboardActionCard(
|
return DashboardActionCard(
|
||||||
title: 'Entrar numa Disciplina',
|
title: 'Entrar numa Disciplina',
|
||||||
@@ -245,15 +216,6 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
context.go('/student/achievements');
|
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(
|
_QuickAccessItem(
|
||||||
title: 'Entrar numa Disciplina',
|
title: 'Entrar numa Disciplina',
|
||||||
subtitle: 'Junta-te a uma disciplina com o código',
|
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;
|
bool _loading = true;
|
||||||
String _userRole = '';
|
String _userRole = '';
|
||||||
|
|
||||||
|
// Disciplina seleccionada (null = vista de disciplinas)
|
||||||
|
String? _selectedDisciplineId;
|
||||||
|
Map<String, String> _classNames = {}; // classId → name
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -116,9 +120,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final snapshot = await query.get();
|
final snapshot = await query.get();
|
||||||
|
final quizzes = snapshot.docs
|
||||||
setState(() {
|
|
||||||
_quizHistory = snapshot.docs
|
|
||||||
.map((doc) {
|
.map((doc) {
|
||||||
final data = Map<String, dynamic>.from(doc.data() as Map);
|
final data = Map<String, dynamic>.from(doc.data() as Map);
|
||||||
data['id'] = doc.id;
|
data['id'] = doc.id;
|
||||||
@@ -126,6 +128,29 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
})
|
})
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.toList();
|
.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 = quizzes;
|
||||||
|
_classNames = classNamesMap;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -277,11 +302,32 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
return result ?? false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
|
canPop: _selectedDisciplineId == null,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop && _selectedDisciplineId != null) {
|
||||||
|
setState(() => _selectedDisciplineId = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -293,6 +339,10 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (_userRole == 'teacher' && _selectedDisciplineId != null) {
|
||||||
|
setState(() => _selectedDisciplineId = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Navigator.of(context).canPop()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else {
|
} else {
|
||||||
@@ -317,6 +367,8 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _quizHistory.isEmpty
|
: _quizHistory.isEmpty
|
||||||
? _buildEmptyState()
|
? _buildEmptyState()
|
||||||
|
: _userRole == 'teacher'
|
||||||
|
? _buildTeacherBody(cs)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _quizHistory.length,
|
itemCount: _quizHistory.length,
|
||||||
@@ -329,6 +381,140 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
bool _loadingHistory = true;
|
bool _loadingHistory = true;
|
||||||
String? _generatingForId;
|
String? _generatingForId;
|
||||||
|
|
||||||
|
// Disciplina seleccionada no histórico (null = vista de disciplinas)
|
||||||
|
String? _selectedHistoryDisciplineId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -256,7 +259,13 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
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(
|
bottom: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
@@ -330,37 +339,26 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistoryTab(ColorScheme cs) {
|
Map<String, String> get _classNamesMap {
|
||||||
if (_loadingHistory)
|
return {for (final c in _teacherClasses) c['id']!: c['name'] ?? c['id']!};
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
if (_history.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Ainda não criaste nenhum quiz.',
|
|
||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.all(16),
|
Map<String, List<Map<String, dynamic>>> _groupHistoryByDiscipline() {
|
||||||
itemCount: _history.length,
|
final classNames = _classNamesMap;
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||||
itemBuilder: (context, i) {
|
for (final quiz in _history) {
|
||||||
final item = _history[i];
|
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')
|
final name = (item['materialName'] as String? ?? 'Material')
|
||||||
.replaceAll('.pdf', '')
|
.replaceAll('.pdf', '')
|
||||||
.replaceAll('_', ' ');
|
.replaceAll('_', ' ');
|
||||||
@@ -385,10 +383,7 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -418,6 +413,160 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
onTap: () => _showResultsPopup(item),
|
onTap: () => _showResultsPopup(item),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryTab(ColorScheme cs) {
|
||||||
|
if (_loadingHistory)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
if (_history.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history,
|
||||||
|
size: 64,
|
||||||
|
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ainda não criaste nenhum quiz.',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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(() => _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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user