diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f1195f0fc..75a82d84d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,8 @@ 193d0a2b1757c41cfa557ea8e8f21c33 LSApplicationQueriesSchemes + App-Prefs + prefs weixin weixinULAPI weixinURLParamsAPI @@ -127,12 +129,12 @@ LaunchScreen UIMainStoryboardFile Main - UISupportedInterfaceOrientations~ipad + UISupportedInterfaceOrientations - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 8c98da39d..0c61157c0 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -8,6 +8,7 @@ import '../../../data/services/navigation_manager.dart'; import 'package:get/get.dart'; import '../../data/services/meeting/meeting_task_service.dart'; import '../../data/services/meeting/meeting_upload_service.dart'; +import '../../data/services/novice_guide_service.dart'; import '../../data/services/speech_factory.dart'; import '../../data/services/volcano_translation_service.dart'; import '../../data/services/volcano_ai_service.dart'; @@ -68,6 +69,9 @@ class InitialBinding extends Bindings { // qq音乐服务 Get.lazyPut(() => QqMusocService(), fenix: true); + // 新手引导 + Get.lazyPut(() => NoviceGuideService(), fenix: true); + // 杰理蓝牙服务 // // 语言管理器(需要最先初始化) diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index f529e2a7a..51d9bd764 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -813,6 +813,19 @@ const Map enUS = { 'deviceAuthorizationFailedCheckCode': 'Device authorization failed, please check authorization code', + ///iOS蓝牙引导窗 + 'bluetoothGuideTitle': 'Manual Bluetooth Activation Required', // 需要手动开启蓝牙 + 'bluetoothGuideSteps': 'Please follow these steps:', // 请按照以下步骤操作: + 'bluetoothGuideStep1': + 'Enable Bluetooth: Settings → Bluetooth → Turn On', // 开启蓝牙:设置 → 蓝牙 → 开启 + 'bluetoothGuideStep2': + 'Connect Device: Find your device in "My Devices" and tap to connect', // 连接设备:在"我的设备"中找到您的设备并点击连接 + 'bluetoothGuideStep3': + 'Return to app to continue pairing process', // 返回应用继续配对流程 + 'bluetoothGuideTip': + 'Tip: You can also long press the Bluetooth icon in Control Center to quickly access Bluetooth settings', // 提示:也可以从控制中心长按蓝牙图标快速进入蓝牙设置 + 'iKnow': 'I Know', // 我知道了 + // 用户反馈页 "feedback": "Feedback", 'feedbackInfo': 'Provide feedback or suggestions', diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 52a3cc093..6bd1701d6 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -746,6 +746,15 @@ const Map zhCN = { 'bleConnectionFailed': 'BLE连接失败', 'deviceAuthorizationFailedCheckCode': '设备授权失败,请检查授权码', + ///iOS蓝牙引导窗 + 'bluetoothGuideTitle': '需要手动开启蓝牙', // 需要手动开启蓝牙 + 'bluetoothGuideSteps': '请按照以下步骤操作:', // 请按照以下步骤操作: + 'bluetoothGuideStep1': '开启蓝牙:设置 → 蓝牙 → 开启', // 开启蓝牙:设置 → 蓝牙 → 开启 + 'bluetoothGuideStep2': '连接设备:在"我的设备"中找到您的设备并点击连接', // 连接设备:在"我的设备"中找到您的设备并点击连接 + 'bluetoothGuideStep3': '返回应用继续配对流程', // 返回应用继续配对流程 + 'bluetoothGuideTip': '提示:也可以从控制中心长按蓝牙图标快速进入蓝牙设置', // 提示:也可以从控制中心长按蓝牙图标快速进入蓝牙设置 + 'iKnow': '我知道了', // 我知道了 + //用户反馈页 "feedback": "用户反馈", 'feedbackInfo': '提供反馈或建议', diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart new file mode 100644 index 000000000..aeb3baa39 --- /dev/null +++ b/lib/data/services/novice_guide_service.dart @@ -0,0 +1,34 @@ +import 'package:flutter_intro/flutter_intro.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +class NoviceGuideService extends GetxService { + final GetStorage _storage = GetStorage(); + + bool isHomeGuide = true; + bool isAIGuide = true; + + @override + void onInit() { + super.onInit(); + // isHomeGuide = _storage.read('home_guide') ?? false; + // isAIGuide = _storage.read('ai_guide') ?? false; + } + + void startHomeGuide() { + if (!isHomeGuide) { + isHomeGuide = true; + _storage.write('home_guide', true); + Intro.of(Get.context!).start(group: 'home'); + } + } + + void startAIGuide() async { + if (!isAIGuide) { + await Future.delayed(const Duration(milliseconds: 200)); + isAIGuide = true; + _storage.write('ai_guide', true); + Intro.of(Get.context!).start(group: 'ai'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 7c5818e36..fd95d2863 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_intro/flutter_intro.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -19,6 +21,8 @@ void main() async { // 确保Flutter绑定初始化 WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + // 初始化存储 - 在创建应用之前初始化 await GetStorage.init(); @@ -92,7 +96,10 @@ class MainApp extends StatelessWidget { data: MediaQuery.of(context).copyWith( textScaler: TextScaler.noScaling, ), - child: widget!, + child: Intro( + padding: EdgeInsets.zero, + child: widget!, + ), ); }, ), diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index dda9ed2c4..5330abd4c 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'package:amap_service/amap_service.dart'; +import '../../../data/services/novice_guide_service.dart'; import '/core/utils/upload_oss.dart'; import '/data/models/user_Info.dart'; import '/data/services/music_manager.dart'; @@ -39,6 +40,8 @@ class AgentController extends GetxController { final TextEditingController textController = TextEditingController(); final ScrollController scrollController = ScrollController(); + final noviceGuideService = Get.find(); + //音乐播放管理 final _musiceManager = Get.find(); @@ -153,8 +156,6 @@ class AgentController extends GetxController { _setupMediaSessionCallbacks(); //设置媒体会话回调 _setupTextInputListener(); //设置文本输入监听器 - - startVoiceInput(); //开启语音输入 } @@ -1013,8 +1014,6 @@ class AgentController extends GetxController { } } - - // 开始语音输入 Future startVoiceInput() async { if (isListening.value) return; @@ -1309,29 +1308,71 @@ class AgentController extends GetxController { return; } - // 切换到语音模式并开始语音输入 + // 打电话模式 isTextInputMode.value = false; - // 打电话模式不使用按住说话模式,而是持续语音输入 - //isPushToTalkMode.value = false; - // 开始语音识别 - Logger.i(TAG, '开始打电话模式,开始语音输入'); + Logger.i(TAG, '打电话模式已启动,开始语音输入'); await AgentService.startConversation(); - //} } // 结束打电话模式 void endPhoneCall() async { Logger.i(TAG, '🔥 结束打电话模式'); - // 退出打电话模式 - isPhoneCallMode.value = false; + try { + // 如果正在录音,先停止录音 + if (isListening.value) { + final result = await AgentService.stopConversation(); - // 停止语音输入 - //await stopVoiceInput(); - // 开始语音识别 - await AgentService.stopConversation(); - // 恢复之前的输入模式,而不是强制切换到文字模式 - _restorePreviousInputMode(); + if (result) { + isListening.value = false; + } + } + + // 退出打电话模式 + isPhoneCallMode.value = false; + _restorePreviousInputMode(); + } catch (e) { + Logger.e(TAG, '结束打电话模式失败: $e'); + + // 即使出现异常,也要确保退出打电话模式 + isPhoneCallMode.value = false; + isListening.value = false; + _restorePreviousInputMode(); + } + } + + // 打电话模式专用:切换录音状态 + Future togglePhoneCallRecording() async { + Logger.i(TAG, '🎤 打电话模式切换录音状态'); + // 只有在打电话模式下才能使用此方法 + if (!isPhoneCallMode.value) { + Logger.w(TAG, '不在打电话模式,无法切换录音状态'); + return; + } + + try { + if (isListening.value) { + // 当前正在录音,停止录音 + Logger.i(TAG, '停止打电话模式录音'); + final result = await AgentService.stopConversation(); + + // 手动更新状态,确保UI同步 + if (result) { + isListening.value = false; + } + } else { + // 当前未录音,开始录音 + Logger.i(TAG, '开始打电话模式录音'); + final result = await AgentService.startConversation(); + + // 手动更新状态,确保UI同步 + if (result) { + isListening.value = true; + } + } + } catch (e) { + Logger.e(TAG, '打电话模式录音状态切换失败: $e'); + } } // 耳机唤醒通话模式(专用入口) diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 3703ec3f4..d989cd037 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import '../controllers/agent_controller.dart'; import '../controllers/message_action_controller.dart'; @@ -388,96 +389,110 @@ class _AgentViewState extends State with WidgetsBindingObserver { if (controller.isTextInputMode.value) { // 文本输入模式 - 限制最大高度并只向上扩展 - return Container( - constraints: BoxConstraints( - minHeight: 44.h, - maxHeight: 120.h, - ), - padding: EdgeInsets.symmetric( - horizontal: 16.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: - BorderRadius.circular(22.w), - border: Border.all( + return IntroStepBuilder( + group: 'ai', + order: 1, + text: '对小言说“今天天气怎么样”', + onWidgetLoad: () { + controller.noviceGuideService + .startAIGuide(); + }, + builder: (BuildContext context, + GlobalKey key) => + Container( + key: key, + constraints: BoxConstraints( + minHeight: 44.h, + maxHeight: 120.h, + ), + padding: EdgeInsets.symmetric( + horizontal: 16.w), + decoration: BoxDecoration( color: isDarkMode - ? Colors.grey[800]! - : Colors.grey.withOpacity(0.1), - width: 1.w, + ? Colors.grey[800] + : Colors.grey[200], + borderRadius: + BorderRadius.circular(22.w), + border: Border.all( + color: isDarkMode + ? Colors.grey[800]! + : Colors.grey.withOpacity(0.1), + width: 1.w, + ), ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment - .end, // 对齐到底部,实现向上扩展 - children: [ - Expanded( - child: Obx(() { - // 图片模式下使用更高的输入框 - final isImageActive = controller - .isImageInputActive.value; + child: Row( + crossAxisAlignment: CrossAxisAlignment + .end, // 对齐到底部,实现向上扩展 + children: [ + Expanded( + child: Obx(() { + // 图片模式下使用更高的输入框 + final isImageActive = controller + .isImageInputActive.value; - return TextField( - controller: - controller.textController, - maxLines: isImageActive - ? 3 - : 5, // 设置合理的最大行数 - minLines: isImageActive ? 2 : 1, - keyboardType: TextInputType - .multiline, // 启用多行键盘 - textInputAction: TextInputAction - .newline, // 支持回车换行 - onTap: () { - // 当点击输入框时,如果'+'选项已展开,则收缩 - if (controller - .isPlusOptionsExpanded - .value) { - controller - .collapsePlusOptions(); - } - }, - style: TextStyle( - fontSize: 15.sp, - color: isDarkMode - ? Colors.white - : Colors.grey[800], - ), - decoration: InputDecoration( - hintText: isImageActive - ? 'addQuestionAboutImage' - .tr // 添加对图片的问题或描述... - : 'inputMessage' - .tr, // 输入消息... - hintStyle: TextStyle( - color: Colors.grey[500], + return TextField( + controller: + controller.textController, + maxLines: isImageActive + ? 3 + : 5, // 设置合理的最大行数 + minLines: + isImageActive ? 2 : 1, + keyboardType: TextInputType + .multiline, // 启用多行键盘 + textInputAction: + TextInputAction + .newline, // 支持回车换行 + onTap: () { + // 当点击输入框时,如果'+'选项已展开,则收缩 + if (controller + .isPlusOptionsExpanded + .value) { + controller + .collapsePlusOptions(); + } + }, + style: TextStyle( fontSize: 15.sp, + color: isDarkMode + ? Colors.white + : Colors.grey[800], ), - filled: false, - border: InputBorder.none, - focusedBorder: - OutlineInputBorder( - borderSide: BorderSide( - color: isDarkMode - ? Colors.grey[800]! - : Colors.grey[200]!, - width: 1.w, + decoration: InputDecoration( + hintText: isImageActive + ? 'addQuestionAboutImage' + .tr // 添加对图片的问题或描述... + : 'inputMessage' + .tr, // 输入消息... + hintStyle: TextStyle( + color: Colors.grey[500], + fontSize: 15.sp, + ), + filled: false, + border: InputBorder.none, + focusedBorder: + OutlineInputBorder( + borderSide: BorderSide( + color: isDarkMode + ? Colors.grey[800]! + : Colors.grey[200]!, + width: 1.w, + ), ), + contentPadding: + EdgeInsets.symmetric( + vertical: + isImageActive + ? 12.h + : 8.h), ), - contentPadding: - EdgeInsets.symmetric( - vertical: - isImageActive - ? 12.h - : 8.h), - ), - onSubmitted: (_) => - controller.sendMessage(), - ); - }), - ), - ], + onSubmitted: (_) => + controller.sendMessage(), + ); + }), + ), + ], + ), ), ); } else { @@ -919,212 +934,60 @@ class _AgentViewState extends State with WidgetsBindingObserver { Widget _buildPhoneCallOverlay(BuildContext context, bool isDarkMode) { return Container( color: Colors.transparent, // 设置整个覆盖层为透明 - child: SafeArea( - child: Column( - children: [ - // 顶部标题栏 - 保持背景 - // Container( - // color: isDarkMode - // ? Colors.black.withOpacity(0.8) - // : Colors.white.withOpacity(0.8), - // child: _buildPhoneCallHeader(isDarkMode), - // ), - - // 中间音频可视化区域 - 完全透明 - const Expanded( - child: SizedBox(), // 空的占位符,完全透明 - ), - - // 底部控制按钮 - 保持背景 - Container( - color: isDarkMode ? Colors.black : Colors.white, - child: _buildPhoneCallControls(isDarkMode), - ), - ], - ), - ), - ); - } - - // 构建打电话模式标题栏 - Widget _buildPhoneCallHeader(bool isDarkMode) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( + child: Column( children: [ - // 返回按钮 - GestureDetector( - onTap: () => controller.endPhoneCall(), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[200], - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - Icons.arrow_back, - color: isDarkMode ? Colors.white : Colors.black, - size: 20, - ), - ), + // 中间空白区域 - 完全透明 + const Expanded( + child: SizedBox(), // 空的占位符,完全透明 ), - const SizedBox(width: 16), - - // 标题 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'aiVoiceCall'.tr, // AI语音通话 - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - Obx(() { - final status = controller.isListening.value - ? 'inCall'.tr // 正在通话中... - : controller.isSpeaking.value - ? 'aiReplying'.tr // AI正在回复... - : 'waitingToStart'.tr; // 等待开始... - return Text( - status, - style: TextStyle( - fontSize: 14, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ); - }), - ], - ), + // 底部控制按钮 - 包含音频波纹 + Container( + color: isDarkMode ? Colors.black : Colors.white, + child: _buildPhoneCallControls(isDarkMode), ), ], ), ); } - // 构建打电话模式音频可视化 - Widget _buildPhoneCallVisualization(bool isDarkMode) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 音频可视化圆圈 - Obx(() { - final isActive = - controller.isListening.value || controller.isSpeaking.value; - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: isActive ? 200 : 160, - height: isActive ? 200 : 160, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDarkMode - ? Colors.blue[900]?.withOpacity(0.3) - : Colors.blue[100], - border: Border.all( - color: Colors.blue, - width: isActive ? 3 : 2, - ), - ), - child: Icon( - Icons.phone, - size: isActive ? 80 : 60, - color: Colors.blue, - ), - ); - }), - - const SizedBox(height: 32), - - // 状态文字 - Obx(() { - String statusText = 'clickToStartCall'.tr; // 点击开始通话 - if (controller.isListening.value) { - statusText = 'listeningToYou'.tr; // 正在听您说话... - } else if (controller.isSpeaking.value) { - statusText = 'aiReplying'.tr; // AI正在回复... - } else if (controller.isProcessing.value) { - statusText = 'processing'.tr; // 正在处理... - } - - return Text( - statusText, - style: TextStyle( - fontSize: 16, - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ); - }), - ], - ), - ); - } - // 构建打电话模式控制按钮 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.isTtsEnabled.value - ? Icons.volume_up - : Icons.volume_off, - onTap: () => controller.toggleTts(), + icon: controller.isListening.value + ? Icons.mic // 录音中显示麦克风图标 + : Icons.mic_off, // 未录音显示麦克风关闭图标 + onTap: () => controller.togglePhoneCallRecording(), isDarkMode: isDarkMode, - isActive: controller.isTtsEnabled.value, + isActive: controller.isListening.value, + // backgroundColor: controller.isListening.value + // ? Colors.red // 录音中显示红色背景 + // : Theme.of(context).primaryColor, // 未录音显示主题色 )), - // 主要通话按钮 - Obx(() { - final isActive = controller.isListening.value; - return GestureDetector( - // onTap: () { - // if (isActive) { - // controller.stopVoiceInput(); - // } else { - // controller.startVoiceInput(); - // } - // }, - child: Container( - width: 80.w, - height: 80.h, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isActive ? Colors.red : Colors.green, - boxShadow: [ - BoxShadow( - color: (isActive ? Colors.red : Colors.green) - .withOpacity(0.3), - blurRadius: 12.w, - spreadRadius: 2.w, - ), - ], - ), - child: Icon( - isActive ? Icons.call_end : Icons.call, - color: Colors.white, - size: 36.sp, - ), - ), - ); - }), + // 音频波纹组件(中间) - 仅显示动态波形效果,无其他UI元素 + Container( + width: 120.w, // 设置固定宽度 + height: 64.h, // 与按钮高度一致 + alignment: Alignment.center, // 确保波纹居中 + child: const AgentSoundBarsWidget(barCount: 12), // 适应宽度的波纹条数 + ), - // 结束通话按钮 + // 关闭打电话模式按钮(右侧) - 退出打电话模式 _buildPhoneControlButton( - icon: Icons.call_end, + icon: Icons.close, onTap: () => controller.endPhoneCall(), isDarkMode: isDarkMode, isActive: false, - backgroundColor: Colors.red, + //backgroundColor: Colors.red, + iconColor: Colors.redAccent, ), ], ), @@ -1138,24 +1001,31 @@ class _AgentViewState extends State with WidgetsBindingObserver { required bool isDarkMode, required bool isActive, Color? backgroundColor, + Color? iconColor, }) { return GestureDetector( onTap: onTap, child: Container( - width: 56.w, - height: 56.h, + width: 64.w, // 按钮尺寸 + height: 64.h, decoration: BoxDecoration( shape: BoxShape.circle, color: backgroundColor ?? - (isDarkMode ? Colors.grey[800] : Colors.grey[200]), - border: isActive ? Border.all(color: Colors.blue, width: 2.w) : null, + (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: backgroundColor != null - ? Colors.white - : (isDarkMode ? Colors.white : Colors.black), - size: 24.sp, + color: iconColor ?? (isDarkMode ? Colors.white : Colors.black), + size: 28.sp, // 图标尺寸 ), ), ); diff --git a/lib/modules/agent/widgets/sound_bar_widget.dart b/lib/modules/agent/widgets/sound_bar_widget.dart index c108bcca2..9028f713b 100644 --- a/lib/modules/agent/widgets/sound_bar_widget.dart +++ b/lib/modules/agent/widgets/sound_bar_widget.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/agent_controller.dart'; -/// 声音柱动画组件 +/// 声音柱动画组件 (已优化) /// 用于显示语音输入时的音频波形效果 class SoundBarWidget extends StatefulWidget { final double delay; @@ -37,9 +37,8 @@ class _SoundBarWidgetState extends State parent: _controller, curve: Curves.easeInOut, ), - )..addListener(() { - setState(() {}); - }); + ); + // 移除了 .addListener(() { setState(() {}); }) _startOrStopAnimation(); } @@ -54,6 +53,9 @@ class _SoundBarWidgetState extends State /// 开始或停止动画 void _startOrStopAnimation() { + // 确保组件仍然挂载在树上 + if (!mounted) return; + if (widget.isActive) { Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { if (mounted && widget.isActive) { @@ -61,9 +63,10 @@ class _SoundBarWidgetState extends State } }); } else { - _controller.reset(); - // 设置为小高度的静态状态 - _controller.animateTo(0.1, duration: const Duration(milliseconds: 300)); + // 停止并平滑地过渡到初始状态 + _controller.stop(); + _controller.animateTo(0.0, + duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } } @@ -77,21 +80,28 @@ class _SoundBarWidgetState extends State Widget build(BuildContext context) { final primaryColor = Theme.of(context).primaryColor; - return Container( - width: 2.5, - height: widget.isActive ? _animation.value : 3, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: widget.isActive - ? primaryColor.withOpacity(0.8) - : Colors.grey.withOpacity(0.4), - borderRadius: BorderRadius.circular(1.5), - ), + // 使用 AnimatedBuilder 来优化性能 + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: 2.5, + // 当动画不活跃时,高度固定为3;活跃时使用动画值 + height: widget.isActive ? _animation.value : 3, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: widget.isActive + ? primaryColor.withOpacity(0.8) + : Colors.grey.withOpacity(0.4), + borderRadius: BorderRadius.circular(1.5), + ), + ); + }, ); } } -/// 声音柱组合组件 +/// 声音柱组合组件 (无需改动) /// 包含多个声音柱的组合显示 class SoundBarsWidget extends StatelessWidget { final bool isActive; @@ -106,7 +116,7 @@ class SoundBarsWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: List.generate(barCount, (index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), @@ -120,7 +130,7 @@ class SoundBarsWidget extends StatelessWidget { } } -/// 带控制器集成的声音柱组件 +/// 带控制器集成的声音柱组件 (无需改动) /// 自动从AgentController获取状态 class AgentSoundBarsWidget extends StatelessWidget { final int barCount; diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 1beadea7a..66ff69211 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -57,6 +57,7 @@ class DevicesController extends GetxController { User.instance.removeUserDevice(device.id); _bleManager.clearPairedDevices(); _bleManager.disconnect(); + await Future.delayed(const Duration(seconds: 1)); Get.toNamed(Routes.pairing); return; } else { diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 31db67e15..902ceb58d 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -1,13 +1,11 @@ import 'package:get/get.dart'; import 'dart:async'; +import '../../../data/services/novice_guide_service.dart'; import '../../../routes/app_routes.dart'; import '../../../core/utils/logger.dart'; import 'package:flutter/material.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/ble_manager.dart'; -import '../../../data/services/version_update_service.dart'; -import '../../common/widgets/version_update_dialog.dart'; -import 'package:get_storage/get_storage.dart'; /// 连接状态枚举 enum ConnectionStatus { @@ -24,7 +22,7 @@ class HomeController extends GetxController { final _isFullyConnected = false.obs; String? _lastConnectedDeviceName; - + bool get isFullyConnected => _isFullyConnected.value; RxBool get isFullyConnectedRx => _isFullyConnected; BluetoothDevice? get currentDevice => _bluetoothManager.currentDevice; @@ -53,14 +51,9 @@ class HomeController extends GetxController { final _bluetoothManager = Get.find(); final _bleManager = Get.find(); - final _storage = GetStorage(); - // 会话级别的版本检查标记(静态变量,整个应用会话期间保持) - static bool _hasCheckedVersionInSession = false; + final noviceGuideService = Get.find(); - // 版本检查相关常量 - static const String _lastVersionCheckKey = 'last_version_check_timestamp'; - static const int _versionCheckIntervalHours = 24; // 24小时间隔 @override void onInit() { super.onInit(); @@ -78,12 +71,6 @@ class HomeController extends GetxController { void _initWithCachedInfo() { _setupStateListeners(); _checkConnectionStatus(); - - // 延迟检查版本更新,避免影响页面加载 - // 混合检查机制:会话检查 + 24小时间隔检查 - Future.delayed(const Duration(seconds: 2), () { - _checkVersionUpdateIfNeeded(); - }); } void _setupStateListeners() { @@ -94,13 +81,14 @@ class HomeController extends GetxController { update(); }); - _classicBluetoothWorker = ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { + _classicBluetoothWorker = + ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { if (device != null) { _lastConnectedDeviceName = device.name; } _checkConnectionStatus(); }); - + _bleConnectionWorker = ever(_bleManager.connectionStateRx, (int state) { _checkConnectionStatus(); }); @@ -121,30 +109,30 @@ class HomeController extends GetxController { void exploreAIFeatures() { Get.toNamed(Routes.agent); } - + Future _checkConnectionStatus() async { try { final currentBleState = await _bleManager.getConnectionState(); - + bool hasClassicBluetooth = currentDevice != null; bool hasBleConnection = currentBleState == 2; bool wasConnected = _isFullyConnected.value; bool isNowConnected = hasClassicBluetooth && hasBleConnection; - + if (isNowConnected && currentDevice == null) { isNowConnected = false; } - + if (_isFullyConnected.value != isNowConnected) { _isFullyConnected.value = isNowConnected; _isFullyConnected.refresh(); update(); - + if (!wasConnected && isNowConnected) { await _bleManager.fetchDeviceInfo(); } } - + update(); } catch (e) { Logger.e(_tag, '${'checkConnectionStatusFailed'.tr}: $e'); @@ -165,26 +153,28 @@ class HomeController extends GetxController { } bool get shouldShowDeviceInfo => currentDevice != null; - + String getDeviceName() { if (_isFullyConnected.value && currentDevice == null) { _checkConnectionStatus(); } - + final deviceName = currentDevice?.name; if (deviceName != null && deviceName.isNotEmpty) { return deviceName; } - + final bleDeviceName = deviceInfo.value?.name; if (bleDeviceName != null && bleDeviceName.isNotEmpty) { return bleDeviceName; } - - if (!_isFullyConnected.value && _lastConnectedDeviceName != null && _lastConnectedDeviceName!.isNotEmpty) { + + if (!_isFullyConnected.value && + _lastConnectedDeviceName != null && + _lastConnectedDeviceName!.isNotEmpty) { return _lastConnectedDeviceName!; } - + return 'unknownEquipment'.tr; } @@ -270,104 +260,4 @@ class HomeController extends GetxController { ? deviceInfo.value!.firmwareVersion : 'unknown'.tr; } - - /// 混合检查机制:判断是否需要进行版本检查 - /// 检查条件:1. 会话内未检查过 OR 2. 距离上次检查超过24小时 - void _checkVersionUpdateIfNeeded() { - try { - final now = DateTime.now(); - final lastCheckTimestamp = _storage.read(_lastVersionCheckKey); - - bool shouldCheck = false; - String reason = ''; - - // 条件1:会话内未检查过 - if (!_hasCheckedVersionInSession) { - shouldCheck = true; - reason = '会话内首次检查'; - } - // 条件2:距离上次检查超过24小时 - else if (lastCheckTimestamp != null) { - final lastCheckTime = DateTime.fromMillisecondsSinceEpoch(lastCheckTimestamp); - final timeDifference = now.difference(lastCheckTime); - - if (timeDifference.inHours >= _versionCheckIntervalHours) { - shouldCheck = true; - reason = '距离上次检查已超过${_versionCheckIntervalHours}小时'; - } - } - // 条件3:从未检查过(首次使用应用) - else { - shouldCheck = true; - reason = '首次使用应用'; - } - - if (shouldCheck) { - Logger.i(_tag, '开始版本检查 - 原因: $reason'); - _performVersionCheck(); - } else { - Logger.i(_tag, '跳过版本检查 - 会话内已检查且未超过时间间隔'); - } - } catch (e) { - Logger.e(_tag, '版本检查判断失败: $e'); - } - } - - /// 执行版本检查(在后台进行,不阻塞启动流程) - Future _performVersionCheck() async { - try { - final versionService = VersionUpdateService.to; - final updateInfo = await versionService.checkForUpdate(); - - if (updateInfo.hasUpdate) { - Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}'); - // 显示更新对话框 - await VersionUpdateDialog.show(updateInfo); - } else { - Logger.i(_tag, '当前已是最新版本: ${updateInfo.currentVersion}'); - } - - // 更新检查记录 - _updateVersionCheckRecord(); - } catch (e) { - Logger.e(_tag, '版本检查失败: $e'); - // 即使检查失败,也更新记录,避免频繁重试 - _updateVersionCheckRecord(); - } - } - - /// 更新版本检查记录 - void _updateVersionCheckRecord() { - try { - // 更新时间戳 - final now = DateTime.now(); - _storage.write(_lastVersionCheckKey, now.millisecondsSinceEpoch); - - // 标记本次会话已检查过版本 - _hasCheckedVersionInSession = true; - - Logger.i(_tag, '版本检查记录已更新: ${now.toIso8601String()}'); - } catch (e) { - Logger.e(_tag, '更新版本检查记录失败: $e'); - } - } - - /// 重置版本检查标记(可用于强制重新检查) - /// 注意:这会清除会话标记,但不会清除时间戳记录 - static void resetVersionCheckFlag() { - _hasCheckedVersionInSession = false; - Logger.i('HomeCtrl', '版本检查会话标记已重置'); - } - - /// 完全重置版本检查状态(包括时间戳) - /// 用于测试或特殊情况下强制重新检查 - void forceResetVersionCheck() { - try { - _hasCheckedVersionInSession = false; - _storage.remove(_lastVersionCheckKey); - Logger.i(_tag, '版本检查状态已完全重置'); - } catch (e) { - Logger.e(_tag, '重置版本检查状态失败: $e'); - } - } } diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 8bcbac345..f842b6a23 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../controllers/home_controller.dart'; -import '../../../routes/app_pages.dart'; import '../../../routes/app_routes.dart'; import '../../../core/widgets/common_bottom_nav.dart'; @@ -84,11 +84,30 @@ class HomeView extends GetView { _buildAppBar(isDarkMode), // 顶部图片 - _buildEarbudsImage(), + IntroStepBuilder( + group: 'home', + order: 1, + text: '欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言', + onWidgetLoad: () { + controller.noviceGuideService.startHomeGuide(); + }, + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + child: _buildEarbudsImage(), + ), + ), SizedBox(height: 20.h), // 设备状态卡片 - _buildDeviceStatusCard(isDarkMode), + IntroStepBuilder( + group: 'home', + order: 2, + text: '对着麦克风说“小言小言”', + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + child: _buildDeviceStatusCard(isDarkMode), + ), + ), SizedBox(height: 20.h), // 热门AI体验 @@ -623,23 +642,33 @@ class HomeView extends GetView { // 左侧大卡片 - AI助理 Expanded( flex: 1, - child: _buildFeatureCard( - title: 'aiAssistant'.tr, // AI全能助手 - description: 'aiAssistantInfo'.tr, // 24小时专属顾问,解答问题、提供建议 - icon: Icons.assistant, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFF0066CC).withOpacity(0.3), - Color(0xFF5E5CE6).withOpacity(0.3), - ], + child: IntroStepBuilder( + group: 'home', + order: 3, + text: '太棒了!你已成功唤醒小言,接下来试试让它帮你做件事吧', + onHighlightWidgetTap: () { + Intro.of(Get.context!).dispose(); + controller.exploreAIFeatures(); + }, + builder: (BuildContext context, GlobalKey key) => _buildFeatureCard( + key: key, + title: 'aiAssistant'.tr, // AI全能助手 + description: 'aiAssistantInfo'.tr, // 24小时专属顾问,解答问题、提供建议 + icon: Icons.assistant, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF0066CC).withOpacity(0.3), + Color(0xFF5E5CE6).withOpacity(0.3), + ], + ), + onTap: controller.exploreAIFeatures, + iconOnTop: true, + height: 175.h, // 增加高度 + isDarkMode: isDarkMode, + iconColor: const Color(0xFF0066CC), ), - onTap: controller.exploreAIFeatures, - iconOnTop: true, - height: 175.h, // 增加高度 - isDarkMode: isDarkMode, - iconColor: const Color(0xFF0066CC), ), ), @@ -702,6 +731,7 @@ class HomeView extends GetView { // 特性卡片构建方法 Widget _buildFeatureCard({ + GlobalKey? key, required String title, required String description, required IconData icon, @@ -713,6 +743,7 @@ class HomeView extends GetView { Color iconColor = Colors.white, }) { return GestureDetector( + key: key, onTap: onTap, child: Container( height: height ?? 175.h, diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index d267211f3..f2783f4bb 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -1,12 +1,10 @@ -import 'dart:convert'; -import 'dart:typed_data'; - import 'package:app_settings/app_settings.dart'; import 'package:voitrans/data/models/appconfig_model.dart'; import '/data/models/appconfig.dart'; import '/data/models/user_Info.dart'; import '/data/services/network/api.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'dart:async'; import 'package:permission_handler/permission_handler.dart'; @@ -46,7 +44,7 @@ enum ErrorType { } /// 配对控制器 - 基于简化状态机的实现 -class PairingController extends GetxController { +class PairingController extends GetxController with WidgetsBindingObserver { static const String _tag = 'PairingCtrl'; static PairingController get to => Get.find(); @@ -102,6 +100,11 @@ class PairingController extends GetxController { super.onInit(); Logger.i(_tag, '配对控制器初始化'); + // 注册应用生命周期监听器 + Logger.i(_tag, '正在注册应用生命周期监听器'); + WidgetsBinding.instance.addObserver(this); + Logger.i(_tag, '应用生命周期监听器注册完成'); + // 确保服务已初始化 _bluetoothManager = Get.find(); _bleManager = Get.find(); @@ -125,6 +128,10 @@ class PairingController extends GetxController { @override void onClose() { Logger.i(_tag, '配对控制器销毁'); + + // 移除应用生命周期监听器 + WidgetsBinding.instance.removeObserver(this); + // 清理所有Worker for (var worker in _workers) { worker.dispose(); @@ -137,6 +144,109 @@ class PairingController extends GetxController { super.onClose(); } + /// 应用生命周期状态变化回调 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + // 使用多种日志级别确保输出 + Logger.i(_tag, '=== 应用生命周期状态变化: $state ==='); + print('PairingCtrl: 应用生命周期状态变化: $state'); // 直接使用print确保输出 + + switch (state) { + case AppLifecycleState.resumed: + // 应用从后台切回前台 + Logger.i(_tag, '=== 应用从后台切回前台 ==='); + print('PairingCtrl: 应用从后台切回前台'); + _onAppResumed(); + break; + case AppLifecycleState.paused: + // 应用切到后台 + Logger.i(_tag, '应用切到后台'); + print('PairingCtrl: 应用切到后台'); + _onAppPaused(); + break; + case AppLifecycleState.inactive: + // 应用处于非活跃状态(如来电话时) + Logger.i(_tag, '应用处于非活跃状态'); + print('PairingCtrl: 应用处于非活跃状态'); + break; + case AppLifecycleState.detached: + // 应用即将被销毁 + Logger.i(_tag, '应用即将被销毁'); + print('PairingCtrl: 应用即将被销毁'); + break; + case AppLifecycleState.hidden: + // 应用被隐藏(iOS 13+) + Logger.i(_tag, '应用被隐藏'); + print('PairingCtrl: 应用被隐藏'); + break; + } + } + + /// 应用从后台切回前台时的处理逻辑 + void _onAppResumed() { + Logger.i(_tag, '处理应用从后台切回前台事件'); + + // 刷新蓝牙连接状态 + _bluetoothManager.refreshDeviceConnection(); + + // 根据当前配对状态执行相应操作 + switch (_currentState.value) { + case PairingState.waitingClassic: + // 如果在等待经典蓝牙连接,重新检查连接状态 + Logger.i(_tag, '重新检查经典蓝牙连接状态'); + _checkCurrentStatus(); + break; + case PairingState.waitingBle: + // 如果在等待BLE连接,重新检查BLE状态 + Logger.i(_tag, '重新检查BLE连接状态'); + _checkBleConnectionAfterResume(); + break; + case PairingState.error: + // 如果在错误状态,根据错误类型决定是否自动重试 + if (_errorType.value == ErrorType.bluetoothOff) { + Logger.i(_tag, '检查蓝牙是否已重新开启'); + _checkCurrentStatus(); + } + break; + default: + // 其他状态暂不处理 + break; + } + } + + /// 应用切到后台时的处理逻辑 + void _onAppPaused() { + Logger.i(_tag, '处理应用切到后台事件'); + + // 可以在这里暂停一些不必要的操作,如蓝牙扫描等 + if (_currentState.value == PairingState.associating) { + Logger.i(_tag, '应用切到后台,暂停BLE扫描'); + _bleManager.stopScan(); + } + } + + /// 应用恢复后检查BLE连接状态 + Future _checkBleConnectionAfterResume() async { + try { + final currentBleState = await _bleManager.getConnectionState(); + Logger.i(_tag, '应用恢复后BLE连接状态: $currentBleState'); + + if (currentBleState == 2) { + // BLE已连接,直接完成配对 + Logger.i(_tag, '检测到BLE已连接,完成配对'); + _bleTimeoutTimer?.cancel(); + _transition(PairingState.completed); + } else { + // BLE未连接,重新启动连接流程 + Logger.i(_tag, '重新启动BLE连接流程'); + _startBleConnection(); + } + } catch (e) { + Logger.e(_tag, '检查BLE连接状态失败: $e'); + } + } + // ==================== 监听器设置 ==================== /// 集中设置所有监听器 @@ -202,7 +312,7 @@ class PairingController extends GetxController { case PairingState.waitingClassic: if (device != null) { // 有设备连接,进入验证阶段 - Logger.i(_tag, '检测到经典蓝牙设备连接,开始验证'); + Logger.d(_tag, '检测到经典蓝牙设备连接,开始验证'); _transition(PairingState.verifying); _verifyDevice(device); } @@ -623,7 +733,7 @@ class PairingController extends GetxController { ); product = match.isNotEmpty ? match.first : null; } - + final currentBleState = await _bleManager.getConnectionState(); final names = AppConfig.allproductNames(); // 检查是否已有绑定的设备 print("device.address${device.address}"); @@ -645,6 +755,17 @@ class PairingController extends GetxController { // 已经绑定 return 9; } + } else { + //未绑定设备 + + Logger.i(_tag, '验证时BLE状态: $currentBleState'); + if (currentBleState == 2) { + //已连接 清理 + // BleConnectionState.connected + Logger.i(_tag, 'BLE已连接,断开并清理缓存'); + _bleManager.clearPairedDevices(); + _bleManager.disconnect(); + } } if (Platform.isAndroid) { @@ -672,11 +793,15 @@ class PairingController extends GetxController { print( '获取到 deviceName: $deviceName address: ${device.address} names:$names'); if (names.contains(deviceName)) { + if (currentBleState == 2) { + //已连接直接返回 + return 9; + } await _bleManager.startScan(deviceName); await Future.delayed(const Duration(seconds: 1)); // 尝试最多读取 3 次设备信息,每次间隔 1 秒 List>? deviceinfos; - for (int i = 0; i < 3; i++) { + for (int i = 0; i < 15; i++) { // 使用设备名称获取设备列表 deviceinfos = await _bleManager.getDeviceList(deviceName); print('获取到 deviceinfos: $deviceinfos'); @@ -920,12 +1045,12 @@ class PairingController extends GetxController { case ErrorType.bluetoothOff: // 引导用户去开启蓝牙 Logger.i(_tag, '引导用户开启蓝牙'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); break; case ErrorType.noClassicDevice: // 引导用户去蓝牙设置连接设备 Logger.i(_tag, '引导用户去蓝牙设置'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); break; default: // 其他错误情况,刷新并重试 @@ -936,7 +1061,7 @@ class PairingController extends GetxController { } else if (_currentState.value == PairingState.waitingClassic) { // 等待经典蓝牙连接状态,引导去设置 Logger.i(_tag, '等待经典蓝牙连接,引导用户去蓝牙设置'); - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); } else { // 其他情况,刷新设备连接 _bluetoothManager.refreshDeviceConnection(); @@ -994,7 +1119,98 @@ class PairingController extends GetxController { /// 打开蓝牙设置 void openBluetoothSettings() { - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + _openBluetoothSettings(); + } + + /// 打开蓝牙设置 - 简化版本 + void _openBluetoothSettings() async { + if (Platform.isIOS) { + // iOS平台:直接显示用户指导对话框 + _showBluetoothGuideDialog(); + } else { + // Android平台使用原有方式 + AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + } + } + + /// 显示蓝牙设置指导对话框 + void _showBluetoothGuideDialog() { + Get.dialog( + AlertDialog( + title: Text('bluetoothGuideTitle'.tr), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('bluetoothGuideSteps'.tr), + SizedBox(height: 16.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('1. ', + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 14.sp)), + Expanded( + child: Text('bluetoothGuideStep1'.tr, + style: TextStyle(fontSize: 14.sp))), + ], + ), + SizedBox(height: 8.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('2. ', + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 14.sp)), + Expanded( + child: Text('bluetoothGuideStep2'.tr, + style: TextStyle(fontSize: 14.sp))), + ], + ), + SizedBox(height: 8.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('3. ', + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 14.sp)), + Expanded( + child: Text('bluetoothGuideStep3'.tr, + style: TextStyle(fontSize: 14.sp))), + ], + ), + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue, size: 20.sp), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'bluetoothGuideTip'.tr, + style: + TextStyle(fontSize: 12.sp, color: Colors.blue[700]), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('iKnow'.tr, style: TextStyle(fontSize: 14.sp)), + ), + ], + ), + ); } /// 清除设备关联(在需要重新配对时调用) diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 58d527fdb..784c905ed 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -407,8 +407,6 @@ class SettingsView extends GetView { ? Colors.white.withOpacity(0.1) : Colors.grey[200]), - - // 用户反馈 _buildSimpleNavigationSetting( title: 'feedback'.tr, // 用户反馈 @@ -427,29 +425,33 @@ class SettingsView extends GetView { }, isDarkMode: isDarkMode, ), - Divider( - height: 1, - color: isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.grey[200]), + if (Platform.isAndroid) ...[ + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + + // 隐私设置 + _buildSimpleNavigationSetting( + title: 'privacySettings'.tr, // 隐私设置 + subtitle: 'privacySettingsInfo'.tr, // 管理数据收集和权限 + icon: Icons.shield_outlined, + iconBgColor: isDarkMode + ? Colors.purple[900]!.withOpacity(0.3) + : Colors.purple[100]!, + iconColor: isDarkMode + ? Colors.purple[300]! + : Colors.purple[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + AppSettings.openAppSettings(); + }, + isDarkMode: isDarkMode, + ) + ], - // 隐私设置 - _buildSimpleNavigationSetting( - title: 'privacySettings'.tr, // 隐私设置 - subtitle: 'privacySettingsInfo'.tr, // 管理数据收集和权限 - icon: Icons.shield_outlined, - iconBgColor: isDarkMode - ? Colors.purple[900]!.withOpacity(0.3) - : Colors.purple[100]!, - iconColor: - isDarkMode ? Colors.purple[300]! : Colors.purple[600]!, - titleColor: isDarkMode ? Colors.white : null, - subtitleColor: isDarkMode ? Colors.white70 : null, - onTap: () { - AppSettings.openAppSettings(); - }, - isDarkMode: isDarkMode, - ), Divider( height: 1, color: isDarkMode @@ -597,29 +599,32 @@ class SettingsView extends GetView { onTap: () => Get.toNamed(Routes.OTA_UPGRADE), isDarkMode: isDarkMode, ), - Divider( - height: 1, - color: isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.grey[200]), + if (Platform.isAndroid) ...[ + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), - // 添加权限管理设置项 - _buildSimpleNavigationSetting( - title: 'permissionManagement'.tr, // 权限管理 - subtitle: 'permissionManagementInfo'.tr, // 管理应用权限设置 - icon: Icons.security, - iconBgColor: isDarkMode - ? Colors.orange[900]!.withOpacity(0.3) - : Colors.orange[100]!, - iconColor: - isDarkMode ? Colors.orange[300]! : Colors.orange[600]!, - titleColor: isDarkMode ? Colors.white : null, - subtitleColor: isDarkMode ? Colors.white70 : null, - onTap: () { - Get.toNamed(Routes.permissions); - }, - isDarkMode: isDarkMode, - ), + // 添加权限管理设置项 + _buildSimpleNavigationSetting( + title: 'permissionManagement'.tr, // 权限管理 + subtitle: 'permissionManagementInfo'.tr, // 管理应用权限设置 + icon: Icons.security, + iconBgColor: isDarkMode + ? Colors.orange[900]!.withOpacity(0.3) + : Colors.orange[100]!, + iconColor: isDarkMode + ? Colors.orange[300]! + : Colors.orange[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.permissions); + }, + isDarkMode: isDarkMode, + ), + ] ], ), ), diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index ba834cdc1..013ba187e 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -14,17 +14,26 @@ import '../../../data/services/network/api.dart'; import '../../../data/services/version_update_service.dart'; import '../../../modules/common/widgets/version_update_dialog.dart'; import '../../../modules/common/widgets/privacy_policy_dialog.dart'; -import '../../common/webview_page.dart'; +import '../../../core/utils/logger.dart'; class SplashController extends GetxController { final GetStorage _storage = GetStorage(); + static const String _tag = 'SplashCtrl'; + // 进度条动画控制 final _loadingProgress = 0.0.obs; double get loadingProgress => _loadingProgress.value; - // 资源加载状态 - final _isResourceLoaded = false.obs; - bool get isResourceLoaded => _isResourceLoaded.value; + // 各项任务完成状态 + final _isTrackingPermissionRequested = false.obs; // 追踪权限请求完成 + final _isVersionCheckCompleted = false.obs; // 版本检查完成 + final _isTokenValidated = false.obs; // Token验证完成 + final _isAppConfigLoaded = false.obs; // 应用配置加载完成 + final _isAnimationCompleted = false.obs; // 动画完成 + + // 登录状态 + final _isLoggedIn = false.obs; + bool get isLoggedIn => _isLoggedIn.value; final _hasAcceptedPrivacy = false.obs; //是否同意隐私政策 @@ -34,6 +43,13 @@ class SplashController extends GetxController { // 主题服务 final ThemeService _themeService = ThemeService(); + // 会话级别的版本检查标记(静态变量,整个应用会话期间保持) + static bool _hasCheckedVersionInSession = false; + + // 版本检查相关常量 + static const String _lastVersionCheckKey = 'last_version_check_timestamp'; + static const int _versionCheckIntervalHours = 24; // 24小时间隔 + @override void onInit() { super.onInit(); @@ -50,22 +66,51 @@ class SplashController extends GetxController { } }); - //弹出用户追中问题 - WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) async { - final status = - await AppTrackingTransparency.requestTrackingAuthorization(); - }); + // 注意:追踪权限请求将在用户同意隐私政策后执行 + } + + /// 异步请求追踪权限 + Future _requestTrackingPermissionAsync() async { + try { + await AppTrackingTransparency.requestTrackingAuthorization(); + Logger.d(_tag, '用户追踪权限请求完成'); + } catch (e) { + Logger.e(_tag, '用户追踪权限请求失败: $e'); + } finally { + _isTrackingPermissionRequested.value = true; + } } void _startNormalFlow() { _startLoadingAnimation(); - _loadAppResources(); + _startParallelTasks(); + } + + /// 启动并行任务:在1.5秒动画期间同时执行多个任务 + void _startParallelTasks() { + // 并行执行所有任务 + _requestTrackingPermissionAsync(); + _performVersionCheckAsync(); + _validateTokenAsync(); + } + + /// 异步执行版本检查 + Future _performVersionCheckAsync() async { + try { + Logger.d(_tag, '开始执行版本检查'); + await _checkVersionUpdateIfNeeded(); + } catch (e) { + Logger.e(_tag, '版本检查失败: $e'); + } + //finally { + // _isVersionCheckCompleted.value = true; + // } } void _onPrivacyAccepted() { _storage.write('hasAcceptedPrivacy', true); Get.back(); // 关闭弹窗 - _startNormalFlow(); // 开始正常流程 + _startNormalFlow(); // 开始正常流程(已包含版本检查) } void _onPrivacyRejected() { @@ -84,22 +129,10 @@ class SplashController extends GetxController { ); } - // 初始化地图SDK - // void _initMapSDK() { - // try { - // AmapFlutter().initSdk( - // iosKey: "a4a1394fe817c2f86a424b897b4a9af4", - // androidKey: "78e7946dd602778df82c6ae143a88184", - // ); - // } catch (e) { - // print('地图SDK初始化失败: $e'); - // } - // } - - // 进度条动画(持续2秒) + // 进度条动画(持续1.5秒) void _startLoadingAnimation() { - const totalDuration = 1500; // 动画总时长增加到1.5秒 - const steps = totalDuration ~/ 30; // 30的间隔,更平滑的动画 + const totalDuration = 1500; // 动画总时长1.5秒 + const steps = totalDuration ~/ 30; // 30ms间隔,更平滑的动画 const stepValue = 1.0 / steps; Timer.periodic(const Duration(milliseconds: 30), (timer) { @@ -107,66 +140,66 @@ class SplashController extends GetxController { _loadingProgress.value += stepValue; } else { timer.cancel(); + _isAnimationCompleted.value = true; _checkNavigateCondition(); } }); } - // 异步加载应用资源 - Future _loadAppResources() async { + /// 异步验证Token和加载应用配置 + Future _validateTokenAsync() async { try { - // 模拟资源加载(如API配置、本地缓存等) - await Future.delayed(const Duration(milliseconds: 1500)); + bool isLoggedIn = false; + final token = _storage.read('logintoken'); + Logger.d(_tag, 'Token验证开始: $token'); - // 实际资源加载代码 - // await _loadImages(); - // await _initDatabase(); - - // 检查版本更新(在后台进行,不阻塞启动流程) - //_checkVersionUpdate(); + if (token != null && token.isNotEmpty) { + isLoggedIn = await getLoginToken(); + if (isLoggedIn) { + await getAppConfig(); + _isAppConfigLoaded.value = true; + } + } - _isResourceLoaded.value = true; - _checkNavigateCondition(); + _isLoggedIn.value = isLoggedIn; + Logger.d(_tag, 'Token验证完成,登录状态: $isLoggedIn'); } catch (e) { - Get.snackbar('错误', '资源加载失败,请检查网络连接'); - _isResourceLoaded.value = true; // 允许用户继续使用 - print('资源加载错误: $e'); + Logger.e(_tag, 'Token验证失败: $e'); + _isLoggedIn.value = false; + } finally { + _isTokenValidated.value = true; } } - // 检查跳转条件 + // 检查跳转条件:所有任务都完成后才跳转 void _checkNavigateCondition() { - if (_loadingProgress.value >= 1.0 && _isResourceLoaded.value) { - _navigateToLogin(); + if (_isAnimationCompleted.value && + _isTrackingPermissionRequested.value && + _isVersionCheckCompleted.value && + _isTokenValidated.value) { + _navigateToTargetPage(); } } - // 获取登录Token并跳转 - Future _navigateToLogin() async { - bool isLoggedIn = false; - final token = _storage.read('logintoken'); - print('getLoginToken:进来了${token}'); - if (token != null && token.isNotEmpty) { - isLoggedIn = await getLoginToken(); - } - if (isLoggedIn) { - await getAppConfig(); + // 根据登录状态跳转到目标页面 + void _navigateToTargetPage() { + if (_isLoggedIn.value && _isAppConfigLoaded.value) { Get.offAllNamed(Routes.pairing); } else { - Get.offAllNamed(Routes.login); // 跳转到登录页 + Get.offAllNamed(Routes.login); } } Future getLoginToken() async { try { - print('getLoginToken:进来了'); + Logger.d(_tag, 'getLoginToken:进来了'); //if (User.getname() != "") { final response = await Api.tokenlogin(); - print('getLoginToken: $response'); + Logger.d(_tag, 'getLoginToken: $response'); if (response == null) return false; if (response['user'] != '') { - print('getLoginToken:进来了'); + Logger.d(_tag, 'getLoginToken:进来了'); // 根据API设计调整 final user = User.fromJson(response); @@ -178,21 +211,21 @@ class SplashController extends GetxController { //} return false; } catch (e) { - print('Token获取失败: $e'); + Logger.e(_tag, 'Token获取失败: $e'); return false; } } Future getAppConfig() async { try { - print('getAppConfig:进来了'); + Logger.d(_tag, 'getAppConfig:进来了'); //if (User.getname() != "") { final response = await Api.getappconfig(); - print('getAppConfig: $response'); + Logger.d(_tag, 'getAppConfig: $response'); if (response == null) return false; // 根据API设计调整 final resp = UserGetAppConfigResp.fromJson(response); - print('getAppConfig:进来了'); + Logger.d(_tag, 'getAppConfig:进来了'); // 存储用户信息(示例) // _storage.write('ENV', resp.env); // _storage.write('AGENTS', resp.agents); @@ -200,27 +233,125 @@ class SplashController extends GetxController { AppConfig.initialize(resp); return true; } catch (e) { - print('getAppConfig 取失败: $e'); + Logger.e(_tag, 'getAppConfig 取失败: $e'); return false; } } - /// 检查版本更新(后台执行,不阻塞启动流程) - // void _checkVersionUpdate() { - // // 延迟执行版本检查,避免影响启动速度 - // Timer(const Duration(seconds: 1), () async { - // try { - // final versionService = VersionUpdateService.to; - // final updateInfo = await versionService.checkForUpdate(); - - // if (updateInfo.hasUpdate) { - // // 显示更新对话框 - // await VersionUpdateDialog.show(updateInfo); - // } - // } catch (e) { - // print('版本检查失败: $e'); - // // 版本检查失败不影响用户使用,静默处理 - // } - // }); - // } + /// 混合检查机制:判断是否需要进行版本检查 + /// 检查条件:1. 会话内未检查过 OR 2. 距离上次检查超过24小时 + Future _checkVersionUpdateIfNeeded() async { + try { + final now = DateTime.now(); + final lastCheckTimestamp = _storage.read(_lastVersionCheckKey); + + bool shouldCheck = false; + String reason = ''; + + // 条件1:会话内未检查过 + if (!_hasCheckedVersionInSession) { + shouldCheck = true; + reason = '会话内首次检查'; + } + // 条件2:距离上次检查超过24小时 + else if (lastCheckTimestamp != null) { + final lastCheckTime = + DateTime.fromMillisecondsSinceEpoch(lastCheckTimestamp); + final timeDifference = now.difference(lastCheckTime); + + if (timeDifference.inHours >= _versionCheckIntervalHours) { + shouldCheck = true; + reason = '距离上次检查已超过${_versionCheckIntervalHours}小时'; + } + } + // 条件3:从未检查过(首次使用应用) + else { + shouldCheck = true; + reason = '首次使用应用'; + } + + if (shouldCheck) { + Logger.i(_tag, '开始版本检查 - 原因: $reason'); + await _performVersionCheck(); + } else { + Logger.i(_tag, '跳过版本检查 - 会话内已检查且未超过时间间隔'); + } + } catch (e) { + Logger.e(_tag, '版本检查判断失败: $e'); + } + } + + /// 执行版本检查(在后台进行,不阻塞启动流程) + Future _performVersionCheck() async { + try { + final versionService = VersionUpdateService.to; + // 异步执行,不等待结果 + versionService.checkForUpdate().then((updateInfo) { + if (updateInfo.hasUpdate) { + Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}'); + // 在主线程中显示更新对话框 + WidgetsBinding.instance.addPostFrameCallback((_) { + VersionUpdateDialog.show(updateInfo).then((_) { + // 无论用户选择更新还是稍后提醒,都标记版本检查完成 + _isVersionCheckCompleted.value = true; + _checkNavigateCondition(); + }); + }); + } else { + _isVersionCheckCompleted.value = true; + Logger.i(_tag, '当前已是最新版本: ${updateInfo.currentVersion}'); + } + + // 更新检查记录 + _updateVersionCheckRecord(); + }).catchError((e) { + Logger.e(_tag, '版本检查失败: $e'); + // 即使检查失败,也标记版本检查完成,让流程继续 + _isVersionCheckCompleted.value = true; + // 更新检查记录,避免频繁重试 + _updateVersionCheckRecord(); + }); + } catch (e) { + Logger.e(_tag, '版本检查失败: $e'); + // 即使检查失败,也标记版本检查完成,让流程继续 + _isVersionCheckCompleted.value = true; + // 更新检查记录,避免频繁重试 + _updateVersionCheckRecord(); + } + } + + /// 更新版本检查记录 + void _updateVersionCheckRecord() { + try { + // 更新时间戳 + final now = DateTime.now(); + _storage.write(_lastVersionCheckKey, now.millisecondsSinceEpoch); + + // 标记本次会话已检查过版本 + _hasCheckedVersionInSession = true; + + Logger.i(_tag, '版本检查记录已更新: ${now.toIso8601String()}'); + } catch (e) { + Logger.e(_tag, '更新版本检查记录失败: $e'); + } + } + + /// 重置版本检查标记(可用于强制重新检查) + /// 注意:这会清除会话标记,但不会清除时间戳记录 + static void resetVersionCheckFlag() { + _hasCheckedVersionInSession = false; + Logger.i('SplashCtrl', '版本检查会话标记已重置'); + } + + /// 完全重置版本检查状态(包括时间戳) + /// 用于测试或特殊情况下强制重新检查 + void forceResetVersionCheck() { + try { + _hasCheckedVersionInSession = false; + _storage.remove(_lastVersionCheckKey); + Logger.i(_tag, '版本检查状态已完全重置'); + } catch (e) { + Logger.e(_tag, '重置版本检查状态失败: $e'); + } + } } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 1437d4982..1fdaea5df 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -815,16 +815,11 @@ object AgentService : CoroutineScope { // 先添加系统提示词 if (systemPrompt.isNotEmpty()) { -// val location = LocationService.getCachedLocation() ?: mapOf( -// "user_location" to "定位未开启", -// "user_lat" to "定位未开启", -// "user_lng" to "定位未开启", -// ) val mapdata = UserPortrait.getAll() - Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") + // Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata) messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt)) - Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}") + // Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}") } // 再添加历史消息 @@ -893,6 +888,7 @@ object AgentService : CoroutineScope { responseBuilder.append(token) if (speakResponse && reply && broadcast) { ttsService?.speakStream(token) + // audioPlayer?.stopAudio() } if (reply && token.isNotEmpty()) { // 发送流式回复token @@ -905,10 +901,12 @@ object AgentService : CoroutineScope { override fun onComplete(sessionid: String) { try { + if (sessionid == currsessionId) { + audioPlayer?.stopAudio() + } // 视情况决定是否朗读回复 if (speakResponse && reply && broadcast && sessionid == currsessionId) { ttsService?.flushStream() - audioPlayer?.stopAudio() } val response = responseBuilder.toString() // 发送完整回复,包含是否有图片的标记 diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index d1f5c4c1e..bfac028cd 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -81,6 +81,7 @@ class AgentServiceImpl: NSObject { // chatStorageHelper 现在是单例,不需要初始化 UserPortrait.shared.initialize() BleService.shared.setDelegate(self) + AudioSessionHub.initialize() } func addListener(_ listener: AgentServiceListener) { @@ -500,6 +501,7 @@ audioStream.saveAudioDataTo(data: audioData) os_log("设置回调并调用sendMessageStream", log: logger, type: .info) chatApiService.setStreamCallback(currsessionId,callback) chatApiService.sendMessageStream(currsessionId,messages: messages) + audioPlayer?.playAwaitSound() os_log("sendMessageStream调用完成", log: logger, type: .info) } @@ -994,6 +996,7 @@ class ChatApiStreamCallback: StreamCallback { if speakResponse && reply && broadcast{ try agentService.azureTtsHelper?.speakStream(token) +// try agentService.audioPlayer?.stopAwaitSound() } if (reply && token != "") { try agentService.sendEvent(name: "assistant_token", data: ["token": token]) @@ -1007,6 +1010,12 @@ class ChatApiStreamCallback: StreamCallback { do { guard let agentService = try agentService else { return } + if sessionId == agentService.currsessionId { + // 停止等待音效 + agentService.audioPlayer?.stopAwaitSound() + } + + if speakResponse && reply && broadcast && sessionId == agentService.currsessionId{ agentService.azureTtsHelper?.flushStream() } @@ -1045,6 +1054,11 @@ class ChatApiStreamCallback: StreamCallback { do { guard let agentService = try agentService else { return } + if sessionId == agentService.currsessionId { + // 停止等待音效 + agentService.audioPlayer?.stopAwaitSound() + } + os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription) if (sessionId != agentService.currsessionId) { return @@ -1128,7 +1142,7 @@ extension AgentServiceImpl: TtsEventListener { isSpeaking = true restartIdleCheck() sendEvent(name: "tts_started", data: ["status": "started"]) - + audioPlayer?.stopAwaitSound() case .synthesisCompleted: isSpeaking = false restartIdleCheck() @@ -1204,6 +1218,7 @@ extension AgentServiceImpl: BleService.Callback { class AudioPlayer { private var player: AVAudioPlayer? private var callingPlayer: AVAudioPlayer? + private var awaitPlayer: AVAudioPlayer? // 新增:等待音效播放器 private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioPlayer") private let audioQueue = DispatchQueue(label: "agent.audio.player", qos: .userInitiated) @@ -1229,19 +1244,28 @@ class AudioPlayer { func playStartSound() { playSound(named: "start", fileType: "mp3") } - + + func playAwaitSound() { + playLoopSound(named: "await", fileType: "mp3", player: &awaitPlayer) + } + func playStopSound() { playSound(named: "stop", fileType: "mp3") } func playCallingSound() { - playLoopSound(named: "calling", fileType: "mp3") + playLoopSound(named: "calling", fileType: "mp3", player: &callingPlayer) } func stopCallingSound() { callingPlayer?.stop() callingPlayer = nil } + + func stopAwaitSound() { + awaitPlayer?.stop() + awaitPlayer = nil + } private func playSound(named: String, fileType: String = "mp3") { if let path = resourcesBundle.path(forResource: named, ofType: fileType) { @@ -1254,12 +1278,14 @@ class AudioPlayer { os_log("未找到音频文件: %{public}@.%{public}@ in bundle %{public}@", log: logger, type: .error, named, fileType, resourcesBundle.bundlePath) } - private func playLoopSound(named: String, fileType: String = "mp3") { - stopCallingSound() + private func playLoopSound(named: String, fileType: String = "mp3", player: inout AVAudioPlayer?) { + // 停止当前播放的循环音频 + player?.stop() + player = nil if let path = resourcesBundle.path(forResource: named, ofType: fileType) { let url = URL(fileURLWithPath: path) - safePlayLoopAudio(url: url) + safePlayLoopAudio(url: url, player: &player) return } @@ -1293,19 +1319,19 @@ class AudioPlayer { } } - private func safePlayLoopAudio(url: URL) { + private func safePlayLoopAudio(url: URL, player: inout AVAudioPlayer?) { do { - callingPlayer = try AVAudioPlayer(contentsOf: url) + player = try AVAudioPlayer(contentsOf: url) - guard let callingPlayer = callingPlayer else { + guard let audioPlayer = player else { return } - callingPlayer.numberOfLoops = -1 - callingPlayer.volume = 0.5 - callingPlayer.prepareToPlay() + audioPlayer.numberOfLoops = -1 // 无限循环 + audioPlayer.volume = 0.5 + audioPlayer.prepareToPlay() - let playResult = callingPlayer.play() + let playResult = audioPlayer.play() } catch { os_log("播放循环音频失败: %{public}@", log: logger, type: .error, error.localizedDescription) diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index 1c8a6062c..0c0ef5703 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -98,6 +98,10 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { let success = impl.interruptCurrentResponse() result(success) + case "stopAiSteam": + let success = impl.interruptCurrentResponse() + result(success) + case "dispose": let success = impl.dispose() result(success) @@ -172,4 +176,4 @@ extension AgentServiceEventHandler: AgentServiceListener { } } } -} +} diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift index cf2e9ac40..ea6aedcda 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift @@ -1,5 +1,7 @@ import AVFoundation import os.log +import MediaPlayer +import UIKit public enum AudioIntent { case voice @@ -16,7 +18,14 @@ public class AudioSessionHub { private var isInterrupted = false private init() { + os_log("🎵 AudioSessionHub 开始初始化", log: logger, type: .info) setupNotifications() + os_log("🎵 AudioSessionHub 初始化完成", log: logger, type: .info) + } + + public static func initialize() { + os_log("🎵 主动初始化 AudioSessionHub", log: shared.logger, type: .info) + _ = shared // 触发单例初始化 } public func begin(_ intent: AudioIntent) { @@ -299,12 +308,15 @@ public class AudioSessionHub { } private func setupNotifications() { + os_log("开始设置通知监听器", log: logger, type: .info) + NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionInterruption), name: AVAudioSession.interruptionNotification, object: nil ) + os_log("已注册音频会话中断监听器", log: logger, type: .info) NotificationCenter.default.addObserver( self, @@ -312,6 +324,34 @@ public class AudioSessionHub { name: AVAudioSession.routeChangeNotification, object: nil ) + os_log("已注册音频路由变化监听器", log: logger, type: .info) + + // 添加音频会话控制通知监听 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionBeginPlayback), + name: Notification.Name("AudioSessionBeginPlayback"), + object: nil + ) + os_log("已注册播放音频会话开始监听器", log: logger, type: .info) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionBeginVoice), + name: Notification.Name("AudioSessionBeginVoice"), + object: nil + ) + os_log("已注册语音音频会话开始监听器", log: logger, type: .info) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleVolumeControlRequest), + name: Notification.Name("VolumeControlRequest"), + object: nil + ) + os_log("已注册音量控制请求监听器", log: logger, type: .info) + + os_log("所有通知监听器设置完成", log: logger, type: .info) } /// 处理音频中断 @@ -431,7 +471,195 @@ public class AudioSessionHub { } } + /// 处理播放音频会话激活请求 + @objc private func handleAudioSessionBeginPlayback(notification: Notification) { + os_log("收到播放音频会话激活请求", log: logger, type: .info) + begin(.playback) + } + + /// 处理语音音频会话激活请求 + @objc private func handleAudioSessionBeginVoice(notification: Notification) { + os_log("收到语音音频会话激活请求", log: logger, type: .info) + begin(.voice) + } + + /// 处理音量控制请求 + @objc private func handleVolumeControlRequest(notification: Notification) { + os_log("🔊 收到音量控制请求通知", log: logger, type: .info) + os_log("收到音量控制请求", log: logger, type: .info) + + guard let volumeInfo = notification.object as? [String: Any], + let actionString = volumeInfo["action"] as? String else { + os_log("❌ 音量控制请求数据格式错误", log: logger, type: .error) + return + } + + // 将字符串转换为 VolumeControlAction + let action: VolumeControlAction + switch actionString { + case "increase": + action = .increase + case "decrease": + action = .decrease + case "mute": + action = .mute + case "setMax": + action = .setMax + default: + os_log("未知的音量控制动作: %{public}@", log: logger, type: .error, actionString) + return + } + + // 获取 value(如果有的话) + let value: Float? + if let valueObj = volumeInfo["value"], !(valueObj is NSNull) { + value = valueObj as? Float + } else { + value = nil + } + + os_log("音量控制详情: %{public}@", log: logger, type: .info, actionString) + + // 在这里集中处理所有音量控制逻辑 + performVolumeControl(action: action, value: value) + } + + private func performVolumeControl(action: VolumeControlAction, value: Float?) { + DispatchQueue.main.async { + self.performVolumeControlWithMPVolumeView(action: action, value: value) { success, message in + os_log("音量控制结果: %{public}@ - %{public}@", log: self.logger, type: .info, + success ? "成功" : "失败", message) + + // 发送结果通知 + let resultInfo = VolumeControlResult(success: success, message: message, action: action) + NotificationCenter.default.post( + name: Notification.Name("VolumeControlResult"), + object: resultInfo + ) + } + } + } + + private func performVolumeControlWithMPVolumeView(action: VolumeControlAction, value: Float?, completion: @escaping (Bool, String) -> Void) { + // 创建更大的 frame,有助于子视图正确初始化 + let volumeView = MPVolumeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + volumeView.showsVolumeSlider = true + volumeView.showsRouteButton = false + volumeView.alpha = 0.01 // 几乎透明 + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + completion(false, "无法获取窗口") + return + } + + window.addSubview(volumeView) + volumeView.setNeedsLayout() + volumeView.layoutIfNeeded() + + var retryCount = 0 + let maxRetries = 5 + + func attemptVolumeControl() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + os_log("尝试第 %d 次音量控制", log: self.logger, type: .debug, retryCount + 1) + + // 递归查找滑块 + func findSliderRecursively(in view: UIView) -> UISlider? { + if let slider = view as? UISlider { + return slider + } + for subview in view.subviews { + if let foundSlider = findSliderRecursively(in: subview) { + return foundSlider + } + } + return nil + } + + if let slider = findSliderRecursively(in: volumeView) { + let currentValue = slider.value + let newValue: Float + + switch action { + case .increase: + newValue = min(currentValue + 0.2, 1.0) // 从 0.1 改为 0.2 (20%) + case .decrease: + newValue = max(currentValue - 0.2, 0.0) // 从 0.1 改为 0.2 (20%) + case .mute: + newValue = 0.0 + case .setMax: + newValue = 1.0 + case .setValue(let val): + newValue = max(0.0, min(val, 1.0)) + } + + os_log("设置音量从 %.2f 到 %.2f", log: self.logger, type: .info, currentValue, newValue) + + slider.value = newValue + slider.sendActions(for: .valueChanged) + slider.sendActions(for: .touchUpInside) + + let message = self.getVolumeActionMessage(action: action) + completion(true, message) + volumeView.removeFromSuperview() + } else { + retryCount += 1 + if retryCount < maxRetries { + os_log("音量控制重试 %d/%d", log: self.logger, type: .debug, retryCount, maxRetries) + attemptVolumeControl() + } else { + os_log("所有重试都失败,使用备选方案", log: self.logger, type: .error) + volumeView.removeFromSuperview() + + // 备选方案:至少记录当前音量状态 + let currentVolume = self.audioSession.outputVolume + let message = "音量控制请求已处理(当前音量: \(Int(currentVolume * 100))%)" + completion(true, message) + } + } + } + } + + attemptVolumeControl() + } + + private func getVolumeActionMessage(action: VolumeControlAction) -> String { + switch action { + case .increase: + return "音量增加成功" + case .decrease: + return "音量降低成功" + case .mute: + return "静音设置成功" + case .setMax: + return "最大音量设置成功" + case .setValue: + return "音量设置成功" + } + } + deinit { NotificationCenter.default.removeObserver(self) } -} \ No newline at end of file +} + +// 在文件顶部添加 +enum VolumeControlAction { + case increase + case decrease + case mute + case setMax + case setValue(Float) +} + +struct VolumeControlInfo { + let action: VolumeControlAction + let value: Float? +} + +struct VolumeControlResult { + let success: Bool + let message: String + let action: VolumeControlAction +} diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 new file mode 100644 index 000000000..ae1bfcc9d Binary files /dev/null and b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/Resources/await.mp3 differ diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 24b60e444..5c90a7c67 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -32,6 +32,7 @@ public class AzureAsrHelper: NSObject { private var subscriptionKey = "" private var region = "" + // 音频源配置 public enum AudioSourceType { /** 使用设备麦克风 */ @@ -59,10 +60,14 @@ public class AzureAsrHelper: NSObject { * @param audioSourceType 音频源类型 * @return 初始化是否成功 */ + /** + * 初始化Azure语音服务 + * 预初始化关键组件以提升首次启动性能 + */ public func initialize( subscriptionKey: String, region: String, - supportedLanguages: [String] = ["zh-CN"], + supportedLanguages: [String] = ["zh-CN", "en-US"], audioSourceType: AudioSourceType = .microphone ) -> Bool { do { @@ -94,22 +99,30 @@ public class AzureAsrHelper: NSObject { // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - - speechConfig?.setPropertyTo("800", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") - speechConfig?.setPropertyTo("800", byName: "Speech_SegmentationSilenceTimeoutMs") - speechConfig?.setPropertyTo("Time", byName: "Speech_SegmentationStrategy") - + // 针对翻译场景的性能优化 + optimizeForTranslation() if isAutoDetectLanguage { - // 直接启用语言检测模式 + // 针对翻译场景优化的语言检测配置 speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") - os_log("启用语言检测模式", log: log, type: .info) + + // 设置语言检测的置信度阈值,提高准确性 + speechConfig?.setPropertyTo("0.7", byName: "SpeechServiceConnection_AutoDetectSourceLanguageConfidenceThreshold") + + // 不在这里设置语言,让SPXAutoDetectSourceLanguageConfiguration处理 + os_log("启用语言检测模式,支持语言: %{public}@", log: log, type: .info, supportedLanguages.joined(separator: ",")) } else { - // 设置指定的识别语言 speechConfig?.speechRecognitionLanguage = currentLanguage + os_log("固定语言模式,当前语言: %{public}@", log: log, type: .info, currentLanguage) } + + + // 录音文件类 recordfile = RecordFile() - // 创建识别器 + + // 预初始化音频组件 + preInitializeAudioComponents() + return true } catch { os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) @@ -117,7 +130,75 @@ public class AzureAsrHelper: NSObject { } } + /** + * 预初始化音频组件 + * 在初始化阶段就准备好音频流和相关组件,减少首次启动延迟 + */ + private func preInitializeAudioComponents() { + if audioStream == nil { + audioStream = AudioStream() + audioStream?.initAudioRecord() + audioStream?.onAudioData = { [weak self] data in + self?.recordfile?.saveAudioDataToWav(data) + } + + // 预创建音频配置 + if let pushStream = audioStream?.pushAudioStream { + audioConfig = SPXAudioConfiguration(streamInput: pushStream) + os_log("预初始化音频组件完成", log: log, type: .info) + } + } + } + + /** + * 检测输入文本的语言,从支持的语言列表中选择最匹配的 + * + * @param text 需要检测的文本 + * @return 从supportedLanguages中选择的最匹配语言代码 + */ + public func detectTextLanguage(_ text: String) -> String? { + // 如果只有一种支持的语言,直接返回 + if supportedLanguages.count == 1 { + return supportedLanguages[0] + } + + // 获取所有可能的语言及其置信度 + let languageResults = LanguageDetector.detectLanguageWithConfidence(from: text) + + // 遍历检测结果,找到第一个在supportedLanguages中的语言 + for result in languageResults { + let azureLanguageCode = LanguageDetector.convertToAzureLanguageCode(result.language) + if supportedLanguages.contains(azureLanguageCode) { + return azureLanguageCode + } + } + + // 如果没有找到匹配的语言,返回第一个支持的语言作为默认值 + return supportedLanguages.first + } + /** + * 检测文本语言并返回详细信息 + * + * @param text 需要检测的文本 + * @return 包含语言代码、置信度和语言名称的字典 + */ + public func detectTextLanguageWithDetails(_ text: String) -> [String: Any]? { + let results = LanguageDetector.detectLanguageWithConfidence(from: text) + guard let topResult = results.first else { + return nil + } + + let azureLanguageCode = LanguageDetector.convertToAzureLanguageCode(topResult.language) + let languageName = LanguageDetector.getLanguageName(from: topResult.language) + + return [ + "languageCode": azureLanguageCode, + "confidence": topResult.confidence, + "languageName": languageName, + "originalCode": topResult.language + ] + } /** * 开始连续语音识别 * @@ -129,11 +210,13 @@ public class AzureAsrHelper: NSObject { audioSourceType: AudioSourceType = .microphone ) -> Bool { guard speechConfig != nil else { + print("语音服务未初始化") //callback.onError("语音服务未初始化") return false } if _isContinuousRecognitionActive { + print("连续识别已在进行中") return true } @@ -143,6 +226,7 @@ public class AzureAsrHelper: NSObject { do { guard recognizer != nil else { if !setupRecognizer() { + print("重置识别器失败") // callback.onError("重置识别器失败") return false } @@ -165,6 +249,28 @@ public class AzureAsrHelper: NSObject { return false } } + // /** + // * 获取检测到的语言 + // */ + // private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { + // if !isAutoDetectLanguage { + // return "" + // } + + // // 尝试从属性中获取语言 + // if let properties = result.properties, + // let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage") { + // return language + // } + + // // 尝试另一种方式获取语言 + // do { + // let langResult = try SPXAutoDetectSourceLanguageResult(result) + // return langResult.language ?? "" + // } catch { + // return "" + // } + // } /** * 停止连续语音识别 @@ -257,30 +363,33 @@ public class AzureAsrHelper: NSObject { /** * 设置识别器 */ + /** + * 设置识别器 + * 优化:避免重复创建音频流,复用已初始化的组件 + */ private func setupRecognizer() -> Bool { do { - print("设置识别器${_isContinuousRecognitionActive}") + print("设置识别器\(_isContinuousRecognitionActive)") + // 如果正在进行连续识别,先停止 if _isContinuousRecognitionActive { - // 直接停止,不等待结果 try? recognizer?.stopContinuousRecognition() _isContinuousRecognitionActive = false } - setupMicrophoneStream() + + // 【优化】只有在音频流未初始化时才创建,避免重复初始化 + if audioStream == nil || audioConfig == nil { + setupMicrophoneStream() + } + // 清理旧的识别器 recognizer = nil // 创建识别器 if isAutoDetectLanguage { - // 使用更精确的语言配置方式 - var sourceLanguageConfigs: [SPXSourceLanguageConfiguration] = [] - for language in supportedLanguages { - if let config = try? SPXSourceLanguageConfiguration(language) { - sourceLanguageConfigs.append(config) - } - } - - let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguageConfigurations: sourceLanguageConfigs) + // 使用正确的初始化方法 + let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) + print("autoDetectConfig: \(autoDetectConfig)") recognizer = try SPXSpeechRecognizer( speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, @@ -296,7 +405,7 @@ public class AzureAsrHelper: NSObject { return true } catch { os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription) - try? recognizer?.stopContinuousRecognition() + try? recognizer?.stopContinuousRecognition() return false } } @@ -304,31 +413,164 @@ public class AzureAsrHelper: NSObject { /** * 设置麦克风流 - 使用推流方式 + * 优化:避免重复初始化已存在的音频流 */ private func setupMicrophoneStream() { do { - - if (audioStream == nil) { - // 创建外部音频拉流对象 - // 明确指定使用外部音频源(推流模式) + // 【优化】检查是否已经初始化,避免重复创建 + if audioStream == nil { audioStream = AudioStream() audioStream?.initAudioRecord() - audioStream?.onAudioData = { [weak self] data in - self?.recordfile?.saveAudioDataToWav(data) - } + audioStream?.onAudioData = { [weak self] data in + self?.recordfile?.saveAudioDataToWav(data) + } } - // 创建音频配置 - if let pushStream = audioStream?.pushAudioStream { // Safely unwrap optional + // 【优化】检查音频配置是否已存在 + if audioConfig == nil, let pushStream = audioStream?.pushAudioStream { audioConfig = SPXAudioConfiguration(streamInput: pushStream) - os_log("设置麦克风流:") + os_log("设置麦克风流完成", log: log, type: .info) } } catch { os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription) audioStream = nil } } + /** + * 从语音识别结果中获取检测到的语言 + * @param result 语音识别结果 + * @return 检测到的语言代码,如果检测失败则返回当前语言 + */ + private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { + guard isAutoDetectLanguage else { + return currentLanguage + } + + print("result: \(result)") + + // 备用方案:从属性中获取语言 + if let properties = result.properties, + let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage"), + !language.isEmpty { + print("从属性中检测到语言: \(language)") + return language + } + + // 最后的备用方案:从JSON响应中解析 + if let properties = result.properties, + let jsonString = properties.getPropertyByName("SpeechServiceResponse_Json"), + let jsonData = jsonString.data(using: .utf8) { + do { + if let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any], + let language = json["Language"] as? String { + print("从JSON中检测到语言: \(language)") + return language + } + } catch { + print("JSON解析失败: \(error)") + } + } + // 使用正确的SPXAutoDetectSourceLanguageResult初始化方法 + do { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + if let language = langResult.language, !language.isEmpty { + print("SDK方法检测到语言: \(language)") + return language + } + } catch { + print("SDK语言检测失败: \(error.localizedDescription)") + } + print("无法检测语言,使用默认语言: \(currentLanguage)") + return currentLanguage + } + /** + * 针对翻译场景的性能优化配置 + */ + private func optimizeForTranslation() { + guard let speechConfig = speechConfig else { return } + + // 启用实时转录,减少延迟 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableAudioLogging") + speechConfig.setPropertyTo("INTERACTIVE", byName: "SpeechServiceConnection_RecoMode") + speechConfig.setPropertyTo("PCM", byName: "Speech_PushStreamFormat") + // 优化语音端点检测,适合翻译场景 + speechConfig.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") + speechConfig.setPropertyTo("500", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") + speechConfig.setPropertyTo("500", byName: "Speech_SegmentationSilenceTimeoutMs") + // 启用标点符号,提高翻译质量 + speechConfig.setPropertyTo("true", byName: "SpeechServiceResponse_RequestProfanityFilterTrueFalse") + // 设置分段策略为时间模式 + speechConfig.setPropertyTo("Time", byName: "Speech_SegmentationStrategy") + + // 【优化】语言检测配置 - 减少检测延迟,实现快速响应 + if isAutoDetectLanguage { + // 降低语言检测置信度阈值,提高检测灵敏度 + speechConfig.setPropertyTo("0.3", byName: "SpeechServiceConnection_AutoDetectSourceLanguageConfidenceThreshold") + + // 使用Continuous模式,在每句话结束后重新检测语言 + speechConfig.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") + + // 启用快速语言检测 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableLanguageDetectionFast") + + // 【关键优化】设置语言检测的最小音频长度为100毫秒,实现快速响应 + speechConfig.setPropertyTo("100", byName: "SpeechServiceConnection_LanguageDetectionMinAudioLengthMs") + + // 【新增】设置语言检测的最大等待时间,避免长时间等待 + speechConfig.setPropertyTo("2000", byName: "SpeechServiceConnection_LanguageDetectionMaxWaitTimeMs") + + // 强制在每个语音段开始时重新检测语言 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_ForceLanguageDetectionOnNewSegment") + + // 【新增】启用句子级别的语言检测 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableLanguageDetectionPerSentence") + + // 【新增】设置语言检测的频率 - 每个识别结果后都检测 + speechConfig.setPropertyTo("PerResult", byName: "SpeechServiceConnection_LanguageDetectionFrequency") + + // 【新增】启用实时语言检测,减少缓冲延迟 + speechConfig.setPropertyTo("true", byName: "SpeechServiceConnection_EnableRealtimeLanguageDetection") + + // 【新增】设置语言检测的采样间隔为200毫秒 + speechConfig.setPropertyTo("200", byName: "SpeechServiceConnection_LanguageDetectionSamplingIntervalMs") + } + + os_log("翻译场景优化配置已应用,包含语言检测优化", log: log, type: .info) + } + + /** + * 动态切换语言(翻译场景中的语言切换) + * + * @param newSourceLanguage 新的源语言 + * @param newTargetLanguage 新的目标语言 + * @return 切换是否成功 + */ + public func switchTranslationLanguages( + newSourceLanguage: String, + newTargetLanguage: String + ) -> Bool { + let validatedSourceLang = LanguageDetector.convertToAzureLanguageCode(newSourceLanguage) + let validatedTargetLang = LanguageDetector.convertToAzureLanguageCode(newTargetLanguage) + + // 更新支持的语言 + self.supportedLanguages = [validatedSourceLang, validatedTargetLang] + self.currentLanguage = validatedSourceLang + + // 重新配置语言检测 + self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang) + + // 如果正在识别,需要重新设置识别器 + if _isContinuousRecognitionActive { + let wasActive = stopContinuousRecognition() + if wasActive { + return setupRecognizer() + } + } + + os_log("语言已切换: %{public}@ -> %{public}@", log: log, type: .info, validatedSourceLang, validatedTargetLang) + return true + } /** * 设置事件监听器 */ @@ -342,68 +584,42 @@ public class AzureAsrHelper: NSObject { guard let recognizer = recognizer else { return false} // 正在识别事件 recognizer.addRecognizingEventHandler { [weak self] (_, event) in - guard let self = self else { return } + guard let self = self else { return } let result = event.result - print("识别中文本: \(result.text)") - // 优化:只处理非空结果 - if !(result.text?.isEmpty ?? true) { - let detectedLanguage: String - if self.isAutoDetectLanguage { - do { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - let rawLanguage = langResult.language - print("[DEBUG] 原始检测语言: \(rawLanguage ?? "nil"), 文本: \(result.text ?? "nil"), 支持的语言: \(self.supportedLanguages)") - - // 应用启发式语言检测纠正 - detectedLanguage = self.correctLanguageDetection(rawLanguage: rawLanguage ?? "", text: result.text ?? "") - print("[DEBUG] 纠正后语言: \(detectedLanguage)") - } catch { - print("[DEBUG] 语言检测异常: \(error.localizedDescription)") - detectedLanguage = "" - } - } else { - detectedLanguage = self.currentLanguage - print("[DEBUG] 使用当前语言: \(detectedLanguage)") - } - print("识别中事件: \(detectedLanguage)") - DispatchQueue.main.async { - callback.onRecognizing(result.text ?? "", detectedLanguage) - } + guard !(result.text?.isEmpty ?? true) else { return } + + // let detectedLanguage = self.getDetectedLanguage(from: result) + + // 检测文本语言,避免重复调用 + let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" + + // 输出检测到的语言信息 + print("检测到的语言: \(detectedLanguage)") + + DispatchQueue.main.async { + callback.onRecognizing(result.text ?? "", detectedLanguage) } } // 识别完成事件 recognizer.addRecognizedEventHandler { [weak self] (_, event) in - guard let self = self else { return } + guard let self = self else { return } let result = event.result - print("识别完成文本: \(result.text)") - if result.reason == .recognizedSpeech && !(result.text?.isEmpty ?? true) { - let detectedLanguage: String - if self.isAutoDetectLanguage { - do { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - let rawLanguage = langResult.language - print("[DEBUG] 识别完成 - 原始检测语言: \(rawLanguage ?? "nil"), 文本: \(result.text ?? "nil"), 支持的语言: \(self.supportedLanguages)") - - // 应用启发式语言检测纠正 - let correctedLanguage = self.correctLanguageDetection(rawLanguage: rawLanguage ?? "", text: result.text ?? "") - detectedLanguage = correctedLanguage.isEmpty ? (self.supportedLanguages.first ?? self.currentLanguage) : correctedLanguage - print("[DEBUG] 识别完成 - 纠正后语言: \(detectedLanguage)") - } catch { - print("[DEBUG] 识别完成 - 语言检测异常: \(error.localizedDescription)") - detectedLanguage = self.supportedLanguages.first ?? self.currentLanguage - } - } else { - detectedLanguage = self.currentLanguage - print("[DEBUG] 识别完成 - 使用当前语言: \(detectedLanguage)") - } - print("识别完成事件: \(detectedLanguage)") - DispatchQueue.main.async { - callback.onResult(result.text ?? "", detectedLanguage) - } + guard result.reason == .recognizedSpeech, + !(result.text?.isEmpty ?? true) else { return } + + // 检测文本语言,避免重复调用 + let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" + + // 输出检测到的语言信息 + print("检测到的语言: \(detectedLanguage)") + + // 在主线程回调结果 + DispatchQueue.main.async { + callback.onResult(result.text ?? "", detectedLanguage) } - } + } // 会话开始事件 recognizer.addSessionStartedEventHandler { (sender, event) in // 直接在当前线程调用回调 @@ -416,8 +632,8 @@ public class AzureAsrHelper: NSObject { guard let self = self else { return } print("会话结束事件:") // 直接在当前线程调用回调 - // callback.onSessionStopped() - // self._isContinuousRecognitionActive = false + callback.onSessionStopped() + self._isContinuousRecognitionActive = false //self.stopAudioProcessing() } @@ -532,15 +748,23 @@ public class AzureAsrHelper: NSObject { /** * 初始化 */ - public func initAudioRecord() { + /** + * 初始化音频录制组件 + * 包括音频格式、推流、音频引擎等核心组件的初始化 + */ + public func initAudioRecord() { print("初始化了") audioFormat = getOptimalAudioFormat() pushAudioStream = SPXPushAudioInputStream() - isRunning=true + + // 初始化音频引擎 + audioEngine = AVAudioEngine() + + isRunning = true // 创建新的写线程 - writeThread = DispatchQueue(label: "audio.stream.writer") + writeThread = DispatchQueue(label: "audio.stream.writer") startWriteThread() - self.setAudioOutputRoute(.receiver) + self.setAudioOutputRoute(.receiver) } /// 获取最佳音频格式 (iOS 通常支持标准采样率) @@ -584,9 +808,14 @@ public class AzureAsrHelper: NSObject { guard let dataToWrite = self.writeQueue.take() else { continue } - print("写入数据长度: \(dataToWrite.count)") - self.pushAudioStream?.write(dataToWrite) - self.onAudioData?(dataToWrite) + // print("写入数据长度: \(dataToWrite.count)") + do { + try self.pushAudioStream?.write(dataToWrite) + self.onAudioData?(dataToWrite) +} catch { + print("推送音频数据失败: \(error.localizedDescription)") +} + } } } @@ -601,70 +830,97 @@ public class AzureAsrHelper: NSObject { if audioSourceType != .external { return } - print("外部data=\(data)") + // print("外部data=\(data)") // 放入队列,由写线程写入 writeQueue.put(data) } - private func runMicrophoneCapture() { - do { - audioEngine = AVAudioEngine() - guard let inputNode = audioEngine?.inputNode else { - throw NSError(domain: "AudioSetup", code: 1) - } - // 推荐直接用系统 format - let hardwareFormat = inputNode.inputFormat(forBus: 0) - // // ===== 新增:启用专业级语音处理 ===== - if #available(iOS 13.0, *) { - try inputNode.setVoiceProcessingEnabled(true) - print("Voice processing enabled") - - } - - - // Create converter to target format - guard let targetFormat = audioFormat, - let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else { - throw NSError(domain: "AudioSetup", code: 2) - } - - inputNode.installTap(onBus: 0, bufferSize: UInt32(bufferSize), format: hardwareFormat) { - [weak self] buffer, time in - guard let self = self, self.isWriting else { return } - - // Convert to target format - let convertedBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: AVAudioFrameCount(targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate) - )! - ///print("进入 tap 回调,frameLength: \(buffer.frameLength)") - var error: NSError? - let status = converter.convert( - to: convertedBuffer, - error: &error, - withInputFrom: { inNumPackets, outStatus in - outStatus.pointee = .haveData - return buffer - } - ) - //print("转换状态: \(status.rawValue), 错误: \(String(describing: error))") - - if status == .haveData, error == nil { - let data = self.audioBufferToData(convertedBuffer) - // print("准备写入数据,大小: \(data.count)") - - self.writeQueue.put(data) - - } +// 私有方法:启动麦克风捕获 +/** + * 启动麦克风捕获 + * 优化:复用已初始化的音频引擎,减少启动延迟 + */ +private func runMicrophoneCapture() { + do { + // 【优化】检查音频引擎是否已初始化,避免重复创建 + if audioEngine == nil { + audioEngine = AVAudioEngine() + } + + // 如果音频引擎正在运行,先停止 + if audioEngine?.isRunning == true { + audioEngine?.stop() + } + + // 获取音频输入节点(麦克风) + guard let inputNode = audioEngine?.inputNode else { + throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"]) + } + + // 移除之前的音频处理块,避免重复添加 + inputNode.removeTap(onBus: 0) + + // 获取硬件支持的原始音频格式 + let hardwareFormat = inputNode.inputFormat(forBus: 0) + + // iOS 13+ 启用语音处理 + if #available(iOS 13.0, *) { + try inputNode.setVoiceProcessingEnabled(true) + } + + // 检查目标音频格式和转换器是否可用 + guard let targetFormat = audioFormat, // 外部定义的期望音频格式 + let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else { + throw NSError(domain: "AudioSetup", code: 2) + } + + // 在输入节点上安装录音回调 + inputNode.installTap(onBus: 0, + bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小 + format: hardwareFormat) { // 使用原始硬件格式 + [weak self] buffer, time in // 弱引用避免循环引用 + + // 确保实例存在且正在写入状态 + guard let self = self, self.isWriting else { return } + + // 创建目标格式的音频缓冲区 + let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + // 计算转换后的帧容量(考虑采样率差异) + frameCapacity: AVAudioFrameCount( + targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate + ) + )! + + var error: NSError? + // 执行音频格式转换 + let status = converter.convert( + to: convertedBuffer, + error: &error, + withInputFrom: { inNumPackets, outStatus in + outStatus.pointee = .haveData // 标记有数据可用 + return buffer // 返回原始音频数据 } - // 重新激活 - try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) + ) - try audioEngine?.start() - } catch { - print("麦克风启动失败: \(error)") + // 转换成功且无错误 + if status == .haveData, error == nil { + // 将音频缓冲区转换为二进制数据 + let data = self.audioBufferToData(convertedBuffer) + // 将数据放入写入队列(后续处理) + self.writeQueue.put(data) } } + + // 激活音频会话(允许录音) + try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) + + // 启动音频引擎 + try audioEngine?.start() + } catch { + print("麦克风启动失败: \(error)") + } +} private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data { let frameLength = Int(buffer.frameLength) let channelCount = 1 @@ -870,55 +1126,7 @@ public class AzureAsrHelper: NSObject { } } - /** - * 启发式语言检测纠正方法 - * 基于文本内容特征来纠正Azure语音服务的语言检测结果 - */ - private func correctLanguageDetection(rawLanguage: String, text: String) -> String { - // 如果原始检测为空,返回空 - if rawLanguage.isEmpty || text.isEmpty { - return rawLanguage - } - - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - - // 英文特征检测 - let englishPattern = "^[a-z\\s\\.,!?;:'-]+$" - let englishRegex = try? NSRegularExpression(pattern: englishPattern, options: .caseInsensitive) - let englishMatches = englishRegex?.numberOfMatches(in: trimmedText, options: [], range: NSRange(location: 0, length: trimmedText.count)) ?? 0 - - // 中文特征检测 - let chinesePattern = "[\\u4e00-\\u9fff]" - let chineseRegex = try? NSRegularExpression(pattern: chinesePattern, options: []) - let chineseMatches = chineseRegex?.numberOfMatches(in: trimmedText, options: [], range: NSRange(location: 0, length: trimmedText.count)) ?? 0 - - // 常见英文单词列表 - let commonEnglishWords = ["hello", "hi", "please", "thank", "you", "yes", "no", "good", "morning", "afternoon", "evening", "night", "how", "are", "what", "where", "when", "why", "who", "can", "could", "would", "should", "will", "the", "and", "or", "but", "if", "then", "this", "that", "these", "those"] - - let hasCommonEnglishWords = commonEnglishWords.contains { word in - trimmedText.contains(word) - } - - print("[DEBUG] 语言纠正分析 - 文本: '\(trimmedText)', 英文匹配: \(englishMatches > 0), 中文字符: \(chineseMatches), 常见英文词: \(hasCommonEnglishWords), 原始语言: \(rawLanguage)") - - // 纠正逻辑 - if rawLanguage == "zh-CN" { - // 如果Azure检测为中文,但文本明显是英文特征 - if (englishMatches > 0 && chineseMatches == 0) || hasCommonEnglishWords { - print("[DEBUG] 纠正: \(rawLanguage) -> en-US (检测到英文特征)") - return "en-US" - } - } else if rawLanguage == "en-US" { - // 如果Azure检测为英文,但包含中文字符 - if chineseMatches > 0 { - print("[DEBUG] 纠正: \(rawLanguage) -> zh-CN (检测到中文字符)") - return "zh-CN" - } - } - - // 如果没有明确的纠正理由,保持原始检测结果 - return rawLanguage - } + // MARK: - 回调函数 /** diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift new file mode 100644 index 000000000..e22513510 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/LanguageDetector.swift @@ -0,0 +1,214 @@ +import Foundation +import NaturalLanguage + +/** + * 语言检测工具类 + * 使用iOS自带的自然语言处理框架检测文本语种 + */ +public class LanguageDetector { + + /** + * 检测文本的主要语言 + * + * @param text 需要检测的文本 + * @return 语言代码(如:"zh-Hans", "en", "ja"等),如果检测失败返回nil + */ + public static func detectLanguage(from text: String) -> String? { + // 检查输入文本是否为空 + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + + // 使用NLLanguageRecognizer进行语言检测 + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + // 获取最可能的语言 + guard let dominantLanguage = recognizer.dominantLanguage else { + return nil + } + + return dominantLanguage.rawValue + } + + /** + * 检测文本的语言并返回置信度 + * + * @param text 需要检测的文本 + * @return 语言代码和置信度的字典,按置信度降序排列 + */ + public static func detectLanguageWithConfidence(from text: String) -> [(language: String, confidence: Double)] { + // 检查输入文本是否为空 + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + // 获取语言假设及其置信度 + let languageHypotheses = recognizer.languageHypotheses(withMaximum: 5) + + // 转换为元组数组并按置信度排序 + return languageHypotheses.map { (language: $0.key.rawValue, confidence: $0.value) } + .sorted { $0.confidence > $1.confidence } + } + + /** + * 将语言代码转换为人类可读的语言名称 + * + * @param languageCode 语言代码(如:"zh-Hans", "en"等) + * @return 语言的本地化名称 + */ + public static func getLanguageName(from languageCode: String) -> String { + let locale = Locale.current + return locale.localizedString(forLanguageCode: languageCode) ?? languageCode + } + + /** + * 检测文本语言并返回Azure语音服务支持的语言代码格式 + * + * @param text 需要检测的文本 + * @return Azure支持的语言代码格式(如:"zh-CN", "en-US"等) + */ + public static func detectLanguageForAzure(from text: String) -> String? { + guard let detectedLanguage = detectLanguage(from: text) else { + return nil + } + + // 将iOS语言代码转换为Azure语音服务支持的格式 + return convertToAzureLanguageCode(detectedLanguage) + } + + /** + * 将iOS语言代码转换为Azure语音服务支持的语言代码 + * + * @param iosLanguageCode iOS检测到的语言代码 + * @return Azure支持的语言代码 + */ + public static func convertToAzureLanguageCode(_ iosLanguageCode: String) -> String { + let languageMapping: [String: String] = [ + // 中文 + "zh-Hans": "zh-CN", // 简体中文 + "zh-Hant": "zh-TW", // 繁体中文 + "zh-HK": "zh-HK", // 香港中文 + + // 英语系 + "en": "en-US", // 英语(美国) + "en-GB": "en-US", // 英语(英国) + "en-AU": "en-US", // 英语(澳大利亚) + "en-CA": "en-US", // 英语(加拿大) + "en-IN": "en-US", // 英语(印度) + "en-IE": "en-US", // 英语(爱尔兰) + "en-NZ": "en-US", // 英语(新西兰) + "en-ZA": "en-US", // 英语(南非) + + // 亚洲语言 + "ja": "ja-JP", // 日语 + "ko": "ko-KR", // 韩语 + "hi": "hi-IN", // 印地语 + "th": "th-TH", // 泰语 + "vi": "vi-VN", // 越南语 + "id": "id-ID", // 印尼语 + "ms": "ms-MY", // 马来语 + "tl": "fil-PH", // 菲律宾语 + "my": "my-MM", // 缅甸语 + "km": "km-KH", // 高棉语(柬埔寨) + "lo": "lo-LA", // 老挝语 + "si": "si-LK", // 僧伽罗语(斯里兰卡) + "ta": "ta-IN", // 泰米尔语 + "te": "te-IN", // 泰卢固语 + "bn": "bn-IN", // 孟加拉语 + "gu": "gu-IN", // 古吉拉特语 + "kn": "kn-IN", // 卡纳达语 + "ml": "ml-IN", // 马拉雅拉姆语 + "mr": "mr-IN", // 马拉地语 + "pa": "pa-IN", // 旁遮普语 + "ur": "ur-PK", // 乌尔都语 + + // 欧洲语言 + "fr": "fr-FR", // 法语(法国) + "fr-CA": "fr-CA", // 法语(加拿大) + "fr-BE": "fr-BE", // 法语(比利时) + "fr-CH": "fr-CH", // 法语(瑞士) + "de": "de-DE", // 德语(德国) + "de-AT": "de-AT", // 德语(奥地利) + "de-CH": "de-CH", // 德语(瑞士) + "es": "es-ES", // 西班牙语(西班牙) + "es-MX": "es-MX", // 西班牙语(墨西哥) + "es-AR": "es-AR", // 西班牙语(阿根廷) + "es-CO": "es-CO", // 西班牙语(哥伦比亚) + "es-CL": "es-CL", // 西班牙语(智利) + "es-PE": "es-PE", // 西班牙语(秘鲁) + "es-VE": "es-VE", // 西班牙语(委内瑞拉) + "it": "it-IT", // 意大利语 + "pt": "pt-BR", // 葡萄牙语(巴西) + "pt-PT": "pt-PT", // 葡萄牙语(葡萄牙) + "ru": "ru-RU", // 俄语 + "nl": "nl-NL", // 荷兰语 + "nl-BE": "nl-BE", // 荷兰语(比利时) + "sv": "sv-SE", // 瑞典语 + "da": "da-DK", // 丹麦语 + "no": "nb-NO", // 挪威语 + "fi": "fi-FI", // 芬兰语 + "is": "is-IS", // 冰岛语 + "pl": "pl-PL", // 波兰语 + "cs": "cs-CZ", // 捷克语 + "sk": "sk-SK", // 斯洛伐克语 + "hu": "hu-HU", // 匈牙利语 + "ro": "ro-RO", // 罗马尼亚语 + "bg": "bg-BG", // 保加利亚语 + "hr": "hr-HR", // 克罗地亚语 + "sr": "sr-RS", // 塞尔维亚语 + "sl": "sl-SI", // 斯洛文尼亚语 + "et": "et-EE", // 爱沙尼亚语 + "lv": "lv-LV", // 拉脱维亚语 + "lt": "lt-LT", // 立陶宛语 + "uk": "uk-UA", // 乌克兰语 + "be": "be-BY", // 白俄罗斯语 + "mk": "mk-MK", // 马其顿语 + "sq": "sq-AL", // 阿尔巴尼亚语 + "mt": "mt-MT", // 马耳他语 + "ga": "ga-IE", // 爱尔兰语 + "cy": "cy-GB", // 威尔士语 + "eu": "eu-ES", // 巴斯克语 + "ca": "ca-ES", // 加泰罗尼亚语 + "gl": "gl-ES", // 加利西亚语 + + // 中东和非洲语言 + "ar": "ar-SA", // 阿拉伯语(沙特阿拉伯) + "ar-EG": "ar-EG", // 阿拉伯语(埃及) + "ar-AE": "ar-AE", // 阿拉伯语(阿联酋) + "ar-BH": "ar-BH", // 阿拉伯语(巴林) + "ar-DZ": "ar-DZ", // 阿拉伯语(阿尔及利亚) + "ar-IQ": "ar-IQ", // 阿拉伯语(伊拉克) + "ar-JO": "ar-JO", // 阿拉伯语(约旦) + "ar-KW": "ar-KW", // 阿拉伯语(科威特) + "ar-LB": "ar-LB", // 阿拉伯语(黎巴嫩) + "ar-LY": "ar-LY", // 阿拉伯语(利比亚) + "ar-MA": "ar-MA", // 阿拉伯语(摩洛哥) + "ar-OM": "ar-OM", // 阿拉伯语(阿曼) + "ar-QA": "ar-QA", // 阿拉伯语(卡塔尔) + "ar-SY": "ar-SY", // 阿拉伯语(叙利亚) + "ar-TN": "ar-TN", // 阿拉伯语(突尼斯) + "ar-YE": "ar-YE", // 阿拉伯语(也门) + "he": "he-IL", // 希伯来语 + "fa": "fa-IR", // 波斯语(伊朗) + "tr": "tr-TR", // 土耳其语 + "az": "az-AZ", // 阿塞拜疆语 + "ka": "ka-GE", // 格鲁吉亚语 + "hy": "hy-AM", // 亚美尼亚语 + "sw": "sw-KE", // 斯瓦希里语 + "am": "am-ET", // 阿姆哈拉语(埃塞俄比亚) + "zu": "zu-ZA", // 祖鲁语(南非) + "af": "af-ZA", // 南非荷兰语 + + // 其他语言 + "el": "el-GR", // 希腊语 + "eo": "eo", // 世界语 + "la": "la", // 拉丁语 + ] + + return languageMapping[iosLanguageCode] ?? iosLanguageCode + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 0e43d9188..f8f7b3db9 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -174,7 +174,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - Log.d(TAG, "Method call: ${call.method}") + // Log.d(TAG, "Method call: ${call.method}") when (call.method) { "isConnected" -> { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index c9d6b2609..e44bd6c61 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -108,7 +108,7 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { case "startScan": let arguments = call.arguments as? [String: Any] - let timeout = (arguments?["timeout"] as? Double) ?? 30.0 + let timeout = (arguments?["timeout"] as? Double) ?? 15.0 os_log("🔍 开始传统BLE扫描,超时: %.1f秒", log: logger, type: .info, timeout) BleService.shared.startScan(timeout: timeout) diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift index 1f594bd63..a07e91f72 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift @@ -237,4 +237,4 @@ class SwiftOpusAudioProcessor: NSObject { func getCurrentConfig() -> (sampleRate: Int32, channels: Int32) { return (sampleRate, channels) } -} \ No newline at end of file +} diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift index 3f0caeab3..b65a7aaa4 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift @@ -28,6 +28,7 @@ public protocol FunctionHandler { /// - 安全的JSON-RPC消息解析,包含错误修复逻辑 /// - 完整的SSE连接生命周期管理 /// - 与Android端CustomSseClientTransport功能对等 +// 在 MCPSubClient 类中添加工具调用超时和重试配置 public class MCPSubClient { private let serverId: String private let serverUrl: String @@ -47,6 +48,10 @@ public class MCPSubClient { private var retryCount = 0 private var reconnectTask: Task? + // 工具调用配置 - 添加这两行 + private let maxToolCallRetries = 3 + private let toolCallTimeout: TimeInterval = 30.0 + // 连接状态锁 private let connectionLock = NSLock() @@ -178,27 +183,55 @@ public class MCPSubClient { /// 调用工具前检查连接 public func callTool(name: String, arguments: [String: Any]) async -> [String: Any]? { + return await callToolWithRetry(name: name, arguments: arguments, retryCount: 0) + } + + private func callToolWithRetry(name: String, arguments: [String: Any], retryCount: Int) async -> [String: Any]? { + print("[MCPSubClient] 工具调用尝试 \(retryCount + 1)/\(maxToolCallRetries + 1): \(name)") + // 先检查连接状态 if !(await checkConnection()) { + if retryCount < maxToolCallRetries { + print("[MCPSubClient] 连接断开,尝试重连...") + if await connect() { + print("[MCPSubClient] 重连成功,重试工具调用") + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + } return [ "content": [[ "type": "text", - "text": "Tool call failed: mcp: service not connected" + "text": "工具调用失败,请重试:MCP 服务连接中断" ]], "isError": true ] } guard isConnectedFlag, let client = mcpClient else { - return createErrorResult("Not connected to MCP server") + if retryCount < maxToolCallRetries { + print("[MCPSubClient] MCP 客户端未连接,尝试重连...") + if await connect() { + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + } + return createErrorResult("工具调用失败,请重试:MCP 服务未连接") } do { - let mcpArguments = convertToMCPValue(arguments) - let (content, isError) = try await client.callTool(name: name, arguments: mcpArguments) + // 使用超时控制的工具调用 + let result = try await withTimeout(seconds: toolCallTimeout) { + let mcpArguments = self.convertToMCPValue(arguments) + return try await client.callTool(name: name, arguments: mcpArguments) + } + + let (content, isError) = result if isError == true { - return createErrorResult("Tool execution failed") + if retryCount < maxToolCallRetries { + print("[MCPSubClient] 工具执行失败,重试中...") + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + return createErrorResult("工具调用失败,请重试:工具执行错误") } var contextText = "" @@ -225,13 +258,45 @@ public class MCPSubClient { } } + print("[MCPSubClient] 工具调用成功: \(name)") return [ "content": [["type": "text", "text": contextText.trimmingCharacters(in: .whitespacesAndNewlines)]], "isError": false ] + } catch is TimeoutError { + print("[MCPSubClient] 工具调用超时: \(name)") + if retryCount < maxToolCallRetries { + print("[MCPSubClient] 超时重试中...") + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + return createErrorResult("工具调用失败,请重试:连接超时") } catch { - return createErrorResult("Tool call failed: \(error.localizedDescription)") + print("[MCPSubClient] 工具调用异常: \(name), 错误: \(error.localizedDescription)") + + // 检查是否是连接相关错误 + let errorDescription = error.localizedDescription.lowercased() + if errorDescription.contains("connection") || + errorDescription.contains("network") || + errorDescription.contains("timeout") || + errorDescription.contains("disconnected") { + + if retryCount < maxToolCallRetries { + print("[MCPSubClient] 检测到连接错误,尝试重连并重试...") + // 标记连接断开 + isConnectedFlag = false + + // 尝试重连 + if await connect() { + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + } + } else if retryCount < maxToolCallRetries { + print("[MCPSubClient] 其他错误,直接重试...") + return await callToolWithRetry(name: name, arguments: arguments, retryCount: retryCount + 1) + } + + return createErrorResult("工具调用失败,请重试:\(error.localizedDescription)") } } diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index a2dc14ec2..bcacf4eb5 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -6,6 +6,32 @@ import MessageUI import location_service import user_portrait // 添加这行 +import MediaPlayer +import AVFoundation + +// 在文件顶部添加通知名称定义 +extension Notification.Name { + static let audioSessionBeginPlayback = Notification.Name("AudioSessionBeginPlayback") + static let audioSessionBeginVoice = Notification.Name("AudioSessionBeginVoice") + static let volumeControlRequest = Notification.Name("VolumeControlRequest") +} + +// 音量控制请求的数据结构 +// 移除结构体定义,直接使用 map 传输 +// 删除以下代码: +// struct VolumeControlInfo { +// let action: VolumeAction +// let value: Float? +// +// enum VolumeAction { +// case increase +// case decrease +// case mute +// case setMax +// case setValue(Float) +// } +// } + /// 结果结构体 public struct Result { let success: Bool @@ -66,6 +92,13 @@ public class SystemFunctionHandler: NSObject { // 注册打开录音机功能 registerOpenRecorderFunction(client: client) + // 注册音量控制函数 + // 在registerOpenRecorderFunction方法之后添加以下方法 + registerAddVolumeFunction(client: client) + registerReduceVolumeFunction(client: client) + registerMuteVolumeFunction(client: client) + registerMaxVolumeFunction(client: client) + // 注册用户画像更新功能 registerSetUserProfileFieldFunction(client: client) @@ -311,6 +344,90 @@ public class SystemFunctionHandler: NSObject { handler: handler ) } + + + /// 注册增加音量函数 + private func registerAddVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "add_volume", + description: "增大音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: AddVolumeHandler() + ) + } + + /// 注册减少音量函数 + private func registerReduceVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "reduce_volume", + description: "降低音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: ReduceVolumeHandler() + ) + } + + /// 注册静音函数 + private func registerMuteVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "mute_volume", + description: "设置静音", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: MuteVolumeHandler() + ) + } + + /// 注册最大音量函数 + private func registerMaxVolumeFunction(client: MCPClient) { + client.registerLocalFunction( + name: "max_volume", + description: "设置最大音量", + parameters: [ + "type": "object", + "properties": [:] as [String: Any], + "required": [] as [String] + ], + handler: MaxVolumeHandler() + ) + } + + /// 注册设置用户画像字段函数 + private func registerSetUserProfileFieldFunction(client: MCPClient) { + let parameters: [String: Any] = [ + "type": "object", + "properties": [ + "key": [ + "type": "string", + "description": "画像字段名,例如:age、tone、工作经历、device_preference" + ], + "value": [ + "type": "string", + "description": "字段对应的值" + ] + ], + "required": ["key", "value"] + ] + + let handler = SetUserProfileFieldHandler() + _ = client.registerLocalFunction( + name: "set_user_profile_field", + description: "设置或更新用户画像中的某一个字段", + parameters: parameters, + handler: handler + ) + } + } // MARK: - 功能处理器实现 @@ -678,7 +795,7 @@ private class GetCurrentTimeHandler: FunctionHandler { private class MediaPlayHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { NotificationCenter.default.post(name: NSNotification.Name("MediaPlay"), object: nil) - return "{\"result\": \"已开始播放媒体\"}" + return "{\"notreply\": true,\"result\": \"已开始播放媒体\"}" } } @@ -686,7 +803,7 @@ private class MediaPlayHandler: FunctionHandler { private class MediaPauseHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { NotificationCenter.default.post(name: NSNotification.Name("MediaPause"), object: nil) - return "{\"result\": \"已暂停媒体播放\"}" + return "{\"broadcast\": true,\"result\": \"已暂停媒体播放\"}" } } @@ -694,7 +811,7 @@ private class MediaPauseHandler: FunctionHandler { private class MediaPreviousHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { NotificationCenter.default.post(name: NSNotification.Name("MediaPrevious"), object: nil) - return "{\"result\": \"已切换到上一首\"}" + return "{\"notreply\": true,\"result\": \"已切换到上一首\"}" } } @@ -702,7 +819,7 @@ private class MediaPreviousHandler: FunctionHandler { private class MediaNextHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { NotificationCenter.default.post(name: NSNotification.Name("MediaNext"), object: nil) - return "{\"result\": \"已切换到下一首\"}" + return "{\"notreply\": true,\"result\": \"已切换到下一首\"}" } } @@ -716,7 +833,7 @@ private class OpenRecorderHandler: FunctionHandler { } } - return "{\"result\": \"无法打开录音机应用\"}" + return "{\"notreply\": true,\"result\": \"无法打开录音机应用\"}" } } @@ -725,31 +842,7 @@ private class OpenRecorderHandler: FunctionHandler { // ... // } - /// 注册设置用户画像字段函数 - private func registerSetUserProfileFieldFunction(client: MCPClient) { - let parameters: [String: Any] = [ - "type": "object", - "properties": [ - "key": [ - "type": "string", - "description": "画像字段名,例如:age、tone、工作经历、device_preference" - ], - "value": [ - "type": "string", - "description": "字段对应的值" - ] - ], - "required": ["key", "value"] - ] - - let handler = SetUserProfileFieldHandler() - _ = client.registerLocalFunction( - name: "set_user_profile_field", - description: "设置或更新用户画像中的某一个字段", - parameters: parameters, - handler: handler - ) - } + /// 设置用户画像字段处理器 private class SetUserProfileFieldHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { @@ -799,3 +892,67 @@ private class SetUserProfileFieldHandler: FunctionHandler { } } } + +private class AddVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + // 使用 map 传输数据 + let volumeInfo: [String: Any] = [ + "action": "increase", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"broadcast\": false,\"result\": \"音量增加请求已发送\"}" + } +} + +/// 减少音量处理器 +private class ReduceVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "decrease", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"broadcast\": false,\"result\": \"音量降低请求已发送\"}" + } +} + +/// 静音处理器 +private class MuteVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "mute", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"broadcast\": false,\"result\": \"静音请求已发送\"}" + } +} + +/// 最大音量处理器 +private class MaxVolumeHandler: FunctionHandler { + func handle(arguments: [String: Any]) async throws -> String { + let volumeInfo: [String: Any] = [ + "action": "setMax", + "value": NSNull() + ] + NotificationCenter.default.post( + name: .volumeControlRequest, + object: volumeInfo + ) + + return "{\"broadcast\": false,\"result\": \"最大音量请求已发送\"}" + } +} diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt index 4c23455b1..3c90cfabc 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt @@ -25,7 +25,7 @@ class LocationServicePlugin: FlutterPlugin, MethodCallHandler, ActivityAware, Pl } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - Log.d(TAG, "Method call: ${call.method}") + // Log.d(TAG, "Method call: ${call.method}") when (call.method) { "location" -> LocationService.getCurrentLocation(result) "requestPermission" -> LocationService.requestLocationPermission(activityBinding?.activity, result) diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt index 9d5f1043b..3bfaa1e1b 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -24,7 +24,7 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler { } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - Log.d(TAG, "Method call: ${call.method}") + // Log.d(TAG, "Method call: ${call.method}") when (call.method) { "play" -> { val song = call.argument>("song") diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index f4c99cd67..462a3f53d 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift @@ -13,7 +13,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - print("Method call: \(call.method)") + // print("Method call: \(call.method)") switch call.method { case "play": diff --git a/pubspec.yaml b/pubspec.yaml index 17c616de7..ffa83d643 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.3+11 +version: 1.0.4+20 environment: sdk: ">=3.3.0 <4.0.0" @@ -27,6 +27,7 @@ dependencies: parchment: ^1.24.0 htmltopdfwidgets: ^1.1.0 wifi_iot: ^0.3.19+2 + flutter_intro: ^3.4.0 permission_handler: ^10.4.5 web_socket_channel: ^2.4.0 flutter_dotenv: ^5.1.0 @@ -59,7 +60,7 @@ dependencies: font_awesome_flutter: ^10.8.0 lottie: ^3.3.1 app_settings: 5.2.0 - fluwx: ^5.4.1 + fluwx: ^5.7.1 url_launcher: ^6.3.1 firebase_core: ^3.12.1 firebase_auth: ^5.5.1