|
|
@ -8,115 +8,68 @@ import '../models/message_model.dart'; |
|
|
import '../../../core/theme/app_colors.dart'; |
|
|
import '../../../core/theme/app_colors.dart'; |
|
|
import '../../../core/widgets/message_bubble.dart'; |
|
|
import '../../../core/widgets/message_bubble.dart'; |
|
|
|
|
|
|
|
|
class ChatView extends StatefulWidget { |
|
|
class ChatView extends GetView<ChatController> { |
|
|
const ChatView({Key? key}) : super(key: key); |
|
|
const ChatView({Key? key}) : super(key: key); |
|
|
|
|
|
|
|
|
@override |
|
|
@override |
|
|
State<ChatView> createState() => _ChatViewState(); |
|
|
Widget build(BuildContext context) { |
|
|
} |
|
|
return Scaffold( |
|
|
|
|
|
backgroundColor: Colors.grey[50], // 使用浅灰背景,类似HTML模板 |
|
|
class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
appBar: _buildAppBar(), |
|
|
// 获取控制器 |
|
|
body: _buildBody(), |
|
|
ChatController get controller => Get.find<ChatController>(); |
|
|
|
|
|
|
|
|
|
|
|
// 添加ScrollController用于控制滚动 |
|
|
|
|
|
final ScrollController _scrollController = ScrollController(); |
|
|
|
|
|
|
|
|
|
|
|
// 添加一个动画控制器,用于波形动画 |
|
|
|
|
|
late final AnimationController _waveAnimationController; |
|
|
|
|
|
|
|
|
|
|
|
// 添加录音控制器,用于获取麦克风数据 |
|
|
|
|
|
late final RecorderController _recorderController; |
|
|
|
|
|
|
|
|
|
|
|
@override |
|
|
|
|
|
void initState() { |
|
|
|
|
|
super.initState(); |
|
|
|
|
|
|
|
|
|
|
|
// 初始化动画控制器 |
|
|
|
|
|
_waveAnimationController = AnimationController( |
|
|
|
|
|
vsync: this, |
|
|
|
|
|
duration: const Duration(milliseconds: 1000), |
|
|
|
|
|
)..repeat(); |
|
|
|
|
|
|
|
|
|
|
|
// 初始化录音控制器 |
|
|
|
|
|
_recorderController = RecorderController() |
|
|
|
|
|
..androidEncoder = AndroidEncoder.aac |
|
|
|
|
|
..androidOutputFormat = AndroidOutputFormat.mpeg4 |
|
|
|
|
|
..iosEncoder = IosEncoder.kAudioFormatMPEG4AAC |
|
|
|
|
|
..sampleRate = 44100 |
|
|
|
|
|
..bitRate = 128000 |
|
|
|
|
|
..updateFrequency = const Duration(milliseconds: 50); // 更新频率 |
|
|
|
|
|
|
|
|
|
|
|
// 初始化录音控制器 |
|
|
|
|
|
_initializeRecorder(); |
|
|
|
|
|
|
|
|
|
|
|
// 监听消息列表变化,自动滚动到底部 |
|
|
|
|
|
ever(controller.messages, (_) { |
|
|
|
|
|
// 使用Future.delayed确保在UI更新后滚动 |
|
|
|
|
|
Future.delayed(Duration(milliseconds: 50), () { |
|
|
|
|
|
if (_scrollController.hasClients && |
|
|
|
|
|
_scrollController.position.maxScrollExtent > 0 && |
|
|
|
|
|
(_scrollController.position.maxScrollExtent - _scrollController.offset) < 200) { |
|
|
|
|
|
_scrollController.animateTo( |
|
|
|
|
|
_scrollController.position.maxScrollExtent, |
|
|
|
|
|
duration: Duration(milliseconds: 100), |
|
|
|
|
|
curve: Curves.easeOut, |
|
|
|
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// 监听语音识别状态变化 |
|
|
// 构建AppBar |
|
|
ever(controller.isVoiceRecognitionActive, (isActive) { |
|
|
PreferredSizeWidget _buildAppBar() { |
|
|
if (isActive) { |
|
|
return AppBar( |
|
|
// 开始录音以获取波形数据 |
|
|
elevation: 1, |
|
|
_recorderController.record(); |
|
|
backgroundColor: Colors.white, |
|
|
} else { |
|
|
|
|
|
// 停止录音 |
|
|
|
|
|
_recorderController.stop(); |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 初始化录音控制器 |
|
|
|
|
|
Future<void> _initializeRecorder() async { |
|
|
|
|
|
try { |
|
|
|
|
|
// 请求录音权限 |
|
|
|
|
|
await _recorderController.checkPermission(); |
|
|
|
|
|
|
|
|
|
|
|
// 如果当前正在进行语音识别,开始录音 |
|
|
|
|
|
if (controller.isVoiceRecognitionActive.value) { |
|
|
|
|
|
await _recorderController.record(); |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
print('初始化录音控制器失败: $e'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@override |
|
|
|
|
|
void dispose() { |
|
|
|
|
|
_scrollController.dispose(); |
|
|
|
|
|
_waveAnimationController.dispose(); |
|
|
|
|
|
_recorderController.dispose(); |
|
|
|
|
|
super.dispose(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@override |
|
|
|
|
|
Widget build(BuildContext context) { |
|
|
|
|
|
return Scaffold( |
|
|
|
|
|
backgroundColor: Colors.white, // 设置Scaffold背景为白色 |
|
|
|
|
|
appBar: AppBar( |
|
|
|
|
|
leading: IconButton( |
|
|
leading: IconButton( |
|
|
icon: Icon(Icons.arrow_back_ios, color: Colors.black), |
|
|
icon: Icon(Icons.arrow_back_ios, color: Colors.black87, size: 20.r), |
|
|
onPressed: () => Get.back(), |
|
|
onPressed: () => Get.back(), |
|
|
), |
|
|
), |
|
|
|
|
|
titleSpacing: 0, |
|
|
title: Row( |
|
|
title: Row( |
|
|
children: [ |
|
|
children: [ |
|
|
CircleAvatar( |
|
|
// AI头像 |
|
|
backgroundImage: AssetImage(controller.agent.avatarUrl), |
|
|
Container( |
|
|
radius: 18.r, |
|
|
width: 40.r, |
|
|
|
|
|
height: 40.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: AppColors.primary, |
|
|
|
|
|
borderRadius: BorderRadius.circular(20.r), |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.1), |
|
|
|
|
|
blurRadius: 3, |
|
|
|
|
|
offset: Offset(0, 1), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: ClipRRect( |
|
|
|
|
|
borderRadius: BorderRadius.circular(20.r), |
|
|
|
|
|
child: controller.agent.avatarUrl.isNotEmpty |
|
|
|
|
|
? Image.asset( |
|
|
|
|
|
controller.agent.avatarUrl, |
|
|
|
|
|
fit: BoxFit.cover, |
|
|
|
|
|
errorBuilder: (context, error, stackTrace) { |
|
|
|
|
|
return Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
size: 22.r, |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
) |
|
|
|
|
|
: Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
size: 22.r, |
|
|
), |
|
|
), |
|
|
SizedBox(width: 10.w), |
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(width: 12.w), |
|
|
|
|
|
// AI名称和状态 |
|
|
Expanded( |
|
|
Expanded( |
|
|
child: Column( |
|
|
child: Column( |
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
@ -127,19 +80,28 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
style: TextStyle( |
|
|
style: TextStyle( |
|
|
fontSize: 16.sp, |
|
|
fontSize: 16.sp, |
|
|
fontWeight: FontWeight.bold, |
|
|
fontWeight: FontWeight.bold, |
|
|
color: Colors.black, |
|
|
color: Colors.black87, |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
Row( |
|
|
|
|
|
children: [ |
|
|
|
|
|
Container( |
|
|
|
|
|
width: 6.r, |
|
|
|
|
|
height: 6.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.green, |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
), |
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(width: 4.w), |
|
|
Text( |
|
|
Text( |
|
|
controller.agent.description.length > 20 |
|
|
'在线', |
|
|
? '${controller.agent.description.substring(0, 20)}...' |
|
|
|
|
|
: controller.agent.description, |
|
|
|
|
|
style: TextStyle( |
|
|
style: TextStyle( |
|
|
fontSize: 12.sp, |
|
|
fontSize: 12.sp, |
|
|
color: Colors.grey, |
|
|
color: Colors.green, |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
maxLines: 1, |
|
|
], |
|
|
overflow: TextOverflow.ellipsis, |
|
|
|
|
|
), |
|
|
), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
@ -160,8 +122,26 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
onPressed: controller.toggleTts, |
|
|
onPressed: controller.toggleTts, |
|
|
)), |
|
|
)), |
|
|
// 更多选项 |
|
|
// 更多选项 |
|
|
PopupMenuButton( |
|
|
Container( |
|
|
icon: Icon(Icons.more_vert, color: Colors.black), |
|
|
width: 34.r, |
|
|
|
|
|
height: 34.r, |
|
|
|
|
|
margin: EdgeInsets.only(right: 8.w), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[100], |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 2, |
|
|
|
|
|
spreadRadius: 0.5, |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: PopupMenuButton( |
|
|
|
|
|
icon: Icon(Icons.more_vert, color: Colors.black87, size: 20.r), |
|
|
|
|
|
shape: RoundedRectangleBorder( |
|
|
|
|
|
borderRadius: BorderRadius.circular(12.r), |
|
|
|
|
|
), |
|
|
itemBuilder: (context) => [ |
|
|
itemBuilder: (context) => [ |
|
|
PopupMenuItem( |
|
|
PopupMenuItem( |
|
|
value: 'clear', |
|
|
value: 'clear', |
|
|
@ -180,17 +160,97 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
} |
|
|
} |
|
|
}, |
|
|
}, |
|
|
), |
|
|
), |
|
|
], |
|
|
|
|
|
elevation: 0, |
|
|
|
|
|
backgroundColor: Colors.white, |
|
|
|
|
|
), |
|
|
), |
|
|
body: Container( |
|
|
], |
|
|
color: Colors.white, // 确保body背景也是白色 |
|
|
); |
|
|
child: Column( |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 构建主体内容 |
|
|
|
|
|
Widget _buildBody() { |
|
|
|
|
|
return Column( |
|
|
children: [ |
|
|
children: [ |
|
|
// 消息列表 |
|
|
// 消息列表 |
|
|
Expanded( |
|
|
Expanded( |
|
|
child: Obx(() => ListView.builder( |
|
|
child: Container( |
|
|
|
|
|
color: Colors.grey[50], // 使用浅灰背景 |
|
|
|
|
|
child: Obx(() => controller.messages.isEmpty |
|
|
|
|
|
? _buildEmptyChat() |
|
|
|
|
|
: _buildChatList()), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
|
|
|
// 输入区域 - 根据当前模式显示不同的输入面板 |
|
|
|
|
|
Obx(() => controller.isVoiceRecognitionActive.value |
|
|
|
|
|
? _buildVoiceInputPanel() |
|
|
|
|
|
: _buildTextInputPanel()), |
|
|
|
|
|
], |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 构建空聊天界面 |
|
|
|
|
|
Widget _buildEmptyChat() { |
|
|
|
|
|
return Center( |
|
|
|
|
|
child: Column( |
|
|
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
|
|
children: [ |
|
|
|
|
|
Container( |
|
|
|
|
|
width: 80.r, |
|
|
|
|
|
height: 80.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: AppColors.primary.withOpacity(0.1), |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
), |
|
|
|
|
|
child: ClipRRect( |
|
|
|
|
|
borderRadius: BorderRadius.circular(40.r), |
|
|
|
|
|
child: controller.agent.avatarUrl.isNotEmpty |
|
|
|
|
|
? Image.asset( |
|
|
|
|
|
controller.agent.avatarUrl, |
|
|
|
|
|
fit: BoxFit.cover, |
|
|
|
|
|
errorBuilder: (context, error, stackTrace) { |
|
|
|
|
|
return Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
size: 40.r, |
|
|
|
|
|
color: AppColors.primary, |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
) |
|
|
|
|
|
: Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
size: 40.r, |
|
|
|
|
|
color: AppColors.primary, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(height: 24.h), |
|
|
|
|
|
Text( |
|
|
|
|
|
'你好!我是${controller.agent.name}助手', |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 18.sp, |
|
|
|
|
|
fontWeight: FontWeight.bold, |
|
|
|
|
|
color: Colors.black87, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(height: 12.h), |
|
|
|
|
|
Padding( |
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 40.w), |
|
|
|
|
|
child: Text( |
|
|
|
|
|
'我能帮你回答问题、实时语音翻译、推荐音乐和播客等。有什么我可以帮你的吗?', |
|
|
|
|
|
textAlign: TextAlign.center, |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 14.sp, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
height: 1.5, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 构建聊天列表 |
|
|
|
|
|
Widget _buildChatList() { |
|
|
|
|
|
return ListView.builder( |
|
|
controller: controller.scrollController, |
|
|
controller: controller.scrollController, |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|
|
itemCount: controller.messages.length, |
|
|
itemCount: controller.messages.length, |
|
|
@ -199,76 +259,237 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
cacheExtent: 1000, // 增加缓存范围,减少重建 |
|
|
cacheExtent: 1000, // 增加缓存范围,减少重建 |
|
|
itemBuilder: (context, index) { |
|
|
itemBuilder: (context, index) { |
|
|
final message = controller.messages[index]; |
|
|
final message = controller.messages[index]; |
|
|
return MessageBubble( |
|
|
return _buildMessageItem(message); |
|
|
key: ObjectKey(message), // 使用ObjectKey而不是ValueKey |
|
|
}, |
|
|
message: message, |
|
|
); |
|
|
isUser: message.role == 'user', |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 构建消息项 |
|
|
|
|
|
Widget _buildMessageItem(Message message) { |
|
|
|
|
|
final bool isUser = message.role == 'user'; |
|
|
|
|
|
|
|
|
|
|
|
return Padding( |
|
|
|
|
|
padding: EdgeInsets.only(bottom: 16.h), |
|
|
|
|
|
child: Row( |
|
|
|
|
|
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// AI头像(非用户消息时显示) |
|
|
|
|
|
if (!isUser) |
|
|
|
|
|
Container( |
|
|
|
|
|
width: 36.r, |
|
|
|
|
|
height: 36.r, |
|
|
|
|
|
margin: EdgeInsets.only(right: 8.w, top: 2.h), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: AppColors.primary, |
|
|
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.1), |
|
|
|
|
|
blurRadius: 2, |
|
|
|
|
|
offset: Offset(0, 1), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: ClipRRect( |
|
|
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
|
|
child: controller.agent.avatarUrl.isNotEmpty |
|
|
|
|
|
? Image.asset( |
|
|
|
|
|
controller.agent.avatarUrl, |
|
|
|
|
|
fit: BoxFit.cover, |
|
|
|
|
|
errorBuilder: (context, error, stackTrace) { |
|
|
|
|
|
return Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
size: 20.r, |
|
|
); |
|
|
); |
|
|
}, |
|
|
}, |
|
|
)), |
|
|
) |
|
|
|
|
|
: Icon( |
|
|
|
|
|
Icons.smart_toy_rounded, |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
size: 20.r, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
|
|
|
// 输入区域 - 根据当前模式显示不同的输入面板 |
|
|
// 消息气泡 |
|
|
Obx(() => controller.isVoiceRecognitionActive.value |
|
|
Flexible( |
|
|
? _buildVoiceInputPanel() |
|
|
child: Container( |
|
|
: _buildTextInputPanel()), |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: isUser ? AppColors.primary : Colors.white, |
|
|
|
|
|
borderRadius: BorderRadius.only( |
|
|
|
|
|
topLeft: Radius.circular(isUser ? 16.r : 0), |
|
|
|
|
|
topRight: Radius.circular(isUser ? 0 : 16.r), |
|
|
|
|
|
bottomLeft: Radius.circular(16.r), |
|
|
|
|
|
bottomRight: Radius.circular(16.r), |
|
|
|
|
|
), |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 3, |
|
|
|
|
|
spreadRadius: 0.5, |
|
|
|
|
|
offset: Offset(0, 1), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: Column( |
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
|
|
|
children: [ |
|
|
|
|
|
// 消息内容 |
|
|
|
|
|
if (message.content.isNotEmpty) |
|
|
|
|
|
SelectableText( |
|
|
|
|
|
message.content, |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
color: isUser ? Colors.white : Colors.black87, |
|
|
|
|
|
fontSize: 15.sp, |
|
|
|
|
|
height: 1.5, |
|
|
|
|
|
letterSpacing: 0.2, |
|
|
|
|
|
), |
|
|
|
|
|
textAlign: TextAlign.left, |
|
|
|
|
|
maxLines: null, |
|
|
|
|
|
enableInteractiveSelection: true, |
|
|
|
|
|
textWidthBasis: TextWidthBasis.longestLine, |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
|
|
|
// 加载指示器 |
|
|
|
|
|
if (message.isLoading) |
|
|
|
|
|
Padding( |
|
|
|
|
|
padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), |
|
|
|
|
|
child: Row( |
|
|
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
|
|
children: [ |
|
|
|
|
|
SizedBox( |
|
|
|
|
|
width: 12.w, |
|
|
|
|
|
height: 12.h, |
|
|
|
|
|
child: CircularProgressIndicator( |
|
|
|
|
|
strokeWidth: 2, |
|
|
|
|
|
valueColor: AlwaysStoppedAnimation<Color>( |
|
|
|
|
|
isUser ? Colors.white : AppColors.primary, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(width: 8.w), |
|
|
|
|
|
Text( |
|
|
|
|
|
'思考中...', |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
color: isUser ? Colors.white : Colors.black54, |
|
|
|
|
|
fontSize: 12.sp, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
|
|
|
// 用户头像(用户消息时显示) |
|
|
|
|
|
if (isUser) |
|
|
|
|
|
Container( |
|
|
|
|
|
width: 36.r, |
|
|
|
|
|
height: 36.r, |
|
|
|
|
|
margin: EdgeInsets.only(left: 8.w, top: 2.h), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[200], |
|
|
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 2, |
|
|
|
|
|
offset: Offset(0, 1), |
|
|
|
|
|
), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: Icon( |
|
|
|
|
|
Icons.person, |
|
|
|
|
|
color: Colors.grey[600], |
|
|
|
|
|
size: 20.r, |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
), |
|
|
), |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 构建文本输入面板 |
|
|
// 构建文本输入面板 |
|
|
Widget _buildTextInputPanel() { |
|
|
Widget _buildTextInputPanel() { |
|
|
// 创建一个TextEditingController来控制输入框 |
|
|
final textController = TextEditingController(text: controller.inputText.value); |
|
|
final TextEditingController textController = TextEditingController(text: controller.inputText.value); |
|
|
|
|
|
|
|
|
|
|
|
// 监听输入变化 |
|
|
|
|
|
textController.addListener(() { |
|
|
textController.addListener(() { |
|
|
controller.inputText.value = textController.text; |
|
|
controller.inputText.value = textController.text; |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
return Container( |
|
|
return Container( |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|
|
decoration: BoxDecoration( |
|
|
decoration: BoxDecoration( |
|
|
color: Colors.white, |
|
|
color: Colors.white, |
|
|
boxShadow: [ |
|
|
boxShadow: [ |
|
|
BoxShadow( |
|
|
BoxShadow( |
|
|
color: Colors.black.withOpacity(0.05), |
|
|
color: Colors.black.withOpacity(0.06), |
|
|
blurRadius: 5, |
|
|
blurRadius: 10, |
|
|
offset: Offset(0, -1), |
|
|
offset: Offset(0, -1), |
|
|
), |
|
|
), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
|
|
|
child: SafeArea( |
|
|
child: Row( |
|
|
child: Row( |
|
|
children: [ |
|
|
children: [ |
|
|
// 语音输入按钮 |
|
|
// 语音输入按钮 |
|
|
IconButton( |
|
|
Container( |
|
|
|
|
|
width: 40.r, |
|
|
|
|
|
height: 40.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[100], |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 3, |
|
|
|
|
|
spreadRadius: 0.5, |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: IconButton( |
|
|
icon: Icon( |
|
|
icon: Icon( |
|
|
Icons.mic_none, |
|
|
Icons.mic_none, |
|
|
color: Colors.grey, |
|
|
color: Colors.grey[600], |
|
|
|
|
|
size: 20.r, |
|
|
), |
|
|
), |
|
|
|
|
|
padding: EdgeInsets.zero, |
|
|
onPressed: controller.startVoiceRecognition, |
|
|
onPressed: controller.startVoiceRecognition, |
|
|
), |
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
// 文本输入框 |
|
|
// 文本输入框 |
|
|
Expanded( |
|
|
Expanded( |
|
|
|
|
|
child: Container( |
|
|
|
|
|
margin: EdgeInsets.symmetric(horizontal: 8.w), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey[100], |
|
|
|
|
|
borderRadius: BorderRadius.circular(20.r), |
|
|
|
|
|
), |
|
|
child: TextField( |
|
|
child: TextField( |
|
|
controller: textController, |
|
|
controller: textController, |
|
|
decoration: InputDecoration( |
|
|
decoration: InputDecoration( |
|
|
hintText: '发消息...', |
|
|
hintText: '输入消息...', |
|
|
border: OutlineInputBorder( |
|
|
hintStyle: TextStyle(color: Colors.grey[500]), |
|
|
borderRadius: BorderRadius.circular(20.r), |
|
|
border: InputBorder.none, |
|
|
borderSide: BorderSide.none, |
|
|
|
|
|
), |
|
|
|
|
|
filled: true, |
|
|
|
|
|
fillColor: Colors.grey[200], |
|
|
|
|
|
contentPadding: EdgeInsets.symmetric( |
|
|
contentPadding: EdgeInsets.symmetric( |
|
|
horizontal: 16.w, |
|
|
horizontal: 16.w, |
|
|
vertical: 8.h, |
|
|
vertical: 10.h, |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
maxLines: null, |
|
|
maxLines: null, |
|
|
textInputAction: TextInputAction.send, |
|
|
textInputAction: TextInputAction.send, |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 15.sp, |
|
|
|
|
|
color: Colors.black87, |
|
|
|
|
|
), |
|
|
onSubmitted: (value) { |
|
|
onSubmitted: (value) { |
|
|
if (value.trim().isNotEmpty) { |
|
|
if (value.trim().isNotEmpty) { |
|
|
controller.sendMessage(value); |
|
|
controller.sendMessage(value); |
|
|
@ -277,55 +498,109 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
}, |
|
|
}, |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
// 发送按钮 |
|
|
// 发送按钮 |
|
|
Obx(() => IconButton( |
|
|
Obx(() => Container( |
|
|
|
|
|
width: 40.r, |
|
|
|
|
|
height: 40.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: controller.inputText.value.trim().isEmpty |
|
|
|
|
|
? Colors.grey[100] |
|
|
|
|
|
: AppColors.primary, |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
boxShadow: controller.inputText.value.trim().isEmpty |
|
|
|
|
|
? [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 3, |
|
|
|
|
|
spreadRadius: 0.5, |
|
|
|
|
|
), |
|
|
|
|
|
] |
|
|
|
|
|
: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: AppColors.primary.withOpacity(0.3), |
|
|
|
|
|
blurRadius: 5, |
|
|
|
|
|
spreadRadius: 0.5, |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: IconButton( |
|
|
icon: Icon( |
|
|
icon: Icon( |
|
|
Icons.send, |
|
|
Icons.send, |
|
|
|
|
|
size: 18.r, |
|
|
color: controller.inputText.value.trim().isEmpty |
|
|
color: controller.inputText.value.trim().isEmpty |
|
|
? Colors.grey |
|
|
? Colors.grey[500] |
|
|
: AppColors.primary, |
|
|
: Colors.white, |
|
|
), |
|
|
), |
|
|
|
|
|
padding: EdgeInsets.zero, |
|
|
onPressed: controller.inputText.value.trim().isEmpty |
|
|
onPressed: controller.inputText.value.trim().isEmpty |
|
|
? null |
|
|
? null |
|
|
: () { |
|
|
: () { |
|
|
controller.sendMessage(controller.inputText.value); |
|
|
controller.sendMessage(controller.inputText.value); |
|
|
textController.clear(); |
|
|
textController.clear(); |
|
|
}, |
|
|
}, |
|
|
|
|
|
), |
|
|
)), |
|
|
)), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
|
|
|
), |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 构建语音输入面板 |
|
|
// 构建语音输入面板 |
|
|
Widget _buildVoiceInputPanel() { |
|
|
Widget _buildVoiceInputPanel() { |
|
|
final screenWidth = Get.width; |
|
|
|
|
|
|
|
|
|
|
|
return Container( |
|
|
return Container( |
|
|
width: screenWidth, |
|
|
decoration: BoxDecoration( |
|
|
decoration: const BoxDecoration( |
|
|
|
|
|
gradient: LinearGradient( |
|
|
gradient: LinearGradient( |
|
|
begin: Alignment.topCenter, |
|
|
begin: Alignment.topCenter, |
|
|
end: Alignment.bottomCenter, |
|
|
end: Alignment.bottomCenter, |
|
|
colors: [ |
|
|
colors: [ |
|
|
Color(0xFFFFFFFF), // 纯白色开始 |
|
|
Colors.white, |
|
|
Color(0xFFE8D5F5), // 更深的紫色结束 |
|
|
Color(0xFFE8D5F5), |
|
|
], |
|
|
], |
|
|
stops: [0.3, 1.0], // 调整颜色分布,让白色占据更多空间 |
|
|
stops: [0.3, 1.0], |
|
|
), |
|
|
), |
|
|
|
|
|
// boxShadow: [ |
|
|
|
|
|
// BoxShadow( |
|
|
|
|
|
// color: Colors.black.withOpacity(0.06), |
|
|
|
|
|
// blurRadius: 10, |
|
|
|
|
|
// offset: Offset(0, -1), |
|
|
|
|
|
// ), |
|
|
|
|
|
// ], |
|
|
), |
|
|
), |
|
|
child: SafeArea( |
|
|
child: SafeArea( |
|
|
child: SizedBox( |
|
|
child: SizedBox( |
|
|
height: 100.h, |
|
|
height: 80.h, |
|
|
child: Center( |
|
|
|
|
|
child: Padding( |
|
|
child: Padding( |
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|
|
padding: EdgeInsets.symmetric(horizontal: 30.w), |
|
|
child: Row( |
|
|
child: Row( |
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
|
crossAxisAlignment: CrossAxisAlignment.center, |
|
|
crossAxisAlignment: CrossAxisAlignment.center, |
|
|
children: [ |
|
|
children: [ |
|
|
// 左侧麦克风按钮 |
|
|
// 语音录制按钮 |
|
|
_buildMicrophoneButton(), |
|
|
Container( |
|
|
|
|
|
width: 60.r, |
|
|
|
|
|
height: 60.r, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
shape: BoxShape.circle, |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.1), |
|
|
|
|
|
blurRadius: 8, |
|
|
|
|
|
spreadRadius: 1, |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: Obx(() => Icon( |
|
|
|
|
|
controller.isListening.value ? Icons.mic : Icons.mic_none, |
|
|
|
|
|
color: controller.isListening.value ? AppColors.primary : Colors.grey.shade400, |
|
|
|
|
|
size: 28.r, |
|
|
|
|
|
)), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
|
|
|
// 中间音频波形和状态文本 |
|
|
// 中间音频波形和状态文本 |
|
|
Expanded( |
|
|
Expanded( |
|
|
@ -333,75 +608,76 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
mainAxisSize: MainAxisSize.min, |
|
|
mainAxisSize: MainAxisSize.min, |
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
children: [ |
|
|
children: [ |
|
|
// 音频波形 |
|
|
// 音频波形区域 |
|
|
_buildAudioWaveform(), |
|
|
Container( |
|
|
SizedBox(height: 10.h), |
|
|
height: 50.h, |
|
|
// 状态文本 |
|
|
child: Obx(() { |
|
|
Obx(() => Text( |
|
|
final isSpeaking = controller.isSpeechDetected.value; |
|
|
controller.isSpeechDetected.value ? '正在聆听...' : '你可以开始说话', |
|
|
return Center( |
|
|
style: TextStyle( |
|
|
child: Container( |
|
|
fontSize: 16.sp, |
|
|
width: 220.w, |
|
|
color: Colors.grey.shade700, |
|
|
height: 30.h, |
|
|
fontWeight: FontWeight.w500, |
|
|
child: Row( |
|
|
), |
|
|
mainAxisSize: MainAxisSize.min, |
|
|
)), |
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
], |
|
|
children: List.generate( |
|
|
), |
|
|
23, |
|
|
), |
|
|
(index) { |
|
|
|
|
|
// 随机高度模拟波形 |
|
|
|
|
|
double height = isSpeaking |
|
|
|
|
|
? (Random().nextDouble() * 20.0 + 5.0).h |
|
|
|
|
|
: 2.h; |
|
|
|
|
|
|
|
|
// 右侧关闭按钮 |
|
|
return SizedBox( |
|
|
_buildCloseButton(), |
|
|
width: 6.w, |
|
|
], |
|
|
child: Center( |
|
|
|
|
|
child: AnimatedContainer( |
|
|
|
|
|
duration: Duration(milliseconds: 500), |
|
|
|
|
|
width: 2.5.w, |
|
|
|
|
|
height: height, |
|
|
|
|
|
margin: EdgeInsets.symmetric(horizontal: 1.5.w), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: isSpeaking |
|
|
|
|
|
? AppColors.primary |
|
|
|
|
|
: Colors.grey.shade400, |
|
|
|
|
|
borderRadius: BorderRadius.circular(2.r), |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
); |
|
|
); |
|
|
} |
|
|
}), |
|
|
|
|
|
), |
|
|
|
|
|
SizedBox(height: 8.h), |
|
|
|
|
|
|
|
|
// 麦克风按钮 |
|
|
// 语音状态文本 |
|
|
Widget _buildMicrophoneButton() { |
|
|
Obx(() => Text( |
|
|
return Container( |
|
|
controller.isSpeechDetected.value ? '正在聆听...' : '你可以开始说话', |
|
|
width: 60.w, |
|
|
style: TextStyle( |
|
|
height: 60.w, |
|
|
fontSize: 14.sp, |
|
|
decoration: BoxDecoration( |
|
|
color: Colors.grey.shade700, |
|
|
color: Colors.white, |
|
|
fontWeight: FontWeight.w500, |
|
|
shape: BoxShape.circle, |
|
|
|
|
|
boxShadow: [ |
|
|
|
|
|
BoxShadow( |
|
|
|
|
|
color: Colors.black.withOpacity(0.05), |
|
|
|
|
|
blurRadius: 10, |
|
|
|
|
|
spreadRadius: 1, |
|
|
|
|
|
), |
|
|
), |
|
|
|
|
|
)), |
|
|
], |
|
|
], |
|
|
), |
|
|
), |
|
|
child: Center( |
|
|
|
|
|
child: Obx(() => IconButton( |
|
|
|
|
|
icon: Icon( |
|
|
|
|
|
controller.isListening.value ? Icons.mic : Icons.mic_none, |
|
|
|
|
|
color: controller.isListening.value ? AppColors.primary : Colors.grey.shade400, |
|
|
|
|
|
size: 28.sp, |
|
|
|
|
|
), |
|
|
|
|
|
onPressed: controller.stopVoiceRecognition, |
|
|
|
|
|
padding: EdgeInsets.zero, |
|
|
|
|
|
)), |
|
|
|
|
|
), |
|
|
), |
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 关闭按钮 |
|
|
// 关闭按钮 |
|
|
Widget _buildCloseButton() { |
|
|
Container( |
|
|
return Container( |
|
|
width: 60.r, |
|
|
width: 60.w, |
|
|
height: 60.r, |
|
|
height: 60.w, |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
decoration: BoxDecoration( |
|
|
color: Colors.white, |
|
|
color: Colors.white, |
|
|
shape: BoxShape.circle, |
|
|
shape: BoxShape.circle, |
|
|
boxShadow: [ |
|
|
boxShadow: [ |
|
|
BoxShadow( |
|
|
BoxShadow( |
|
|
color: Colors.black.withOpacity(0.05), |
|
|
color: Colors.black.withOpacity(0.1), |
|
|
blurRadius: 10, |
|
|
blurRadius: 8, |
|
|
spreadRadius: 1, |
|
|
spreadRadius: 1, |
|
|
), |
|
|
), |
|
|
], |
|
|
], |
|
|
@ -411,84 +687,17 @@ class _ChatViewState extends State<ChatView> with TickerProviderStateMixin { |
|
|
icon: Icon( |
|
|
icon: Icon( |
|
|
Icons.close, |
|
|
Icons.close, |
|
|
color: Colors.grey.shade600, |
|
|
color: Colors.grey.shade600, |
|
|
size: 28.sp, |
|
|
size: 28.r, |
|
|
), |
|
|
), |
|
|
onPressed: controller.stopVoiceRecognition, |
|
|
|
|
|
padding: EdgeInsets.zero, |
|
|
padding: EdgeInsets.zero, |
|
|
|
|
|
onPressed: controller.stopVoiceRecognition, |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 音频波形 |
|
|
|
|
|
Widget _buildAudioWaveform() { |
|
|
|
|
|
return SizedBox( |
|
|
|
|
|
width: 220.w, |
|
|
|
|
|
height: 50.h, |
|
|
|
|
|
child: Obx(() { |
|
|
|
|
|
final isSpeaking = controller.isSpeechDetected.value; |
|
|
|
|
|
final isRec = controller.isListening.value; |
|
|
|
|
|
|
|
|
|
|
|
if (!isRec) { |
|
|
|
|
|
// 如果没有在录音,显示静态波形 |
|
|
|
|
|
return _buildStaticWaveform(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 使用AudioWaveforms组件显示实时波形 |
|
|
|
|
|
return AudioWaveforms( |
|
|
|
|
|
enableGesture: false, |
|
|
|
|
|
size: Size(220.w, 50.h), |
|
|
|
|
|
recorderController: _recorderController, |
|
|
|
|
|
waveStyle: WaveStyle( |
|
|
|
|
|
waveColor: isSpeaking |
|
|
|
|
|
? AppColors.primary |
|
|
|
|
|
: AppColors.primary.withOpacity(0.3), |
|
|
|
|
|
extendWaveform: true, |
|
|
|
|
|
showMiddleLine: false, |
|
|
|
|
|
spacing: 4.0, |
|
|
|
|
|
waveThickness: 3.0, |
|
|
|
|
|
showDurationLabel: false, |
|
|
|
|
|
scaleFactor: 80, // 调整波形高度 |
|
|
|
|
|
), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
borderRadius: BorderRadius.circular(12.r), |
|
|
|
|
|
color: Colors.transparent, |
|
|
|
|
|
), |
|
|
), |
|
|
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|
|
], |
|
|
margin: EdgeInsets.only(bottom: 5.h), |
|
|
|
|
|
); |
|
|
|
|
|
}), |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 静态波形(当没有录音时显示) |
|
|
|
|
|
Widget _buildStaticWaveform() { |
|
|
|
|
|
final barCount = 23; // 条形数量 |
|
|
|
|
|
|
|
|
|
|
|
return Row( |
|
|
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center, |
|
|
|
|
|
children: List.generate( |
|
|
|
|
|
barCount, |
|
|
|
|
|
(index) { |
|
|
|
|
|
// 静态高度 |
|
|
|
|
|
final height = 2.h; |
|
|
|
|
|
|
|
|
|
|
|
return SizedBox( |
|
|
|
|
|
width: 5.5.w, |
|
|
|
|
|
child: Center( |
|
|
|
|
|
child: Container( |
|
|
|
|
|
width: 2.5.w, |
|
|
|
|
|
height: height, |
|
|
|
|
|
margin: EdgeInsets.symmetric(horizontal: 1.2.w), |
|
|
|
|
|
decoration: BoxDecoration( |
|
|
|
|
|
color: Colors.grey.shade400, |
|
|
|
|
|
borderRadius: BorderRadius.circular(2.r), |
|
|
|
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
), |
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
), |
|
|
), |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|