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.

829 lines
31 KiB

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart';
import '../../../core/widgets/intro_overlay_builder.dart';
import '../../../data/models/channel_app_model.dart';
import '../../../data/services/version_update_service.dart';
import '../controllers/agent_controller.dart';
import '../controllers/message_action_controller.dart';
import 'message_bubble.dart';
import '../widgets/sound_bar_widget.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class AgentView extends StatefulWidget {
const AgentView({Key? key}) : super(key: key);
@override
State<AgentView> createState() => _AgentViewState();
}
class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
late AgentController controller;
late MessageActionController messageActionController;
double _keyboardHeight = 0;
void _showTtsSpeedDialog() {
final primaryColor = Theme.of(context).primaryColor;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final RxInt tempRate = controller.ttsRatePercent.value.obs;
Get.dialog(
AlertDialog(
backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
title: Text(
'ttsSpeed'.tr, // 语速
style: TextStyle(fontSize: 16.sp),
),
content: Obx(() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
'ttsSpeedDefaultHint'.tr, // 默认 +25%
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
),
SizedBox(height: 12.h),
Text(
'+${tempRate.value}%',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
SizedBox(height: 8.h),
Slider(
value: tempRate.value.toDouble(),
min: 0,
max: 100,
divisions: 20,
label: '+${tempRate.value}%',
activeColor: primaryColor,
onChanged: (value) {
tempRate.value = value.round();
},
),
],
);
}),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () async {
Get.back();
await controller.updateTtsRate(tempRate.value);
},
child: Text('confirm'.tr),
),
],
),
barrierDismissible: true,
);
}
@override
void initState() {
super.initState();
controller = Get.find<AgentController>();
messageActionController = Get.find<MessageActionController>();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
final bottomInset = View.of(context).viewInsets.bottom;
final newKeyboardHeight = bottomInset / View.of(context).devicePixelRatio;
if (_keyboardHeight != newKeyboardHeight) {
_keyboardHeight = newKeyboardHeight;
// 更新键盘可见状态
controller.updateKeyboardVisibility(_keyboardHeight > 0);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primaryColor = theme.primaryColor;
final backgroundColor = theme.scaffoldBackgroundColor;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final isOfficialChannel = Get.isRegistered<VersionUpdateService>() &&
VersionUpdateService.to.cachedChannel == UserChannel.OFFICIAL;
return Scaffold(
appBar: AppBar(
elevation: 1,
backgroundColor: backgroundColor,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black87, size: 20.sp),
onPressed: () => Get.back(),
),
titleSpacing: 0,
title: Row(
children: [
Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
color: primaryColor,
borderRadius: BorderRadius.circular(20.w),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 3.w,
offset: const Offset(0, 1),
),
],
),
child: Icon(
Icons.support_agent,
color: Colors.white,
size: 22.sp,
),
),
SizedBox(width: 12.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lily'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
Row(
children: [
Container(
width: 6.w,
height: 6.w,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
SizedBox(width: 4.w),
Text(
'online'.tr,
style: TextStyle(
fontSize: 12.sp,
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
// Obx(() {
// final vipexptime = controller.vipExpiryTime.value;
// DateTime? expiry;
// if (vipexptime > 0) {
// expiry = vipexptime > 1000000000000
// ? DateTime.fromMillisecondsSinceEpoch(vipexptime)
// : DateTime.fromMillisecondsSinceEpoch(
// vipexptime * 1000);
// }
// final text = expiry != null
// ? '${'expiryLabel'.tr}${DateFormat('yyyy-MM-dd').format(expiry)}'
// : 'notActivated'.tr;
// return Text(
// text,
// style: TextStyle(
// fontSize: 12.sp,
// color: Colors.green,
// fontWeight: FontWeight.w500,
// ),
// );
// }),
],
),
],
),
],
),
actions: [
// TTS开关
Obx(() => IconButton(
icon: Icon(
controller.isTtsEnabled.value
? Icons.volume_up
: Icons.volume_off,
size: 24.sp,
color: controller.isTtsEnabled.value
? primaryColor
: isDarkMode
? Colors.grey[400]
: Colors.grey,
),
onPressed: () => controller.toggleTts(),
)),
// 更多选项
Container(
width: 34.w,
height: 34.w,
margin: EdgeInsets.only(right: 8.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.grey[100],
shape: BoxShape.circle,
boxShadow: [
if (!isDarkMode)
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2.w,
spreadRadius: 0.5.w,
),
],
),
child: PopupMenuButton(
icon: Icon(Icons.more_vert,
color: isDarkMode ? Colors.white : Colors.black87,
size: 20.sp),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
color: isDarkMode ? Colors.grey[800] : Colors.white,
itemBuilder: (context) => [
if (isOfficialChannel)
PopupMenuItem(
child: Obx(() => Row(
children: [
Icon(
controller.isShowToolInterruptEnabled.value
? Icons.build_circle
: Icons.build_circle_outlined,
color: controller.isShowToolInterruptEnabled.value
? primaryColor
: (isDarkMode
? Colors.grey[400]
: Colors.grey),
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
"显示工具",
style: TextStyle(fontSize: 14.sp),
),
const Spacer(),
Switch(
value:
controller.isShowToolInterruptEnabled.value,
onChanged: (value) =>
controller.toggleShowAIToolInterrupt(),
activeColor: primaryColor,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
)),
onTap: () => controller.toggleTtsInterrupt(),
),
// 播报打断开关选项
PopupMenuItem(
child: Obx(() => Row(
children: [
Icon(
controller.isTtsInterruptEnabled.value
? Icons.voice_over_off
: Icons.voice_over_off_outlined,
color: controller.isTtsInterruptEnabled.value
? primaryColor
: (isDarkMode ? Colors.grey[400] : Colors.grey),
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
//'ttsInterruptToggle'.tr,
"voice_interrupt".tr, // 语音打断
style: TextStyle(fontSize: 14.sp),
),
const Spacer(),
Switch(
value: controller.isTtsInterruptEnabled.value,
onChanged: (value) =>
controller.toggleTtsInterrupt(),
activeColor: primaryColor,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
)),
onTap: () => controller.toggleTtsInterrupt(),
),
PopupMenuItem(
child: Row(
children: [
Icon(Icons.speed, color: primaryColor, size: 20.sp),
SizedBox(width: 8.w),
Text('ttsSpeed'.tr, // 语速
style: TextStyle(fontSize: 14.sp)),
],
),
onTap: () => Future.delayed(
const Duration(milliseconds: 100),
() => _showTtsSpeedDialog(),
),
),
// 清除聊天记录选项
PopupMenuItem(
child: Row(
children: [
Icon(Icons.delete_outline,
color: primaryColor, size: 20.sp),
SizedBox(width: 8.w),
Text('clearChatHistory'.tr,
style: TextStyle(fontSize: 14.sp)),
],
),
onTap: () => Future.delayed(
const Duration(milliseconds: 100),
() => controller.clearChat(),
),
),
],
),
),
],
),
body: SafeArea(
top: false,
child: Stack(
children: [
IntroStepBuilder(
group: 'ai',
order: 3,
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
params,
false,
title: controller.newAIReplyCompleted.value
? 'aiReplyCompleted'.tr
: 'aiThinking'.tr,
onNext: controller.newAIReplyCompleted.value ? () {} : null,
),
);
},
builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key,
),
),
// 主要聊天界面(占满整个 body,底部按钮浮在其上)
GestureDetector(
onTap: () {
// 点击聊天区域时,收起键盘并切换工具显示状态
FocusScope.of(context).unfocus();
if (controller.isPlusOptionsExpanded.value) {
controller.collapsePlusOptions();
}
},
child: Obx(() {
return Align(
alignment: Alignment.topCenter,
child: ListView.builder(
controller: controller.scrollController,
shrinkWrap: true,
reverse: true,
padding: EdgeInsets.only(
top: 16.h,
// 预留浮动通话/挂断按钮的高度,避免最后一条消息被遮挡
bottom: 140.h,
left: 16.w,
right: 16.w,
),
itemCount: controller.messages.length +
(controller.hasMoreMessages.value ? 1 : 0),
itemBuilder: (context, index) {
// 如果是最后一项且有更多消息,显示加载指示器
if (index == controller.messages.length &&
controller.hasMoreMessages.value) {
return _buildLoadMoreIndicator();
}
final message = controller.messages[
controller.messages.length - 1 - index];
return index == 0
? IntroStepBuilder(
group: 'ai',
order: 4,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['endKey']}'
.tr,
onFinish: () async {
if (controller
.noviceGuideService.aiStep <
5) {
await Future.delayed(const Duration(
milliseconds: 300));
controller
.noviceGuideService.aiStep++;
Intro.of(Get.context!).start(
group: 'ai', reset: true);
}
},
onTap: () async {
params.onFinish();
if (controller
.noviceGuideService.aiStep <
5) {
await Future.delayed(const Duration(
milliseconds: 300));
controller
.noviceGuideService.aiStep++;
Intro.of(Get.context!).start(
group: 'ai', reset: true);
}
},
);
},
builder:
(BuildContext context, GlobalKey key) =>
Padding(
key: key,
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
messageActionController
.onLongPressMessage(context,
isDarkMode, details, message);
},
child: MessageBubble(
sessionid: message.sessionid,
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
functionCalls: message.functionCalls,
isToolCallsExpanded:
message.isToolCallsExpanded,
),
),
),
)
: Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
messageActionController
.onLongPressMessage(context,
isDarkMode, details, message);
},
child: MessageBubble(
sessionid: message.sessionid,
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
functionCalls: message.functionCalls,
isToolCallsExpanded:
message.isToolCallsExpanded,
),
),
);
},
),
);
}),
),
// 底部浮动通话按钮:悬浮在聊天界面之上
// 点击启动耳机录音 → JieliAssistantPort 通路
// (MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING)
Align(
alignment: Alignment.bottomCenter,
child: Obx(() {
if (controller.isDeviceCallMode.value) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.startDeviceCall(),
child: Container(
width: 68.w,
height: 68.w,
decoration: BoxDecoration(
color: const Color(0xFF10B981),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF10B981)
.withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
),
child: Icon(
Icons.call,
color: Colors.white,
size: 30.sp,
),
),
),
SizedBox(height: 8.h),
Text(
'通话',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
),
),
],
),
);
}),
),
// 打电话模式覆盖层
Obx(() {
if (controller.isPhoneCallMode.value) {
return _buildPhoneCallOverlay(context, isDarkMode);
}
return const SizedBox.shrink();
}),
// 设备通话模式覆盖层
Obx(() {
if (controller.isDeviceCallMode.value) {
return _buildDeviceCallOverlay(context, isDarkMode);
}
return const SizedBox.shrink();
}),
],
),
),
);
}
// 构建打电话模式覆盖层
Widget _buildPhoneCallOverlay(BuildContext context, bool isDarkMode) {
return Container(
color: Colors.transparent, // 设置整个覆盖层为透明
child: Column(
children: [
// 中间空白区域 - 完全透明
const Expanded(
child: SizedBox(), // 空的占位符,完全透明
),
// 底部控制按钮 - 包含音频波纹
Container(
color: isDarkMode ? Colors.black : Colors.white,
child: _buildPhoneCallControls(isDarkMode),
),
],
),
);
}
// 构建打电话模式控制按钮
Widget _buildPhoneCallControls(bool isDarkMode) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 录音控制按钮(左侧) - 控制录音输入功能
Obx(() => _buildPhoneControlButton(
icon: controller.isListening.value
? Icons.mic // 录音中显示麦克风图标
: Icons.mic_off, // 未录音显示麦克风关闭图标
onTap: () => controller.togglePhoneCallRecording(),
isDarkMode: isDarkMode,
isActive: controller.isListening.value,
// backgroundColor: controller.isListening.value
// ? Colors.red // 录音中显示红色背景
// : Theme.of(context).primaryColor, // 未录音显示主题色
)),
// 音频波纹组件(中间) - 仅显示动态波形效果,无其他UI元素
Container(
width: 120.w, // 设置固定宽度
height: 64.h, // 与按钮高度一致
alignment: Alignment.center, // 确保波纹居中
child: const AgentSoundBarsWidget(barCount: 12), // 适应宽度的波纹条数
),
// 关闭打电话模式按钮(右侧) - 退出打电话模式
_buildPhoneControlButton(
icon: Icons.close,
onTap: () => controller.endPhoneCall(),
isDarkMode: isDarkMode,
isActive: false,
//backgroundColor: Colors.red,
iconColor: Colors.redAccent,
),
],
),
);
}
// 构建打电话控制按钮
Widget _buildPhoneControlButton({
required IconData icon,
required VoidCallback onTap,
required bool isDarkMode,
required bool isActive,
Color? backgroundColor,
Color? iconColor,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64.w, // 按钮尺寸
height: 64.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor ??
(isDarkMode ? Colors.grey[800] : Colors.grey[250]),
border: Border.all(color: Colors.blue, width: 2.w),
boxShadow: [
BoxShadow(
color: (backgroundColor ?? Colors.grey).withValues(alpha: 0.3),
blurRadius: 8.w,
spreadRadius: 1.w,
offset: Offset(0, 2.h),
),
],
),
child: Icon(
icon,
color: iconColor ?? (isDarkMode ? Colors.white : Colors.black),
size: 28.sp, // 图标尺寸
),
),
);
}
// ─── 设备通话覆盖层(接听后仅一个挂断按钮,悬浮在聊天界面之上)─────────────────────
Widget _buildDeviceCallOverlay(BuildContext context, bool isDarkMode) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.endDeviceCall(),
child: Container(
width: 68.w,
height: 68.w,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
),
child: Transform.rotate(
angle: 2.356,
child: Icon(
Icons.call_end,
color: Colors.white,
size: 30.sp,
),
),
),
),
SizedBox(height: 8.h),
Text(
'挂断',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.grey[300] : Colors.grey[700],
),
),
],
),
),
);
}
// 构建加载更多指示器
Widget _buildLoadMoreIndicator() {
return Obx(
() {
if (controller.isLoadingMore.value) {
// 正在加载状态
return Container(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16.w,
height: 16.h,
child: CircularProgressIndicator(
strokeWidth: 2.w,
),
),
SizedBox(width: 8.w),
Text(
'loadMoreMessages'.tr,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey,
),
),
],
),
),
);
} else if (controller.isLoadingMoreError.value) {
// 加载失败状态
return Container(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Center(
child: GestureDetector(
onTap: () => controller.loadMoreMessages(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh,
size: 16.sp,
color: Colors.grey,
),
SizedBox(width: 8.w),
Text(
'loadFailedClickRetry'.tr,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey,
),
),
],
),
),
),
);
} else {
// 默认状态(有更多消息但未加载)
return Container(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Center(
child: Text(
'swipeUpForMore'.tr,
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
),
),
),
);
}
},
);
}
}