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