Files
ai_chat_assistant/lib/widgets/chat_bubble.dart

377 lines
14 KiB
Dart
Raw Normal View History

2025-08-12 13:36:42 +08:00
import 'package:ai_chat_assistant/utils/common_util.dart';
2025-08-13 09:23:09 +08:00
import 'package:ai_chat_assistant/widgets/rotating_image.dart';
2025-08-12 13:36:42 +08:00
import 'package:basic_intl/intl.dart';
2025-06-18 11:28:37 +08:00
import 'package:flutter/material.dart';
2025-08-12 13:36:42 +08:00
import 'package:flutter_markdown/flutter_markdown.dart';
import '../enums/message_status.dart';
2025-06-18 11:28:37 +08:00
import '../models/chat_message.dart';
2025-08-12 13:36:42 +08:00
import 'package:provider/provider.dart';
import '../services/message_service.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
2025-06-18 11:28:37 +08:00
2025-08-12 13:36:42 +08:00
class ChatBubble extends StatefulWidget {
2025-06-18 11:28:37 +08:00
final ChatMessage message;
const ChatBubble({super.key, required this.message});
2025-08-12 13:36:42 +08:00
@override
State<ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<ChatBubble> {
bool _liked = false;
bool _disliked = false;
ChatMessage get message => widget.message;
2025-06-18 11:28:37 +08:00
@override
Widget build(BuildContext context) {
2025-08-12 13:36:42 +08:00
final isThinking = message.status == MessageStatus.thinking;
2025-06-18 11:28:37 +08:00
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
2025-08-12 13:36:42 +08:00
child: Column(
crossAxisAlignment:
message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(
minWidth: 50,
),
width: isThinking ? double.infinity : null,
child: isThinking
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: message.isUser
? CommonUtil.commonColor
: Colors.white.withValues(alpha: 0.12),
2025-08-12 22:17:40 +08:00
borderRadius: message.isUser
? BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
)
: BorderRadius.only(
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
2025-08-12 13:36:42 +08:00
),
child: _buildAssistantContent(isThinking),
)
: IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: message.isUser
? CommonUtil.commonColor
: Colors.white.withValues(alpha: 0.12),
2025-08-12 22:17:40 +08:00
borderRadius: message.isUser
? BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
)
: BorderRadius.only(
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
2025-08-12 13:36:42 +08:00
),
child: message.isUser
? _buildUserContent()
: _buildAssistantContent(isThinking),
),
),
2025-06-18 11:28:37 +08:00
),
2025-08-12 13:36:42 +08:00
],
),
);
}
Widget _buildUserContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.text.trim().isEmpty) ...[
_buildStatusRow(hasBottomMargin: false),
] else ...[
_buildStatusRow(hasBottomMargin: true),
Text(
message.text,
2025-08-12 22:17:40 +08:00
style: TextStyle(color: Colors.white, fontSize: 16),
2025-08-12 13:36:42 +08:00
)
],
],
);
}
Widget _buildAssistantContent(bool isThinking) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusRow(hasBottomMargin: message.text.trim().isNotEmpty),
if (message.text.trim().isNotEmpty) _buildMarkdownBody(),
_buildDisclaimer(isThinking),
],
);
}
Widget _buildStatusRow({required bool hasBottomMargin}) {
Widget icon;
Color color;
switch (message.status) {
case MessageStatus.listening:
case MessageStatus.recognizing:
case MessageStatus.thinking:
2025-08-13 09:23:09 +08:00
icon = RotatingImage(
imagePath: 'assets/images/thinking_circle.png'
);
color = Colors.white;
break;
2025-08-12 13:36:42 +08:00
case MessageStatus.executing:
icon = const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue)),
);
color = Colors.white;
break;
case MessageStatus.completed:
case MessageStatus.success:
2025-08-13 11:30:53 +08:00
icon = Image.asset(
'assets/images/checked.png',
width: 20,
height: 20
);
color = Colors.white;
2025-08-12 13:36:42 +08:00
break;
case MessageStatus.failure:
icon = const Icon(Icons.error, size: 16, color: Colors.red);
color = Colors.red;
break;
case MessageStatus.aborted:
icon = const Icon(Icons.block, size: 16, color: Colors.grey);
color = Colors.grey;
break;
default:
return const SizedBox.shrink();
}
return _statusRow(
icon: icon, color: color, hasBottomMargin: hasBottomMargin);
}
Widget _statusRow({
required Widget icon,
required Color color,
required bool hasBottomMargin,
Color? backgroundColor,
BorderRadius? borderRadius,
}) {
return Container(
margin: hasBottomMargin ? const EdgeInsets.only(bottom: 6) : null,
decoration: backgroundColor != null
? BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius ?? BorderRadius.circular(0))
: null,
padding: backgroundColor != null ? const EdgeInsets.all(6) : null,
child: Row(
mainAxisAlignment:
message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
icon,
const SizedBox(width: 6),
Text(
Intl.getCurrentLocale().startsWith('zh')
? message.status.chinese
: message.status.english,
style: TextStyle(
fontSize: 16, color: color, fontWeight: FontWeight.bold),
),
],
2025-06-18 11:28:37 +08:00
),
);
}
2025-08-12 13:36:42 +08:00
Widget _buildMarkdownBody() {
return MarkdownBody(
data: message.text,
styleSheet: _markdownStyleSheet,
sizedImageBuilder: (config) {
return Image.network(
config.uri.toString(),
width: config.width,
height: config.height,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
},
onTapLink: (text, href, title) {
// todo
},
);
}
static final MarkdownStyleSheet _markdownStyleSheet = MarkdownStyleSheet(
p: const TextStyle(fontSize: 16, color: Colors.white, height: 1.5),
h1: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
h2: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
h3: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
h4: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
h5: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
h6: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
strong: const TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.5,
fontWeight: FontWeight.bold),
em: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.5,
fontStyle: FontStyle.italic),
code: const TextStyle(
fontSize: 16, backgroundColor: Color(0xFFF5F5F5), color: Colors.white),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4)),
blockquote: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: Colors.grey, width: 4))),
listBullet: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
tableHead: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.5,
fontWeight: FontWeight.bold),
tableBody: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5),
tableBorder: TableBorder.all(color: Colors.grey, width: 1),
tableCellsPadding: const EdgeInsets.all(8),
tableColumnWidth: const FlexColumnWidth(),
);
Widget _buildDisclaimer(bool isThinking) {
if (!isThinking && message.status != MessageStatus.completed) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
2025-08-12 22:17:40 +08:00
const Opacity(
2025-08-13 11:30:53 +08:00
opacity: 0.08,
child: Divider(color: Colors.white, height: 1),
2025-08-12 22:17:40 +08:00
),
2025-08-13 11:30:53 +08:00
const SizedBox(height: 12),
2025-08-12 13:36:42 +08:00
SizedBox(
height: 24,
child: isThinking
? Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
onTap: () {
Provider.of<MessageService>(context, listen: false)
.abortReply();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.stop_circle_outlined,
2025-08-12 22:17:40 +08:00
size: 18, color: CommonUtil.commonColor),
2025-08-12 13:36:42 +08:00
const SizedBox(width: 4),
Text(
Intl.getCurrentLocale().startsWith('zh')
? '停止回答'
: 'Stop',
style: const TextStyle(
2025-08-12 22:17:40 +08:00
color: Colors.white, fontSize: 14)),
2025-08-12 13:36:42 +08:00
],
),
2025-08-12 22:17:40 +08:00
)
2025-08-12 13:36:42 +08:00
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
Intl.getCurrentLocale().startsWith('zh')
? '内容由AI生成具体以实际情况为准'
: 'Generated by AI, for reference only.',
style:
const TextStyle(fontSize: 12, color: Colors.grey),
),
),
Row(
children: [
InkWell(
onTap: () async {
await Clipboard.setData(ClipboardData(
text:
CommonUtil.cleanText(message.text, false)));
Fluttertoast.showToast(
msg: Intl.getCurrentLocale().startsWith('zh')
? '已复制到剪贴板'
: 'Copied to clipboard');
},
2025-08-12 22:17:40 +08:00
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Image.asset(
'assets/images/copy.png',
2025-08-13 11:30:53 +08:00
width: 24,
height: 24
2025-08-12 22:17:40 +08:00
),
2025-08-12 13:36:42 +08:00
),
),
InkWell(
onTap: () {
setState(() {
_liked = !_liked;
if (_liked) _disliked = false;
});
},
child: Padding(
padding: const EdgeInsets.only(left: 12),
2025-08-12 22:17:40 +08:00
child: _liked
? Image.asset('assets/images/liked2.png',
2025-08-13 11:30:53 +08:00
width: 24, height: 24)
2025-08-12 22:17:40 +08:00
: Image.asset('assets/images/liked1.png',
2025-08-13 11:30:53 +08:00
width: 24, height: 24),
2025-08-12 13:36:42 +08:00
),
),
InkWell(
onTap: () {
setState(() {
_disliked = !_disliked;
if (_disliked) _liked = false;
});
},
child: Padding(
2025-08-13 09:23:09 +08:00
padding: const EdgeInsets.only(left: 12),
child: _disliked
? Image.asset('assets/images/disliked2.png',
2025-08-13 11:30:53 +08:00
width: 24, height: 24)
2025-08-13 09:23:09 +08:00
: Image.asset('assets/images/disliked1.png',
2025-08-13 11:30:53 +08:00
width: 24, height: 24)),
2025-08-12 13:36:42 +08:00
),
],
),
],
),
),
],
);
}
}