import 'package:ai_chat_assistant/utils/common_util.dart'; import 'package:ai_chat_assistant/widgets/rotating_image.dart'; import 'package:basic_intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import '../enums/message_status.dart'; import '../models/chat_message.dart'; import 'package:provider/provider.dart'; import '../services/message_service.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; class ChatBubble extends StatefulWidget { final ChatMessage message; const ChatBubble({super.key, required this.message}); @override State createState() => _ChatBubbleState(); } class _ChatBubbleState extends State { bool _liked = false; bool _disliked = false; ChatMessage get message => widget.message; @override Widget build(BuildContext context) { final isThinking = message.status == MessageStatus.thinking; return Align( alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, 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), 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), ), ), 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), 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), ), ), child: message.isUser ? _buildUserContent() : _buildAssistantContent(isThinking), ), ), ), ], ), ); } Widget _buildUserContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (message.text.trim().isEmpty) ...[ _buildStatusRow(hasBottomMargin: false), ] else ...[ _buildStatusRow(hasBottomMargin: true), Text( message.text, style: TextStyle(color: Colors.white, fontSize: 16), ) ], ], ); } 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: icon = RotatingImage( imagePath: 'assets/images/thinking_circle.png' ); color = Colors.white; break; case MessageStatus.executing: icon = const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.blue)), ); color = Colors.white; break; case MessageStatus.completed: case MessageStatus.success: icon = Image.asset( 'assets/images/checked.png', width: 20, height: 20 ); color = Colors.white; 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), ), ], ), ); } 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), const Opacity( opacity: 0.08, child: Divider(color: Colors.white, height: 1), ), const SizedBox(height: 12), SizedBox( height: 24, child: isThinking ? Row( mainAxisAlignment: MainAxisAlignment.end, children: [ InkWell( onTap: () { Provider.of(context, listen: false) .abortReply(); }, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.stop_circle_outlined, size: 18, color: CommonUtil.commonColor), const SizedBox(width: 4), Text( Intl.getCurrentLocale().startsWith('zh') ? '停止回答' : 'Stop', style: const TextStyle( color: Colors.white, fontSize: 14)), ], ), ) ], ) : 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'); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: Image.asset( 'assets/images/copy.png', width: 24, height: 24 ), ), ), InkWell( onTap: () { setState(() { _liked = !_liked; if (_liked) _disliked = false; }); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: _liked ? Image.asset('assets/images/liked2.png', width: 24, height: 24) : Image.asset('assets/images/liked1.png', width: 24, height: 24), ), ), InkWell( onTap: () { setState(() { _disliked = !_disliked; if (_disliked) _liked = false; }); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: _disliked ? Image.asset('assets/images/disliked2.png', width: 24, height: 24) : Image.asset('assets/images/disliked1.png', width: 24, height: 24)), ), ], ), ], ), ), ], ); } }