Quiz e tutor chat modificações
This commit is contained in:
@@ -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
Reference in New Issue
Block a user