You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

168 lines
5.4 KiB

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)),
),
);
}),
);
},
);
}
}