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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
415
lib/features/ai_tutor/presentation/widgets/chat_input.dart
Normal file
415
lib/features/ai_tutor/presentation/widgets/chat_input.dart
Normal file
@@ -0,0 +1,415 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import '../../../../core/services/rag_service.dart';
|
||||
|
||||
/// Enhanced chat input widget with suggestions and mode selection
|
||||
class ChatInput extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onSend;
|
||||
final ValueChanged<TutorMode>? onModeChanged;
|
||||
final TutorMode currentMode;
|
||||
final bool isLoading;
|
||||
final List<String> suggestions;
|
||||
final VoidCallback? onClear;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onSend,
|
||||
this.onModeChanged,
|
||||
this.currentMode = TutorMode.explanation,
|
||||
this.isLoading = false,
|
||||
this.suggestions = const [],
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
bool _showSuggestions = false;
|
||||
bool _isExpanded = false;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
setState(() {
|
||||
_showSuggestions = _focusNode.hasFocus && widget.suggestions.isNotEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Mode selector
|
||||
_buildModeSelector(context),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Input field with send button
|
||||
_buildInputField(context),
|
||||
|
||||
// Suggestions
|
||||
if (_showSuggestions) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildSuggestions(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
).animate().slideY(begin: 1.0, end: 0.0, duration: const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Widget _buildModeSelector(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.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(
|
||||
'Modo de Tutoria',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: TutorMode.values.map((mode) => _buildModeButton(mode)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeButton(TutorMode mode) {
|
||||
final isSelected = widget.currentMode == mode;
|
||||
final modeInfo = _getModeInfo(mode);
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onModeChanged?.call(mode),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: isSelected
|
||||
? LinearGradient(
|
||||
colors: [
|
||||
modeInfo['color'] as Color,
|
||||
modeInfo['colorDark'] as Color,
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: isSelected ? null : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.transparent : Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
modeInfo['icon'] as IconData,
|
||||
size: 20,
|
||||
color: isSelected ? Colors.white : Colors.grey[600],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
modeInfo['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? Colors.white : Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputField(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[50]!,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Text field
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
maxLines: _isExpanded ? 5 : 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF2D3748),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Faça sua pergunta sobre o conteúdo...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handleSend(),
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Expand/Collapse button
|
||||
if (widget.controller.text.isNotEmpty) ...[
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _isExpanded = !_isExpanded),
|
||||
icon: Icon(
|
||||
_isExpanded ? Icons.compress : Icons.expand,
|
||||
color: Colors.grey[600],
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _isExpanded ? 'Reduzir' : 'Expandir',
|
||||
),
|
||||
],
|
||||
|
||||
// Clear button
|
||||
if (widget.controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
widget.controller.clear();
|
||||
widget.onClear?.call();
|
||||
setState(() {
|
||||
_isExpanded = false;
|
||||
_showSuggestions = false;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: Colors.grey[600],
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Limpar',
|
||||
),
|
||||
|
||||
// Send button
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: widget.controller.text.isNotEmpty
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
color: widget.controller.text.isNotEmpty ? null : Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: widget.controller.text.isNotEmpty
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: widget.controller.text.isNotEmpty && !widget.isLoading
|
||||
? _handleSend
|
||||
: null,
|
||||
icon: widget.isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestions(BuildContext context) {
|
||||
return 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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Sugestões',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: widget.suggestions.take(6).map((suggestion) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
widget.controller.text = suggestion;
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFF82C9BD).withOpacity(0.1),
|
||||
const Color(0xFF6BA5A0).withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF82C9BD),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
).animate().fadeIn(duration: const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getModeInfo(TutorMode mode) {
|
||||
switch (mode) {
|
||||
case TutorMode.explanation:
|
||||
return {
|
||||
'label': 'Explicação',
|
||||
'icon': Icons.school,
|
||||
'color': const Color(0xFF82C9BD),
|
||||
'colorDark': const Color(0xFF6BA5A0),
|
||||
};
|
||||
case TutorMode.tutor:
|
||||
return {
|
||||
'label': 'Tutor',
|
||||
'icon': Icons.psychology,
|
||||
'color': const Color(0xFFF68D2D),
|
||||
'colorDark': const Color(0xFFE67E22),
|
||||
};
|
||||
case TutorMode.exploration:
|
||||
return {
|
||||
'label': 'Exploração',
|
||||
'icon': Icons.explore,
|
||||
'color': const Color(0xFF9C27B0),
|
||||
'colorDark': const Color(0xFF7B1FA2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSend() {
|
||||
if (widget.controller.text.trim().isNotEmpty && !widget.isLoading) {
|
||||
widget.onSend();
|
||||
setState(() {
|
||||
_isExpanded = false;
|
||||
_showSuggestions = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
367
lib/features/ai_tutor/presentation/widgets/message_bubble.dart
Normal file
367
lib/features/ai_tutor/presentation/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import '../../../../core/services/rag_service.dart';
|
||||
|
||||
/// Widget for displaying chat messages with source citations
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final String content;
|
||||
final bool isUser;
|
||||
final DateTime timestamp;
|
||||
final List<SourceCitation>? sources;
|
||||
final double? confidence;
|
||||
final bool showSources;
|
||||
final VoidCallback? onSourceTap;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.content,
|
||||
required this.isUser,
|
||||
required this.timestamp,
|
||||
this.sources,
|
||||
this.confidence,
|
||||
this.showSources = true,
|
||||
this.onSourceTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: Column(
|
||||
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMessageBubble(context),
|
||||
if (!isUser && showSources && sources != null && sources!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildSourceCitations(context),
|
||||
],
|
||||
if (!isUser && confidence != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildConfidenceIndicator(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildAvatar(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildTimestamp(context),
|
||||
],
|
||||
),
|
||||
).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: isUser
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF82C9BD), Color(0xFF6BA5A0)],
|
||||
)
|
||||
: const LinearGradient(
|
||||
colors: [Color(0xFFF68D2D), Color(0xFFE67E22)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (isUser ? const Color(0xFF82C9BD) : const Color(0xFFF68D2D))
|
||||
.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
isUser ? Icons.person : Icons.school,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(BuildContext context) {
|
||||
return 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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : const Color(0xFF2D3748),
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
fontWeight: isUser ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceCitations(BuildContext context) {
|
||||
return 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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.source,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Fontes',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${sources!.length} ${sources!.length == 1 ? 'fonte' : 'fontes'}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...sources!.take(3).map((source) => _buildSourceItem(context, source)),
|
||||
if (sources!.length > 3) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'+${sources!.length - 3} mais fontes...',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceItem(BuildContext context, SourceCitation source) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6.0),
|
||||
child: InkWell(
|
||||
onTap: () => onSourceTap?.call(),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.grey[200]!,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 14,
|
||||
color: const Color(0xFF82C9BD),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
source.title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${(source.relevance * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF82C9BD),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category,
|
||||
size: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${source.subject} • ${source.concept}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (source.pageNumber != null) ...[
|
||||
const Spacer(),
|
||||
Icon(
|
||||
Icons.book,
|
||||
size: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'p. ${source.pageNumber}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfidenceIndicator(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 14,
|
||||
color: _getConfidenceColor(confidence!),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Confiança: ${(confidence! * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _getConfidenceColor(confidence!),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getConfidenceColor(double confidence) {
|
||||
if (confidence >= 0.8) return Colors.green;
|
||||
if (confidence >= 0.6) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
Widget _buildTimestamp(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: isUser ? 0 : 48,
|
||||
right: isUser ? 48 : 0,
|
||||
),
|
||||
child: Text(
|
||||
_formatTimestamp(timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,15 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => context.go('/tutor'),
|
||||
onTap: () {
|
||||
print('DEBUG: AI Tutor card clicked!');
|
||||
try {
|
||||
context.go('/ai-tutor');
|
||||
print('DEBUG: Navigation to AI Tutor successful');
|
||||
} catch (e) {
|
||||
print('DEBUG: Navigation error: $e');
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -162,7 +170,15 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => context.go('/quiz'),
|
||||
onTap: () {
|
||||
print('DEBUG: AI Tutor card clicked!');
|
||||
try {
|
||||
context.go('/ai-tutor');
|
||||
print('DEBUG: Navigation to AI Tutor successful');
|
||||
} catch (e) {
|
||||
print('DEBUG: Navigation error: $e');
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
@@ -6,21 +7,29 @@ class ProfilePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Profile'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'Profile Page - Coming Soon',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (didPop) return;
|
||||
// Navigate back to dashboard instead of exiting app
|
||||
context.go('/student-dashboard');
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Profile'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'Profile Page - Coming Soon',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
class QuizListPage extends StatelessWidget {
|
||||
@@ -6,21 +7,29 @@ class QuizListPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Quizzes'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'Quiz List - Coming Soon',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (didPop) return;
|
||||
// Navigate back to dashboard instead of exiting app
|
||||
context.go('/student-dashboard');
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Quizzes'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'Quiz List - Coming Soon',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
class QuizPage extends StatelessWidget {
|
||||
final String quizId;
|
||||
|
||||
const QuizPage({
|
||||
super.key,
|
||||
required this.quizId,
|
||||
});
|
||||
|
||||
const QuizPage({super.key, required this.quizId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: Text('Quiz $quizId'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Quiz Page - ID: $quizId\nComing Soon',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (didPop) return;
|
||||
// Navigate back to quiz list instead of exiting app
|
||||
context.go('/quiz');
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
title: Text('Quiz $quizId'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Quiz Page - ID: $quizId\nComing Soon',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user