397 lines
12 KiB
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';
|
|
}
|
|
}
|
|
}
|