Files
LearnIT/lib/features/ai_tutor/presentation/widgets/message_bubble.dart

397 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.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: [
isUser
? Text(
content,
style: TextStyle(
color: Colors.white,
fontSize: 16,
height: 1.4,
fontWeight: FontWeight.w500,
),
)
: MarkdownBody(
data: content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 16,
height: 1.4,
),
strong: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.4,
),
em: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 16,
fontStyle: FontStyle.italic,
height: 1.4,
),
listBullet: TextStyle(
color: const Color(0xFF2D3748),
fontSize: 16,
height: 1.4,
),
),
),
],
),
);
}
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';
}
}
}