Melhorias no comportamento do chat com IA;

Adição do histórico de conversas com IA.
This commit is contained in:
2026-05-21 23:20:18 +01:00
parent 5bda59f7af
commit 7ee262f4c7
5 changed files with 825 additions and 162 deletions

View File

@@ -14,12 +14,24 @@ class ChatHistoryPage extends StatefulWidget {
class _ChatHistoryPageState extends State<ChatHistoryPage> {
List<Map<String, dynamic>> _conversations = [];
List<Map<String, dynamic>> _filteredConversations = [];
bool _isLoading = true;
final TextEditingController _searchController = TextEditingController();
String _selectedDateFilter = 'all'; // all, today, yesterday, week, month
final Set<String> _selectedConversationIds = {};
bool _isSelectionMode = false;
@override
void initState() {
super.initState();
_loadConversations();
_searchController.addListener(_filterConversations);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadConversations() async {
@@ -29,6 +41,7 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
if (mounted) {
setState(() {
_conversations = conversations;
_filteredConversations = conversations;
_isLoading = false;
});
}
@@ -40,6 +53,136 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
}
}
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedConversationIds.clear();
}
});
}
void _toggleConversationSelection(String conversationId) {
setState(() {
if (_selectedConversationIds.contains(conversationId)) {
_selectedConversationIds.remove(conversationId);
} else {
_selectedConversationIds.add(conversationId);
}
});
}
Future<void> _deleteSelectedConversations() async {
if (_selectedConversationIds.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar ${_selectedConversationIds.length} conversas'),
content: const Text('Tem certeza que deseja eliminar estas conversas?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
for (final id in _selectedConversationIds) {
await ChatMemoryService.deleteConversation(id);
}
await _loadConversations();
setState(() {
_selectedConversationIds.clear();
_isSelectionMode = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversas eliminadas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
Logger.error('Error deleting conversations: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
void _filterConversations() {
final searchQuery = _searchController.text.toLowerCase();
setState(() {
_filteredConversations = _conversations.where((conv) {
// Filter by search query (title)
final title = (conv['title'] as String? ?? '').toLowerCase();
final matchesSearch = title.contains(searchQuery);
// Filter by date
final matchesDate = _matchesDateFilter(conv);
return matchesSearch && matchesDate;
}).toList();
});
}
bool _matchesDateFilter(Map<String, dynamic> conv) {
final updatedAt = conv['updatedAt'] as Timestamp?;
if (updatedAt == null) return true;
final date = updatedAt.toDate();
final now = DateTime.now();
final difference = now.difference(date);
switch (_selectedDateFilter) {
case 'today':
return difference.inDays == 0;
case 'yesterday':
return difference.inDays == 1;
case 'week':
return difference.inDays < 7;
case 'month':
return difference.inDays < 30;
default:
return true;
}
}
Future<void> _deleteConversation(String conversationId) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -66,15 +209,41 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
await ChatMemoryService.deleteConversation(conversationId);
await _loadConversations();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Conversa eliminada')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversa eliminada'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
Logger.error('Error deleting conversation: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erro ao eliminar conversa')),
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversa'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
@@ -111,7 +280,9 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
onPressed: () => context.go('/student-dashboard'),
),
title: Text(
'Histórico de Conversas',
_isSelectionMode
? '${_selectedConversationIds.length} selecionadas'
: 'Histórico de Conversas',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
@@ -119,55 +290,157 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
),
),
actions: [
IconButton(
icon: Icon(Icons.refresh, color: cs.onSurface),
onPressed: _loadConversations,
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: _selectedConversationIds.isEmpty
? null
: _deleteSelectedConversations,
)
else
IconButton(
icon: Icon(Icons.checklist, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
if (!_isSelectionMode)
IconButton(
icon: Icon(Icons.refresh, color: cs.onSurface),
onPressed: _loadConversations,
),
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.close, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
],
),
body: Column(
children: [
// Search and filters
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar conversas...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.3),
),
),
filled: true,
fillColor: cs.surface,
),
),
const SizedBox(height: 12),
// Date filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDateFilterChip('Todas', 'all', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Hoje', 'today', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Ontem', 'yesterday', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Última semana', 'week', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Último mês', 'month', cs),
],
),
),
],
),
),
// Conversation list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredConversations.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
_conversations.isEmpty
? 'Sem conversas ainda'
: 'Nenhuma conversa encontrada',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
_conversations.isEmpty
? 'Começa uma conversa com o Vico!'
: 'Tenta ajustar os filtros',
style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 14,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _filteredConversations.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final conversation = _filteredConversations[index];
return _buildConversationCard(conversation, cs);
},
),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _conversations.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
'Sem conversas ainda',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'Começa uma conversa com o Vico!',
style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 14,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _conversations.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final conversation = _conversations[index];
return _buildConversationCard(conversation, cs);
},
),
);
}
Widget _buildDateFilterChip(String label, String value, ColorScheme cs) {
final isSelected = _selectedDateFilter == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedDateFilter = value;
});
_filterConversations();
},
backgroundColor: cs.surface,
selectedColor: cs.primary.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? cs.primary : cs.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
side: BorderSide(
color: isSelected ? cs.primary : cs.outline.withValues(alpha: 0.3),
),
);
}
@@ -175,10 +448,16 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
Map<String, dynamic> conversation,
ColorScheme cs,
) {
final isSelected = _selectedConversationIds.contains(conversation['id']);
return Dismissible(
key: Key(conversation['id']),
direction: DismissDirection.endToStart,
onDismissed: (_) => _deleteConversation(conversation['id']),
direction: _isSelectionMode
? DismissDirection.none
: DismissDirection.endToStart,
onDismissed: _isSelectionMode
? null
: (_) => _deleteConversation(conversation['id']),
background: Container(
decoration: BoxDecoration(
color: Colors.red,
@@ -190,16 +469,24 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
),
child: InkWell(
onTap: () {
ChatMemoryService.setCurrentConversationId(conversation['id']);
context.go('/ai-tutor/${conversation['id']}');
if (_isSelectionMode) {
_toggleConversationSelection(conversation['id']);
} else {
ChatMemoryService.setCurrentConversationId(conversation['id']);
context.go('/ai-tutor/${conversation['id']}');
}
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface,
color: isSelected ? cs.primary.withValues(alpha: 0.1) : cs.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withValues(alpha: 0.15)),
border: Border.all(
color: isSelected
? cs.primary
: cs.outline.withValues(alpha: 0.15),
),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.05),
@@ -213,21 +500,34 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.7)],
if (_isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Checkbox(
value: isSelected,
onChanged: (_) =>
_toggleConversationSelection(conversation['id']),
),
)
else
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
cs.primary,
cs.primary.withValues(alpha: 0.7),
],
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.chat_bubble,
color: Colors.white,
size: 24,
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.chat_bubble,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(

View File

@@ -77,28 +77,55 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final conversation = await ChatMemoryService.getConversation(
conversationId,
);
if (conversation == null) return;
if (conversation == null) {
Logger.warning('Conversation not found: $conversationId');
return;
}
final messages = await ChatMemoryService.getConversationMessages(
conversationId: conversationId,
limit: 50,
);
final materialIds = conversation['selectedMaterialIds'] as List<dynamic>?;
if (materialIds != null) {
setState(() {
_selectedMaterialIds = materialIds.cast<String>().toSet();
_materialsConfirmed = true;
});
// Safely extract material IDs with type checking
try {
final materialIdsRaw = conversation['selectedMaterialIds'];
if (materialIdsRaw != null && materialIdsRaw is List) {
final materialIds = (materialIdsRaw as List)
.where((item) => item is String)
.cast<String>()
.toSet();
if (mounted) {
setState(() {
_selectedMaterialIds = materialIds;
_materialsConfirmed = materialIds.isNotEmpty;
});
}
}
} catch (e) {
Logger.error('Error loading material IDs: $e');
// Continue without materials rather than crash
}
// Load messages from Firestore
// Load messages from Firestore with safe casting
final loadedMessages = messages.map((msg) {
return {
'content': msg['content'] as String,
'isUser': msg['role'] == 'user',
'timestamp': msg['createdAt'] as Timestamp? ?? DateTime.now(),
};
try {
return {
'content': msg['content']?.toString() ?? '',
'isUser': msg['role']?.toString() == 'user',
'timestamp': msg['createdAt'] is Timestamp
? (msg['createdAt'] as Timestamp).toDate()
: DateTime.now(),
};
} catch (e) {
Logger.error('Error mapping message: $e');
// Return a safe fallback message
return {
'content': '[Mensagem indisponível]',
'isUser': false,
'timestamp': DateTime.now(),
};
}
}).toList();
if (mounted) {
@@ -109,6 +136,12 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
}
} catch (e) {
Logger.error('Error loading conversation: $e');
// Show error to user
if (mounted) {
setState(() {
_messages = [];
});
}
}
}
@@ -256,6 +289,42 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
size: 20,
),
),
IconButton(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nova conversa'),
content: const Text(
'Isto vai limpar o chat atual e guardá-lo no histórico. Continuar?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Limpar'),
),
],
),
);
if (confirmed == true) {
ChatMemoryService.setCurrentConversationId(null);
context.go('/ai-tutor');
}
},
icon: const Icon(
Icons.add_comment,
color: Colors.white,
size: 20,
),
tooltip: 'Nova conversa',
),
const SizedBox(width: 4),
],
),
@@ -1289,12 +1358,6 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
setState(() {
_selectedMaterialIds = tempSelected;
_materialsConfirmed = true;
if (!isFirst && selectionChanged) {
final welcome = _messages.isNotEmpty
? [_messages.first]
: <Map<String, dynamic>>[];
_messages = welcome;
}
});
if (isFirst) _addWelcomeMessage();
if (selectionChanged) {
@@ -1319,9 +1382,10 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
}
void _addWelcomeMessage() {
final welcomeText =
'Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪';
final welcomeMessage = {
'content':
'''Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪''',
'content': welcomeText,
'isUser': false,
'timestamp': DateTime.now(),
};
@@ -1330,29 +1394,81 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
_messages.add(welcomeMessage);
});
// Save welcome message to Firestore so the AI knows it already greeted the user
ChatMemoryService.saveMessage(role: 'assistant', content: welcomeText);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
}
/// Get names of selected materials
List<String> _getSelectedMaterialNames() {
return _selectedMaterialIds.map((id) {
final material = _availableMaterials.firstWhere(
(m) => m['id'] == id,
orElse: () => {'id': id, 'name': 'Material'},
);
return material['name'] ?? 'Material';
}).toList();
}
/// Determine discipline name from selected materials
String? _getDisciplineName() {
if (_selectedMaterialIds.isEmpty) return null;
// Find classId of first selected material
final firstId = _selectedMaterialIds.first;
final material = _availableMaterials.firstWhere(
(m) => m['id'] == firstId,
orElse: () => {},
);
final classId = material['classId'];
if (classId != null && _classNames.containsKey(classId)) {
return _classNames[classId];
}
return null;
}
/// Detect if current discipline is mathematics
bool _isMathematics() {
final discipline = _getDisciplineName();
if (discipline == null) return false;
final lower = discipline.toLowerCase();
final mathKeywords = [
'matemática',
'matematica',
'math',
'álgebra',
'algebra',
'geometria',
'cálculo',
'calculo',
'estatística',
'estatistica',
'física',
'fisica',
'química',
'quimica',
];
return mathKeywords.any((k) => lower.contains(k));
}
Future<void> _handleSendMessage() async {
if (_messageController.text.trim().isEmpty) return;
final userMessage = _messageController.text.trim();
// Save user message to Firestore
await ChatMemoryService.saveMessage(role: 'user', content: userMessage);
// Update conversation title if it's the first message
// Update conversation title if it's still the default "Nova conversa"
final currentId = ChatMemoryService.currentConversationId;
if (currentId != null && _messages.isEmpty) {
final title = userMessage.length > 30
? '${userMessage.substring(0, 30)}...'
: userMessage;
await ChatMemoryService.updateConversationTitle(
conversationId: currentId,
title: title,
);
if (currentId != null) {
final conversation = await ChatMemoryService.getConversation(currentId);
if (conversation != null && conversation['title'] == 'Nova conversa') {
final title = ChatMemoryService.generateConversationTitle(userMessage);
await ChatMemoryService.updateConversationTitle(
conversationId: currentId,
title: title,
);
}
}
// Add user message
@@ -1377,6 +1493,11 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
selectedMaterialIds: _selectedMaterialIds.isEmpty
? null
: _selectedMaterialIds.toList(),
selectedMaterialNames: _selectedMaterialIds.isEmpty
? null
: _getSelectedMaterialNames(),
disciplineName: _getDisciplineName(),
isMathematics: _isMathematics(),
);
final preview = replyText.length > 50
@@ -1384,12 +1505,6 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
: replyText;
Logger.info('Ollama response received: $preview...');
// Save assistant message to Firestore
await ChatMemoryService.saveMessage(
role: 'assistant',
content: replyText,
);
setState(() {
_messages.add({
'content': replyText,