Quiz e tutor chat modificações

This commit is contained in:
2026-05-18 14:27:30 +01:00
parent ad825f47d7
commit 9b53eb06b6
3 changed files with 811 additions and 497 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/services/materials_rag_service.dart';
@@ -26,6 +27,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
List<Map<String, dynamic>> _messages = [];
List<Map<String, String>> _availableMaterials = [];
Map<String, String> _classNames = {}; // classId → name
Set<String> _selectedMaterialIds = {};
@override
@@ -38,8 +40,28 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Future<void> _loadAvailableMaterials() async {
final materials =
await MaterialsRAGService.getAvailableMaterialsForStudent();
// Collect unique classIds that don't have a name yet
final classIds = materials
.map((m) => m['classId'])
.whereType<String>()
.toSet();
final namesMap = <String, String>{};
if (classIds.isNotEmpty) {
final docs = await Future.wait(
classIds.map(
(id) =>
FirebaseFirestore.instance.collection('classes').doc(id).get(),
),
);
for (final doc in docs.where((d) => d.exists)) {
namesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id;
}
}
if (mounted) {
setState(() => _availableMaterials = materials);
setState(() {
_availableMaterials = materials;
_classNames = namesMap;
});
}
}
@@ -567,102 +589,332 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
);
}
// Group materials by classId; ungrouped go to '__geral__'
Map<String, List<Map<String, String>>> _groupMaterialsByClass() {
final groups = <String, List<Map<String, String>>>{};
for (final m in _availableMaterials) {
final cid = m['classId'];
final key = (cid != null && _classNames.containsKey(cid))
? cid
: '__geral__';
groups.putIfAbsent(key, () => []).add(m);
}
return groups;
}
void _showMaterialsPicker() {
final groups = _groupMaterialsByClass();
final disciplineIds = groups.keys.where((k) => k != '__geral__').toList()
..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b));
if (groups.containsKey('__geral__')) disciplineIds.add('__geral__');
showDialog(
context: context,
builder: (dialogContext) {
final tempSelected = Set<String>.from(_selectedMaterialIds);
// Disciplines start collapsed
final expanded = <String, bool>{
for (final k in disciplineIds) k: false,
};
final searchController = TextEditingController();
String searchQuery = '';
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Escolher Materiais',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seleciona os materiais que o tutor deve analisar:',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView(
shrinkWrap: true,
children: _availableMaterials.map((material) {
final id = material['id']!;
final name = material['name']!;
final isChecked = tempSelected.contains(id);
return CheckboxListTile(
value: isChecked,
onChanged: (val) {
setDialogState(() {
if (val == true) {
tempSelected.add(id);
} else {
tempSelected.remove(id);
}
});
},
title: Text(
name,
style: const TextStyle(fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
builder: (context, setDialogState) {
final cs = Theme.of(context).colorScheme;
// Filter groups by search query
final filteredDisciplineIds = disciplineIds.where((groupKey) {
if (searchQuery.isEmpty) return true;
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? groupKey).toLowerCase();
if (label.contains(q)) return true;
return (groups[groupKey] ?? []).any(
(m) => (m['name'] ?? '').toLowerCase().contains(q),
);
}).toList();
// Auto-expand disciplines that match by material name (not discipline name)
for (final groupKey in filteredDisciplineIds) {
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? groupKey).toLowerCase();
if (!label.contains(q)) expanded[groupKey] = true;
}
}
final viewInsets = MediaQuery.of(context).viewInsets;
final screenHeight = MediaQuery.of(context).size.height;
// Leave room for keyboard + dialog chrome (~360px for title/search/actions/padding)
final listMaxHeight = (screenHeight - viewInsets.bottom - 360)
.clamp(60.0, 340.0);
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Escolher Materiais',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search bar
TextField(
controller: searchController,
onChanged: (v) =>
setDialogState(() => searchQuery = v.trim()),
style: TextStyle(fontSize: 13, color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Pesquisar disciplina ou material…',
hintStyle: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
),
prefixIcon: Icon(
Icons.search,
size: 18,
color: cs.onSurfaceVariant,
),
suffixIcon: searchQuery.isNotEmpty
? IconButton(
icon: Icon(
Icons.close,
size: 16,
color: cs.onSurfaceVariant,
),
onPressed: () {
searchController.clear();
setDialogState(() => searchQuery = '');
},
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.4),
),
controlAffinity: ListTileControlAffinity.leading,
dense: true,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: cs.primary),
),
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: listMaxHeight),
child: filteredDisciplineIds.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Nenhum resultado para "$searchQuery"',
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
),
),
),
)
: ListView(
shrinkWrap: true,
children: filteredDisciplineIds.map((groupKey) {
// When searching, filter materials too
final allMats = groups[groupKey]!;
final mats = searchQuery.isEmpty
? allMats
: allMats.where((m) {
final q = searchQuery.toLowerCase();
final label = groupKey == '__geral__'
? 'geral'
: (_classNames[groupKey] ?? '')
.toLowerCase();
return label.contains(q) ||
(m['name'] ?? '')
.toLowerCase()
.contains(q);
}).toList();
final label = groupKey == '__geral__'
? 'Geral'
: (_classNames[groupKey] ?? groupKey);
final isExpanded = expanded[groupKey] ?? false;
// Count how many in this group are selected
final selectedInGroup = mats
.where(
(m) => tempSelected.contains(m['id']),
)
.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Discipline header row
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setDialogState(
() => expanded[groupKey] = !isExpanded,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
decoration: BoxDecoration(
color: cs.primary.withValues(
alpha: 0.07,
),
borderRadius: BorderRadius.circular(
8,
),
),
child: Row(
children: [
Icon(
Icons.folder_outlined,
size: 16,
color: cs.primary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
),
if (selectedInGroup > 0)
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: cs.primary.withValues(
alpha: 0.15,
),
borderRadius:
BorderRadius.circular(10),
),
child: Text(
'$selectedInGroup',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: cs.primary,
),
),
),
const SizedBox(width: 4),
Icon(
isExpanded
? Icons.expand_less
: Icons.expand_more,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
// Material items
if (isExpanded)
...mats.map((material) {
final id = material['id']!;
final name = material['name']!;
final cleanName = name
.replaceAll('.pdf', '')
.replaceAll('_', ' ');
final isChecked = tempSelected.contains(
id,
);
return CheckboxListTile(
value: isChecked,
onChanged: (val) {
setDialogState(() {
if (val == true) {
tempSelected.add(id);
} else {
tempSelected.remove(id);
}
});
},
title: Text(
cleanName,
style: const TextStyle(
fontSize: 13,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
controlAffinity:
ListTileControlAffinity.leading,
dense: true,
contentPadding: const EdgeInsets.only(
left: 16,
),
);
}),
const SizedBox(height: 6),
],
);
}).toList(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
setDialogState(() => tempSelected.clear());
setState(() {
_selectedMaterialIds.clear();
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
child: const Text('Limpar'),
),
ElevatedButton(
onPressed: () {
setState(() {
_selectedMaterialIds = tempSelected;
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
setDialogState(() => tempSelected.clear());
setState(() {
_selectedMaterialIds.clear();
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
child: const Text('Limpar'),
),
ElevatedButton(
onPressed: () {
setState(() {
_selectedMaterialIds = tempSelected;
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: const Text('Confirmar'),
),
child: const Text('Confirmar'),
),
],
),
],
);
},
);
},
);

File diff suppressed because it is too large Load Diff