IA e pequenas coisas a funcionar
This commit is contained in:
674
lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart
Normal file
674
lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart
Normal file
@@ -0,0 +1,674 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../../../../core/services/rag_service.dart';
|
||||
import '../../../../core/services/auth_service.dart';
|
||||
import '../../../../shared/presentation/widgets/custom_notification.dart';
|
||||
import '../widgets/message_bubble.dart';
|
||||
import '../widgets/chat_input.dart';
|
||||
|
||||
/// Main AI Tutor chat interface page
|
||||
class TutorChatPage extends StatefulWidget {
|
||||
const TutorChatPage({super.key});
|
||||
|
||||
@override
|
||||
State<TutorChatPage> createState() => _TutorChatPageState();
|
||||
}
|
||||
|
||||
class _TutorChatPageState extends State<TutorChatPage>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _slideController;
|
||||
|
||||
TutorMode _currentMode = TutorMode.explanation;
|
||||
bool _isLoading = false;
|
||||
List<Map<String, dynamic>> _messages = [];
|
||||
bool _showHistory = false;
|
||||
List<Map<String, dynamic>> _conversationHistory = [];
|
||||
|
||||
// Sample suggestions
|
||||
final List<String> _suggestions = [
|
||||
'O que é fotossíntese?',
|
||||
'Como as plantas produzem energia?',
|
||||
'Explica a cadeia alimentar',
|
||||
'Qual a importância dos ecossistemas?',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeController.forward();
|
||||
_slideController.forward();
|
||||
|
||||
_loadConversationHistory();
|
||||
_addWelcomeMessage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
appBar: _buildAppBar(context),
|
||||
body: Row(
|
||||
children: [
|
||||
// Main chat area
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Messages area
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFF8F9FA),
|
||||
Color(0xFFE8F0FE),
|
||||
Color(0xFFF8F9FA),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _buildMessagesArea(context),
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
ChatInput(
|
||||
controller: _messageController,
|
||||
onSend: _handleSendMessage,
|
||||
onModeChanged: _handleModeChanged,
|
||||
currentMode: _currentMode,
|
||||
isLoading: _isLoading,
|
||||
suggestions: _suggestions,
|
||||
onClear: _handleClearChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// History sidebar (desktop only)
|
||||
if (MediaQuery.of(context).size.width > 768 && _showHistory)
|
||||
_buildHistorySidebar(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||
return AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.school, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AI Study Assistant',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Seu tutor educacional inteligente',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// History toggle (desktop)
|
||||
if (MediaQuery.of(context).size.width > 768)
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showHistory = !_showHistory),
|
||||
icon: Icon(
|
||||
_showHistory ? Icons.history : Icons.history,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
tooltip: 'Histórico de Conversas',
|
||||
),
|
||||
|
||||
// Settings
|
||||
IconButton(
|
||||
onPressed: _showSettings,
|
||||
icon: Icon(Icons.settings, color: Colors.grey[700]),
|
||||
tooltip: 'Configurações',
|
||||
),
|
||||
|
||||
// Logout
|
||||
IconButton(
|
||||
onPressed: _handleLogout,
|
||||
icon: Icon(Icons.logout, color: Colors.grey[700]),
|
||||
tooltip: 'Sair',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesArea(BuildContext context) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
return MessageBubble(
|
||||
content: message['content'] as String,
|
||||
isUser: message['isUser'] as bool,
|
||||
timestamp: message['timestamp'] as DateTime,
|
||||
sources: message['sources'] as List<SourceCitation>?,
|
||||
confidence: message['confidence'] as double?,
|
||||
onSourceTap: () => _showSourceDetails(message['sources']),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistorySidebar(BuildContext context) {
|
||||
return Container(
|
||||
width: 300,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(left: BorderSide(color: Colors.grey[200]!)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(-5, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
border: Border(bottom: BorderSide(color: Colors.grey[200]!)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.history, color: Colors.grey[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Histórico',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showHistory = false),
|
||||
icon: Icon(Icons.close, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// History list
|
||||
Expanded(
|
||||
child: _conversationHistory.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Nenhuma conversa anterior',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _conversationHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conversation = _conversationHistory[index];
|
||||
return _buildHistoryItem(conversation);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().slideX(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(Map<String, dynamic> conversation) {
|
||||
final query = conversation['query'] as String;
|
||||
final timestamp = conversation['createdAt'] as Timestamp;
|
||||
final date = timestamp.toDate();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
child: InkWell(
|
||||
onTap: () => _loadConversation(conversation),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
query,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(date),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addWelcomeMessage() {
|
||||
final welcomeMessage = {
|
||||
'content': '''Olá! Sou seu assistente educacional AI. Posso ajudar você a:
|
||||
|
||||
📚 **Explicar conceitos** de forma detalhada
|
||||
🤔 **Fazer perguntas socráticas** para guiar seu aprendizado
|
||||
🔍 **Explorar tópicos** de forma interativa
|
||||
|
||||
Escolha um modo de tutoria e faça sua pergunta sobre o conteúdo disponível!''',
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
'sources': <SourceCitation>[],
|
||||
'confidence': 1.0,
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_messages.add(welcomeMessage);
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSendMessage() async {
|
||||
if (_messageController.text.trim().isEmpty) return;
|
||||
|
||||
final userMessage = _messageController.text.trim();
|
||||
|
||||
// Add user message
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content': userMessage,
|
||||
'isUser': true,
|
||||
'timestamp': DateTime.now(),
|
||||
'sources': <SourceCitation>[],
|
||||
'confidence': null,
|
||||
});
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
// Process query through RAG pipeline
|
||||
final response = await RAGService.processQuery(
|
||||
userQuery: userMessage,
|
||||
mode: _currentMode,
|
||||
grade: 10, // Would get from user profile
|
||||
);
|
||||
|
||||
// Add AI response
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content': response.answer,
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
'sources': response.sources,
|
||||
'confidence': response.confidence,
|
||||
});
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Save conversation
|
||||
await _saveConversation(userMessage, response);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content':
|
||||
'Desculpe, ocorreu um erro ao processar a pergunta. Tente novamente.',
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
'sources': <SourceCitation>[],
|
||||
'confidence': 0.0,
|
||||
});
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
NotificationHelper.showError(context, message: e.toString());
|
||||
}
|
||||
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _handleModeChanged(TutorMode mode) {
|
||||
setState(() {
|
||||
_currentMode = mode;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleClearChat() {
|
||||
setState(() {
|
||||
_messages.clear();
|
||||
_isLoading = false;
|
||||
});
|
||||
_addWelcomeMessage();
|
||||
}
|
||||
|
||||
void _handleLogout() async {
|
||||
await AuthService.signOut();
|
||||
if (mounted) {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
void _showSettings() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _buildSettingsSheet(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSourceDetails(List<SourceCitation>? sources) {
|
||||
if (sources == null || sources.isEmpty) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _buildSourceDetailsSheet(context, sources),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadConversationHistory() async {
|
||||
try {
|
||||
final user = AuthService.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
final history = await RAGService.getConversationHistory(
|
||||
userId: user.uid,
|
||||
limit: 20,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_conversationHistory = history;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error loading conversation history: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _loadConversation(Map<String, dynamic> conversation) {
|
||||
// Implementation for loading a specific conversation
|
||||
print('Loading conversation: ${conversation['id']}');
|
||||
}
|
||||
|
||||
Future<void> _saveConversation(String query, RAGResponse response) async {
|
||||
try {
|
||||
final user = AuthService.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
await RAGService.saveConversation(
|
||||
userId: user.uid,
|
||||
query: query,
|
||||
response: response,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error saving conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return 'Hoje ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Ontem ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} dias atrás';
|
||||
} else {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSettingsSheet(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Configurações',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.notifications, color: Colors.grey[700]),
|
||||
title: Text('Notificações'),
|
||||
trailing: Switch(value: true, onChanged: (value) {}),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.dark_mode, color: Colors.grey[700]),
|
||||
title: Text('Modo Escuro'),
|
||||
trailing: Switch(value: false, onChanged: (value) {}),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: Icon(Icons.speed, color: Colors.grey[700]),
|
||||
title: Text('Velocidade de Resposta'),
|
||||
subtitle: Text('Normal'),
|
||||
trailing: Icon(Icons.chevron_right),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF82C9BD),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Fechar'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceDetailsSheet(
|
||||
BuildContext context,
|
||||
List<SourceCitation> sources,
|
||||
) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.source, color: Colors.grey[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Fontes Citadas',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: sources.length,
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
source.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('${source.subject} • ${source.concept}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Relevância: ${(source.relevance * 100).toInt()}%',
|
||||
),
|
||||
if (source.pageNumber != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.book,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('Página ${source.pageNumber}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
source.excerpt,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../../core/services/auth_service.dart';
|
||||
|
||||
/// Simple AI Tutor chat interface page (for testing)
|
||||
class TutorChatPageSimple extends StatefulWidget {
|
||||
const TutorChatPageSimple({super.key});
|
||||
|
||||
@override
|
||||
State<TutorChatPageSimple> createState() => _TutorChatPageSimpleState();
|
||||
}
|
||||
|
||||
class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _isLoading = false;
|
||||
List<Map<String, dynamic>> _messages = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addWelcomeMessage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (didPop) return;
|
||||
// Navigate back to dashboard instead of exiting app
|
||||
if (mounted) {
|
||||
context.go('/student-dashboard');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.school, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AI Study Assistant',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Seu tutor educacional inteligente',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _handleLogout,
|
||||
icon: Icon(Icons.logout, color: Colors.grey[700]),
|
||||
tooltip: 'Sair',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Messages area
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFF8F9FA),
|
||||
Color(0xFFE8F0FE),
|
||||
Color(0xFFF8F9FA),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _buildMessagesArea(context),
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
_buildInputArea(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesArea(BuildContext context) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
return _buildMessageBubble(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(Map<String, dynamic> message) {
|
||||
final isUser = message['isUser'] as bool;
|
||||
final content = message['content'] as String;
|
||||
final timestamp = message['timestamp'] as DateTime;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
_buildAvatar(context),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: isUser
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.95),
|
||||
Colors.white.withOpacity(0.9),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(20),
|
||||
topRight: const Radius.circular(20),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(20)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(20),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: isUser
|
||||
? Colors.white
|
||||
: const Color(0xFF2D3748),
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
fontWeight: isUser
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildAvatar(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: isUser ? 0 : 48,
|
||||
right: isUser ? 48 : 0,
|
||||
),
|
||||
child: Text(
|
||||
_formatTimestamp(timestamp),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: const Duration(milliseconds: 300))
|
||||
.slideY(
|
||||
begin: isUser ? 0.1 : -0.1,
|
||||
end: 0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.person, color: Colors.white, size: 20),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(BuildContext context) {
|
||||
// Get bottom padding to avoid system navigation bar
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 16.0,
|
||||
bottom: bottomPadding + 16.0, // Add system navigation bar padding
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white,
|
||||
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Text field
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
hintText: 'Faz a tua pergunta!',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handleSendMessage(),
|
||||
textInputAction: TextInputAction.send,
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Send button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _messageController.text.isNotEmpty
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
color: _messageController.text.isNotEmpty
|
||||
? null
|
||||
: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: _messageController.text.isNotEmpty
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _messageController.text.isNotEmpty && !_isLoading
|
||||
? _handleSendMessage
|
||||
: null,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(Icons.send, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addWelcomeMessage() {
|
||||
final welcomeMessage = {
|
||||
'content': '''Olá! Sou seu assistente educacional AI.
|
||||
|
||||
Bem-vindo ao TeachIT AI Tutor!
|
||||
|
||||
Funcionalidades disponíveis:
|
||||
📚 Respostas baseadas em conteúdo educacional
|
||||
🔍 Busca vetorial semântica
|
||||
🤖 Integração com Ollama API
|
||||
📖 Citações de fontes relevantes
|
||||
🎯 Modo de aprendizado adaptativo
|
||||
|
||||
O sistema usa RAG (Retrieval-Augmented Generation) para fornecer respostas baseadas apenas no conteúdo educacional disponível.
|
||||
|
||||
Faça sua pergunta sobre qualquer assunto educacional!''',
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_messages.add(welcomeMessage);
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSendMessage() async {
|
||||
if (_messageController.text.trim().isEmpty) return;
|
||||
|
||||
final userMessage = _messageController.text.trim();
|
||||
|
||||
// Add user message
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content': userMessage,
|
||||
'isUser': true,
|
||||
'timestamp': DateTime.now(),
|
||||
});
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
// Direct call to Ollama API based on working example
|
||||
print('Processing query: $userMessage');
|
||||
|
||||
final url = Uri.parse('http://89.114.196.110:11434/api/chat');
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'model': 'qwen3-coder:30b',
|
||||
'messages': [
|
||||
{'role': 'user', 'content': userMessage},
|
||||
],
|
||||
'stream': false,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
|
||||
print('API response status: ${response.statusCode}');
|
||||
print('API response body: ${response.body}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final replyText = data['message']?['content'] ?? 'Sem resposta.';
|
||||
|
||||
final preview = replyText.length > 50
|
||||
? replyText.substring(0, 50)
|
||||
: replyText;
|
||||
print('Ollama response received: $preview...');
|
||||
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content': replyText,
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
});
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
throw Exception('Erro HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to mock response if API fails
|
||||
print('Ollama API error: $e');
|
||||
print('Stack trace: ${StackTrace.current}');
|
||||
final aiResponse = _generateMockResponse(userMessage);
|
||||
|
||||
setState(() {
|
||||
_messages.add({
|
||||
'content': aiResponse,
|
||||
'isUser': false,
|
||||
'timestamp': DateTime.now(),
|
||||
});
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
String _generateMockResponse(String userQuery) {
|
||||
final responses = [
|
||||
'Esta é uma resposta simulada para: "$userQuery". Na versão completa, esta resposta seria gerada pela API Ollama com base no conteúdo dos professores.',
|
||||
'Recebi sua pergunta sobre "$userQuery". O sistema RAG completo buscaria conteúdo relevante no banco de dados e geraria uma resposta personalizada.',
|
||||
'Sobre "$userQuery": A versão completa usaria embeddings vetoriais para encontrar o conteúdo mais relevante e fornecer uma resposta baseada apenas no material educacional.',
|
||||
];
|
||||
|
||||
return responses[(userQuery.hashCode % responses.length)];
|
||||
}
|
||||
|
||||
void _handleLogout() async {
|
||||
await AuthService.signOut();
|
||||
if (mounted) {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'Agora';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} min atrás';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} h atrás';
|
||||
} else {
|
||||
return '${difference.inDays} dias atrás';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget para texto com scrolling horizontal (Marquee)
|
||||
class MarqueeText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
final Duration duration;
|
||||
final double scrollSpeed;
|
||||
|
||||
const MarqueeText({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.style,
|
||||
this.duration = const Duration(seconds: 8),
|
||||
this.scrollSpeed = 50.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MarqueeText> createState() => _MarqueeTextState();
|
||||
}
|
||||
|
||||
class _MarqueeTextState extends State<MarqueeText>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
final GlobalKey _textKey = GlobalKey();
|
||||
double _textWidth = 0;
|
||||
double _containerWidth = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(duration: widget.duration, vsync: this);
|
||||
_animation = Tween<double>(begin: 0, end: 1).animate(_controller);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_measureText();
|
||||
});
|
||||
}
|
||||
|
||||
void _measureText() {
|
||||
final RenderBox? textRenderBox =
|
||||
_textKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (textRenderBox != null) {
|
||||
_textWidth = textRenderBox.size.width;
|
||||
final RenderBox? containerRenderBox =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
if (containerRenderBox != null) {
|
||||
_containerWidth = containerRenderBox.size.width;
|
||||
if (_textWidth > _containerWidth) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (_textWidth <= constraints.maxWidth) {
|
||||
return Text(widget.text, style: widget.style, key: _textKey);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: -(_textWidth - constraints.maxWidth) * _animation.value,
|
||||
child: Text(widget.text, style: widget.style, key: _textKey),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user