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.
555 lines
18 KiB
555 lines
18 KiB
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../../../core/theme/app_palette.dart';
|
|
import '../../../data/services/bailian_multimodal_service.dart';
|
|
import '../controllers/emai_controller.dart';
|
|
import 'widgets/emai_bubble.dart';
|
|
import 'widgets/emai_level_bar.dart';
|
|
|
|
/// EMAI 助手对话页。
|
|
///
|
|
/// 结构自上而下:状态条 → 消息列表 → 输入区(按住说话 / 文字输入二选一)。
|
|
/// 所有 AI 能力都在阿里百炼多模态应用里,页面本身只管采音、播音和展示。
|
|
class EmaiView extends GetView<EmaiController> {
|
|
/// [controllerTag]:不传 = 取全局那个 [EmaiController](EMAI 自己),行为不变。
|
|
/// 传了则取同名 tag 的实例——给复用这套 UI 的其它 agent 用(如 EaiCar)。
|
|
/// [titleKey]:标题栏的 i18n key,默认 EMAI 自己的。
|
|
const EmaiView({super.key, this.controllerTag, this.titleKey = 'agentEmai'});
|
|
|
|
final String? controllerTag;
|
|
final String titleKey;
|
|
|
|
/// GetView 用它决定 `controller` 取哪个实例
|
|
@override
|
|
String? get tag => controllerTag;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppPalette.background(isDark),
|
|
appBar: _buildAppBar(isDark),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildStatusStrip(isDark),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
_buildMessageList(isDark),
|
|
_buildJumpToLatest(isDark),
|
|
],
|
|
),
|
|
),
|
|
_buildInputArea(isDark),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar(bool isDark) {
|
|
return AppBar(
|
|
backgroundColor: AppPalette.surface(isDark),
|
|
elevation: 0.5,
|
|
centerTitle: true,
|
|
leading: IconButton(
|
|
icon: Icon(Icons.arrow_back_ios,
|
|
size: 20.sp, color: AppPalette.textPrimary(isDark)),
|
|
onPressed: () => Get.back(),
|
|
),
|
|
title: Text(
|
|
titleKey.tr,
|
|
style: TextStyle(
|
|
fontSize: 17.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppPalette.textPrimary(isDark),
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'emaiClearChat'.tr,
|
|
icon: Icon(Icons.delete_outline,
|
|
size: 21.sp, color: AppPalette.textSecondary(isDark)),
|
|
onPressed: () => _confirmClear(isDark),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _confirmClear(bool isDark) {
|
|
if (controller.messages.isEmpty) return;
|
|
Get.dialog(
|
|
AlertDialog(
|
|
backgroundColor: AppPalette.surface(isDark),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)),
|
|
title: Text('emaiClearChat'.tr, style: TextStyle(fontSize: 16.sp)),
|
|
content: Text('emaiClearConfirm'.tr, style: TextStyle(fontSize: 14.sp)),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(),
|
|
child: Text('cancel'.tr),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
controller.clearMessages();
|
|
Get.back();
|
|
},
|
|
child: Text('confirm'.tr,
|
|
style: const TextStyle(color: AppPalette.error)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------- 状态条 ----------------
|
|
|
|
Widget _buildStatusStrip(bool isDark) {
|
|
return Obx(() {
|
|
final link = controller.linkState.value;
|
|
final dialog = controller.dialogState.value;
|
|
|
|
late final String label;
|
|
late final Color color;
|
|
late final IconData icon;
|
|
VoidCallback? action;
|
|
|
|
switch (link) {
|
|
case EmaiLinkState.unconfigured:
|
|
label = 'emaiNotConfigured'.tr;
|
|
color = AppPalette.warning;
|
|
icon = Icons.info_outline;
|
|
// 冷启动时 AppConfig 可能还没拉回来,给个重试入口,
|
|
// 不然用户只能退出重进页面
|
|
action = controller.reconnect;
|
|
break;
|
|
case EmaiLinkState.failed:
|
|
label = 'emaiConnectFailed'.tr;
|
|
color = AppPalette.error;
|
|
icon = Icons.error_outline;
|
|
action = controller.reconnect;
|
|
break;
|
|
case EmaiLinkState.connecting:
|
|
label = 'emaiConnecting'.tr;
|
|
color = AppPalette.textSecondary(isDark);
|
|
icon = Icons.sync;
|
|
break;
|
|
case EmaiLinkState.onEarphone:
|
|
label = 'emaiOnEarphone'.tr;
|
|
color = AppPalette.accent;
|
|
icon = Icons.headset_mic;
|
|
// 没有这个入口的话,耳机会话不结束页面就永远停在这个状态
|
|
action = controller.takeBackFromEarphone;
|
|
break;
|
|
case EmaiLinkState.idle:
|
|
label = 'emaiOffline'.tr;
|
|
color = AppPalette.textSecondary(isDark);
|
|
icon = Icons.cloud_off;
|
|
action = controller.reconnect;
|
|
break;
|
|
case EmaiLinkState.ready:
|
|
switch (dialog) {
|
|
case BailianDialogState.listening:
|
|
// 「正在聆听」只在麦克风真的开着时显示。
|
|
//
|
|
// 服务端在播报期间、以及播报刚结束时也会把对话状态推成 listening,
|
|
// 但那时本地麦克风并没有开(本页是「按住说话」,isTalking 才代表开麦)。
|
|
// 照着服务端状态无脑显示"正在聆听",会让用户以为 App 一直在录他说话。
|
|
if (controller.isTalking.value) {
|
|
label = 'emaiListening'.tr;
|
|
color = AppPalette.accent;
|
|
icon = Icons.hearing;
|
|
} else {
|
|
label = 'emaiReady'.tr;
|
|
color = AppPalette.success;
|
|
icon = Icons.check_circle_outline;
|
|
}
|
|
break;
|
|
case BailianDialogState.thinking:
|
|
label = 'emaiThinking'.tr;
|
|
color = AppPalette.warning;
|
|
icon = Icons.psychology_outlined;
|
|
break;
|
|
case BailianDialogState.responding:
|
|
label = 'emaiResponding'.tr;
|
|
color = AppPalette.success;
|
|
icon = Icons.graphic_eq;
|
|
break;
|
|
case BailianDialogState.idle:
|
|
label = 'emaiReady'.tr;
|
|
color = AppPalette.success;
|
|
icon = Icons.check_circle_outline;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h),
|
|
color: color.withValues(alpha: isDark ? 0.14 : 0.08),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 15.sp, color: color),
|
|
SizedBox(width: 6.w),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(fontSize: 12.sp, color: color),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (action != null)
|
|
GestureDetector(
|
|
onTap: action,
|
|
child: Text(
|
|
link == EmaiLinkState.onEarphone
|
|
? 'emaiTakeBack'.tr
|
|
: 'emaiReconnect'.tr,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
color: color,
|
|
fontWeight: FontWeight.w600,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ---------------- 消息列表 ----------------
|
|
|
|
Widget _buildMessageList(bool isDark) {
|
|
return Obx(() {
|
|
if (controller.messages.isEmpty) return _buildEmptyState(isDark);
|
|
return ListView.builder(
|
|
controller: controller.scrollController,
|
|
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 12.h),
|
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
itemCount: controller.messages.length,
|
|
itemBuilder: (_, i) =>
|
|
EmaiBubble(message: controller.messages[i], isDark: isDark),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 用户往上翻历史时,右下角给一个回到最新的入口。
|
|
/// 不然翻上去之后要一路拖回来——历史可能有几百条。
|
|
Widget _buildJumpToLatest(bool isDark) {
|
|
return Obx(() {
|
|
if (controller.isPinnedToBottom.value || controller.messages.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Positioned(
|
|
right: 14.w,
|
|
bottom: 14.h,
|
|
child: GestureDetector(
|
|
onTap: controller.jumpToLatest,
|
|
child: Container(
|
|
width: 36.w,
|
|
height: 36.w,
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.surface(isDark),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: AppPalette.border(isDark)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.12),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(Icons.keyboard_double_arrow_down,
|
|
size: 19.sp, color: AppPalette.textSecondary(isDark)),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildEmptyState(bool isDark) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 40.w),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 72.w,
|
|
height: 72.w,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: [Color(0xFF5B8DEF), Color(0xFF7E57C2)],
|
|
),
|
|
),
|
|
child: Icon(Icons.auto_awesome, size: 34.sp, color: Colors.white),
|
|
),
|
|
SizedBox(height: 16.h),
|
|
Text(
|
|
'agentEmai'.tr,
|
|
style: TextStyle(
|
|
fontSize: 17.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppPalette.textPrimary(isDark),
|
|
),
|
|
),
|
|
SizedBox(height: 8.h),
|
|
Text(
|
|
'emaiEmptyHint'.tr,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
height: 1.5,
|
|
color: AppPalette.textSecondary(isDark),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------- 输入区 ----------------
|
|
|
|
Widget _buildInputArea(bool isDark) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.surface(isDark),
|
|
border: Border(top: BorderSide(color: AppPalette.border(isDark))),
|
|
),
|
|
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 10.h),
|
|
child: Obx(() {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
IconButton(
|
|
padding: EdgeInsets.zero,
|
|
constraints: BoxConstraints(minWidth: 38.w, minHeight: 38.w),
|
|
icon: Icon(
|
|
controller.isTextMode.value ? Icons.mic_none : Icons.keyboard,
|
|
size: 24.sp,
|
|
color: AppPalette.textSecondary(isDark),
|
|
),
|
|
tooltip: controller.isTextMode.value
|
|
? 'emaiSwitchToVoice'.tr
|
|
: 'emaiSwitchToText'.tr,
|
|
onPressed: controller.toggleInputMode,
|
|
),
|
|
SizedBox(width: 6.w),
|
|
Expanded(
|
|
child: controller.isTextMode.value
|
|
? _buildTextField(isDark)
|
|
: _buildTalkButton(isDark),
|
|
),
|
|
SizedBox(width: 6.w),
|
|
_buildTrailingButton(isDark),
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField(bool isDark) {
|
|
return Container(
|
|
constraints: BoxConstraints(minHeight: 40.h, maxHeight: 120.h),
|
|
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.background(isDark),
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: TextField(
|
|
controller: controller.textController,
|
|
maxLines: null,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => controller.sendTextMessage(),
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
color: AppPalette.textPrimary(isDark),
|
|
),
|
|
decoration: InputDecoration(
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(vertical: 11.h),
|
|
hintText: 'emaiInputHint'.tr,
|
|
hintStyle: TextStyle(
|
|
fontSize: 15.sp,
|
|
color: AppPalette.textHint(isDark),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 语音输入按钮。两种用法共用这一个控件:
|
|
///
|
|
/// - **点一下** → 进入/退出「连续对话」(服务端 VAD 切句,麦克风一直开着)
|
|
/// - **按住不放** → 单句模式(客户端划段),上滑取消
|
|
///
|
|
/// 点击与按住的区分放在 controller 的 [EmaiController.endTalk] 里做
|
|
/// (按下到松开短于 250ms 算点击)。放在那边是因为按下的瞬间就已经开麦并
|
|
/// 开了一段语音,判成点击时要把那一段取消掉——这是 controller 的事。
|
|
Widget _buildTalkButton(bool isDark) {
|
|
final enabled = controller.canTalk;
|
|
final continuous = controller.isContinuous.value;
|
|
|
|
// 连续对话中:整条就是一个「点击结束」的开关,不再有按住/上滑那套语义
|
|
if (continuous) {
|
|
return GestureDetector(
|
|
onTap: controller.toggleContinuous,
|
|
child: Container(
|
|
height: 40.h,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.accentDark,
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
EmaiLevelBar(color: Colors.white, level: controller.level.value),
|
|
SizedBox(width: 10.w),
|
|
Text(
|
|
'emaiContinuousOn'.tr,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 设备对话中:直接对设备说话就行;点这里 = 改用手机聊(结束设备对话)
|
|
if (controller.showDeviceTakeOverButton) {
|
|
return GestureDetector(
|
|
onTap: controller.takeBackAndTalk,
|
|
child: Container(
|
|
height: 40.h,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.accent,
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
|
// 按钮夹在两个图标之间,宽度有限:只许一行,放不下就整体缩小,别折行溢出
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
'emaiDeviceTalkingTapPhone'.tr,
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final talking = controller.isTalking.value;
|
|
final cancelling = controller.isCancelPending.value;
|
|
|
|
final Color bg = !enabled
|
|
? AppPalette.border(isDark)
|
|
: cancelling
|
|
? AppPalette.error
|
|
: talking
|
|
? AppPalette.accentDark
|
|
: AppPalette.accent;
|
|
|
|
final String label = !enabled
|
|
? 'emaiOffline'.tr
|
|
: cancelling
|
|
? 'emaiReleaseToCancel'.tr
|
|
: talking
|
|
? 'emaiReleaseToSend'.tr
|
|
// 空闲态展示「点击开始连续对话」而不是「按住说话」:
|
|
// 点击是这个按钮现在的主用法,按住说话仍然可用但不再是主推的
|
|
: 'emaiTapForContinuous'.tr;
|
|
|
|
return Listener(
|
|
onPointerDown: enabled ? (_) => controller.startTalk() : null,
|
|
onPointerMove: (e) {
|
|
if (!controller.isTalking.value) return;
|
|
// 上滑超过按钮高度的一半就进入"松开取消"
|
|
controller.updateCancelPending(e.localPosition.dy < -20);
|
|
},
|
|
onPointerUp: (_) => controller.endTalk(),
|
|
onPointerCancel: (_) => controller.endTalk(),
|
|
child: Container(
|
|
height: 40.h,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (talking) ...[
|
|
EmaiLevelBar(color: Colors.white, level: controller.level.value),
|
|
SizedBox(width: 10.w),
|
|
],
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: enabled ? Colors.white : AppPalette.textHint(isDark),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 文字模式下是发送键;语音模式下、助手正在说话时是打断键
|
|
Widget _buildTrailingButton(bool isDark) {
|
|
if (controller.isTextMode.value) {
|
|
final active = controller.hasInputText.value && controller.canTalk;
|
|
return _circleButton(
|
|
icon: Icons.arrow_upward,
|
|
bg: active ? AppPalette.accent : AppPalette.border(isDark),
|
|
fg: active ? Colors.white : AppPalette.textHint(isDark),
|
|
onTap: active ? controller.sendTextMessage : null,
|
|
);
|
|
}
|
|
final responding = controller.isResponding;
|
|
return _circleButton(
|
|
icon: Icons.stop,
|
|
bg: responding ? AppPalette.error : AppPalette.border(isDark),
|
|
fg: responding ? Colors.white : AppPalette.textHint(isDark),
|
|
onTap: responding ? controller.interrupt : null,
|
|
);
|
|
}
|
|
|
|
Widget _circleButton({
|
|
required IconData icon,
|
|
required Color bg,
|
|
required Color fg,
|
|
VoidCallback? onTap,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 40.w,
|
|
height: 40.w,
|
|
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
|
|
child: Icon(icon, size: 20.sp, color: fg),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|