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.
 
 
 
 
 
 

527 lines
17 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),
),
],
),
),
);
}
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),
),
);
}
}