import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../../core/theme/app_palette.dart'; import '../../../../data/models/emai_message.dart'; /// EMAI 助手的消息气泡。 /// /// 用户侧是纯文本(ASR 结果不会有 markdown),助手侧走 [MarkdownBody]—— /// 百炼应用的回复里经常有列表和加粗。长按复制。 class EmaiBubble extends StatelessWidget { const EmaiBubble({super.key, required this.message, required this.isDark}); final EmaiMessage message; final bool isDark; @override Widget build(BuildContext context) { final isUser = message.isUser; final Color bg = message.isError ? AppPalette.error.withValues(alpha: isDark ? 0.18 : 0.10) : isUser ? AppPalette.accent : AppPalette.surface(isDark); final Color fg = message.isError ? AppPalette.error : isUser ? Colors.white : AppPalette.textPrimary(isDark); return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: GestureDetector( onLongPress: () { if (message.text.trim().isEmpty) return; Clipboard.setData(ClipboardData(text: message.text)); Get.snackbar('tip'.tr, 'copySuccess'.tr, snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 1)); }, child: Container( constraints: BoxConstraints(maxWidth: 0.76.sw), margin: EdgeInsets.symmetric(vertical: 5.h), padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.only( topLeft: Radius.circular(16.r), topRight: Radius.circular(16.r), bottomLeft: Radius.circular(isUser ? 16.r : 4.r), bottomRight: Radius.circular(isUser ? 4.r : 16.r), ), border: isUser || message.isError ? null : Border.all(color: AppPalette.border(isDark)), ), child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _content(fg), // 耳机按键唤醒时聊的那些轮次,标一下来源, // 免得用户在页面上看到没印象的内容不知道哪来的 if (message.fromEarphone) ...[ SizedBox(height: 4.h), Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.headset_mic, size: 11.sp, color: fg.withValues(alpha: 0.55)), SizedBox(width: 3.w), Text( 'emaiViaEarphone'.tr, style: TextStyle( fontSize: 10.sp, color: fg.withValues(alpha: 0.55)), ), ], ), ], ], ), ), ), ); } Widget _content(Color fg) { // 还没收到第一个字的助手气泡:显示三个点,别让它空着闪一下 if (!message.isUser && message.text.isEmpty && !message.isFinal) { return _TypingDots(color: fg); } if (message.isUser || message.isError) { return Text( message.text, style: TextStyle(fontSize: 15.sp, height: 1.4, color: fg), ); } return MarkdownBody( data: message.text, selectable: false, styleSheet: MarkdownStyleSheet( p: TextStyle(fontSize: 15.sp, height: 1.45, color: fg), listBullet: TextStyle(fontSize: 15.sp, color: fg), strong: TextStyle(fontWeight: FontWeight.w600, color: fg), code: TextStyle( fontSize: 13.sp, color: fg, backgroundColor: Colors.transparent, ), ), ); } } /// 等待首字时的省略号动画 class _TypingDots extends StatefulWidget { const _TypingDots({required this.color}); final Color color; @override State<_TypingDots> createState() => _TypingDotsState(); } class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), )..repeat(); @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _c, builder: (_, __) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { // 三个点依次亮起:把 0~1 的进度切成三段 final phase = (_c.value * 3 - i).clamp(0.0, 1.0); final opacity = 0.3 + 0.7 * (phase < 0.5 ? phase * 2 : 1); return Container( width: 6.w, height: 6.w, margin: EdgeInsets.symmetric(horizontal: 2.w, vertical: 4.h), decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color.withValues(alpha: opacity.clamp(0.0, 1.0)), ), ); }), ); }, ); } }