diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 127288e55..ebb128fb5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,8 @@ + + diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index 7dca392ef..45071ebdd 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -487,15 +487,19 @@ const Map enUS = { // 权限管理页面 'getPermissionStatusFailed': 'Failed to get permission status', 'communicationPermissions': 'Communication Permissions', + 'alarmPermissions': 'Alarm Permissions', 'calendarPermissions': 'Calendar Permissions', 'locationPermissions': 'Location Permissions', 'phone': 'Phone', 'sms': 'SMS', + 'alarm': 'Alarm', 'calendarWrite': 'Calendar Write', 'calendarFullAccess': 'Calendar Full Access', 'locationInfo': 'Location Info', 'phonePermissionDesc': 'Used for phone call functionality', 'smsPermissionDesc': 'Used for SMS functionality', + 'alarmPermissionDesc': + 'Used to set exact alarms to ensure reminders trigger on time', 'calendarWritePermissionDesc': 'Used for creating calendar events and reminders', 'calendarFullAccessPermissionDesc': @@ -512,6 +516,11 @@ const Map enUS = { 'phonePermissionDetailDesc': 'Phone call functionality requires phone permission', 'smsPermissionDetailDesc': 'SMS functionality requires SMS permission', + 'alarmPermissionDetailDesc': + 'This permission is required to set exact alarms and ensure reminders trigger on time', + 'contactsPermissionDesc': 'Used for accessing contact information', + 'contactsPermissionDetailDesc': + 'Phone call and SMS functionality requires contacts permission to select contacts', 'calendarWritePermissionDetailDesc': 'Creating calendar events requires calendar write permission', 'calendarFullAccessPermissionDetailDesc': diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index d73546416..986e8cd14 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -455,15 +455,18 @@ const Map zhCN = { // 权限管理页面 'getPermissionStatusFailed': '获取权限状态失败', 'communicationPermissions': '通讯权限', + 'alarmPermissions': '闹钟权限', 'calendarPermissions': '日历权限', 'locationPermissions': '位置权限', 'phone': '电话', 'sms': '短信', + 'alarm': '闹钟', 'calendarWrite': '日历写入', 'calendarFullAccess': '日历完整访问', 'locationInfo': '位置信息', 'phonePermissionDesc': '用于拨打电话功能', 'smsPermissionDesc': '用于发送短信功能', + 'alarmPermissionDesc': '用于设置精确闹钟以确保提醒按时触发', 'calendarWritePermissionDesc': '用于创建日历事件和提醒', 'calendarFullAccessPermissionDesc': '用于读取和管理日历事件及提醒', 'locationPermissionDesc': '用于获取当前位置信息', @@ -477,9 +480,12 @@ const Map zhCN = { 'permissionRequestError': '请求权限时出错', 'phonePermissionDetailDesc': '拨打电话功能需要电话权限', 'smsPermissionDetailDesc': '发送短信功能需要短信权限', + 'alarmPermissionDetailDesc': '设置精确闹钟需要此权限,以确保提醒按时触发', 'calendarWritePermissionDetailDesc': '创建日历事件需要日历写入权限', 'calendarFullAccessPermissionDetailDesc': '管理日历事件需要完整日历权限', 'locationPermissionDetailDesc': '获取位置信息需要位置权限', + 'contactsPermissionDesc': '用于访问联系人信息', + 'contactsPermissionDetailDesc': '拨打电话和发送短信功能需要联系人权限以选择联系人', 'appNeedsPermissionForFullFunction': '应用需要此权限以提供完整功能', 'permanentDenialText': '您已永久拒绝{permission}权限,请前往应用设置开启', 'goToSettings': '去设置', diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index 920737ae2..fec06693e 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -7,6 +7,7 @@ class IntroOverlayBuilder { static Widget homeBuilder( StepWidgetParams params, bool isWakeup, { + String title = '对着麦克风说:“小言小言”', Function? onNext, }) { return Column( @@ -21,7 +22,7 @@ class IntroOverlayBuilder { Padding( padding: EdgeInsets.only(bottom: 20.w), child: Text( - isWakeup ? '我在呢~' : '对着麦克风说:“小言小言”', + isWakeup ? '我在呢~' : title, style: TextStyle( fontSize: 15.sp, color: Colors.white, @@ -30,12 +31,27 @@ class IntroOverlayBuilder { ), ), if (onNext != null) - _button( - '下一步', - () { - params.onNext!(); - onNext(); - }, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(right: 50.w), + child: _button( + '下一步', + () { + params.onNext!(); + onNext(); + }, + ), + ), + _button( + '跳过', + () { + params.onFinish(); + }, + isSkip: true, + ), + ], ), ], ); @@ -82,17 +98,43 @@ class IntroOverlayBuilder { onFinish(); }, ), + if (onNext != null || onFinish != null) const Spacer(), + _button( + '跳过', + () { + params.onFinish(); + }, + isSkip: true, + ), ], ), ], ); } - static Widget _button(String text, Function() onTap) { - return _AnimatedBorderButton( - text: text, - onTap: onTap, - ); + static Widget _button(String text, Function() onTap, {bool isSkip = false}) { + return isSkip + ? GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.white), + ), + child: Text( + text, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + ), + ), + ), + ) + : _AnimatedBorderButton( + text: text, + onTap: onTap, + ); } } @@ -207,7 +249,7 @@ class _AnimatedBorderButtonState extends State<_AnimatedBorderButton> borderRadius: 20.r, ), child: Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20.r), border: Border.all(color: Colors.transparent), diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index d32be2368..c58b1a312 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -4,6 +4,8 @@ import 'package:ble_service/ble_service.dart'; import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../../core/utils/permission_util.dart'; class NoviceGuideService extends GetxService { final GetStorage _storage = GetStorage(); @@ -24,44 +26,109 @@ class NoviceGuideService extends GetxService { 'input': '今天天气怎么样', 'send': '小言会为你实时为你播报天气', 'end': '小言能实时为你播报天气,试试其他指令吧', + 'method': () async { + // 天气查询需要位置权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.location, + permissionName: 'locationInfo'.tr, // 位置信息 + explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 + permanentDenialText: + 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + ); + }, }, { 'start': '对小言说:“请播放一组摇滚乐”', 'input': '请播放一组摇滚乐', 'send': '小言会为你搜寻到一组音乐', 'end': '小言已成功为您搜寻到一组音乐,试试其他指令吧', + 'method': () async {}, }, { 'start': '对小言说:“导航到深圳机场”', 'input': '导航到深圳机场', 'send': '小言会为你规划路线', 'end': '小言已成功为您导航到深圳机场,试试其他指令吧', + 'method': () async { + // 导航功能需要位置权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.location, + permissionName: 'locationInfo'.tr, // 位置信息 + explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 + permanentDenialText: + 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + ); + }, }, { 'start': '对小言说:“明天下午3点创建会议提醒”', 'input': '明天下午3点创建会议提醒', 'send': '小言会为你创建会议提醒', 'end': '小言已成功为您创建会议提醒,试试其他指令吧', + 'method': () async { + //创建会议提醒需要日历权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.calendarWriteOnly, + permissionName: 'calendarWrite'.tr, // 日历写入 + explanationText: 'calendarWritePermissionDesc'.tr, // 用于创建日历事件和提醒 + permanentDenialText: + 'calendarWritePermissionDetailDesc'.tr, // 创建日历事件需要日历写入权限 + ); + }, }, { 'start': '对小言说:“拨打妈妈的电话”', 'input': '拨打妈妈的电话', 'send': '小言会为你拨打电话', 'end': '小言已成功为您拨打电话,试试其他指令吧', + 'method': () async { + // 拨打电话需要电话权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.phone, + permissionName: 'phone'.tr, // 电话 + explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能 + permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限 + ); + //拨打电话还需要联系人权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.contacts, + permissionName: 'contacts'.tr, // 联系人 + explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 + permanentDenialText: + 'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限 + ); + }, }, { 'start': '对小言说:“给爸爸发短信说晚上回家吃饭’”', 'input': '给爸爸发短信说晚上回家吃饭’', 'send': '小言会为你发送短信', 'end': '短信已发送,语音操控更便捷', + 'method': () async { + // 发送短信需要短信权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.sms, + permissionName: 'sms'.tr, // 短信 + explanationText: 'smsPermissionDesc'.tr, // 用于发送短信功能 + permanentDenialText: 'smsPermissionDetailDesc'.tr, // 发送短信功能需要短信权限 + ); + //发送短信还需要联系人权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.contacts, + permissionName: 'contacts'.tr, // 联系人 + explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 + permanentDenialText: + 'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限 + ); + }, }, ]; @override void onInit() { super.onInit(); - // isHomeGuide = _storage.read('home_guide') ?? false; - // isAIGuide = _storage.read('ai_guide') ?? false; + isHomeGuide = _storage.read('home_guide') ?? false; + isAIGuide = _storage.read('ai_guide') ?? false; } @override diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 82e7696e1..71d4be63b 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 'package:flutter_intro/flutter_intro.dart'; import '../../../data/services/ble_manager.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/novice_guide_service.dart'; @@ -123,6 +124,7 @@ class AgentController extends GetxController { Timer? _progressTimer; // 用于跟踪AI回复状态 bool _isNewAssistantResponse = true; + RxBool newAIReplyCompleted = true.obs; String _currentResponseId = ''; final TAG = 'AgentController'; @@ -158,6 +160,7 @@ class AgentController extends GetxController { _setupScrollListener(); //设置滚动监听器 _loadChatHistory(); //加载聊天历史记录 _subscribeToEvents(); //订阅代理服务事件 + // _subscribeToBleEvents(); //订阅BLE事件 _initMusicStatus(); //初始化音乐状态 _setupMediaSessionCallbacks(); //设置媒体会话回调 _setupTextInputListener(); //设置文本输入监听器 @@ -207,6 +210,7 @@ class AgentController extends GetxController { _eventSubscription?.cancel(); _amapeventSubscription?.cancel(); _bleEventSubscription?.cancel(); + Intro.of(Get.context!).dispose(); super.onClose(); } @@ -635,6 +639,7 @@ class AgentController extends GetxController { isImageProcessing.value = false; // 标记当前回复完成 _isNewAssistantResponse = true; + newAIReplyCompleted.value = true; break; case AgentServiceEventType.error: @@ -892,6 +897,7 @@ class AgentController extends GetxController { isProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; + newAIReplyCompleted.value = false; // 根据TTS状态决定是否朗读 await AgentService.processTextInput(text, speakResponse: isTtsEnabled.value); @@ -1336,7 +1342,7 @@ class AgentController extends GetxController { // 打电话模式 isTextInputMode.value = false; Logger.i(TAG, '打电话模式已启动,开始语音输入'); - await AgentService.startConversation(); + await AgentService.startConversation(mode: 'phone_call'); } // 结束打电话模式 @@ -1388,7 +1394,7 @@ class AgentController extends GetxController { } else { // 当前未录音,开始录音 Logger.i(TAG, '开始打电话模式录音'); - final result = await AgentService.startConversation(); + final result = await AgentService.startConversation(mode: 'phone_call'); // 手动更新状态,确保UI同步 if (result) { @@ -1492,7 +1498,7 @@ class AgentController extends GetxController { isPushToTalkActive.value = true; //await AgentService.recognizeCallback(); // 开始语音识别 - await AgentService.startConversation(); + await AgentService.startConversation(mode: 'push_to_talk'); // await startVoiceInput(); } @@ -1756,21 +1762,52 @@ class AgentController extends GetxController { // 打开图片查看器 void openImageViewer(BuildContext context, String imagePath) { + // 构建完整的图片路径 + String fullImagePath; + if (imagePath.startsWith('/')) { + // 如果是相对路径(以/开头),需要拼接AppDir.value + fullImagePath = '${AppDir.value}$imagePath'; + } else { + // 如果已经是完整路径,直接使用 + fullImagePath = imagePath; + } + Navigator.push( context, MaterialPageRoute( builder: (context) => Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData(color: Colors.white), - ), + // appBar: AppBar( + // backgroundColor: Colors.black, + // leading: IconButton( + // icon: Icon(Icons.arrow_back_ios, color: Colors.white), + // onPressed: () => Navigator.of(context).pop(), + // ), + // iconTheme: IconThemeData(color: Colors.white), + // //title: Text('图片预览', style: TextStyle(color: Colors.white)), + // ), body: Container( color: Colors.black, - child: PhotoView( - imageProvider: FileImage(File(imagePath)), - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 2, - backgroundDecoration: BoxDecoration(color: Colors.black), + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), // 点击退出 + + child: PhotoView( + imageProvider: FileImage(File(fullImagePath)), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + backgroundDecoration: BoxDecoration(color: Colors.black), + // errorBuilder: (context, error, stackTrace) { + // return const Center( + // child: Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Icon(Icons.error, color: Colors.white, size: 64), + // SizedBox(height: 16), + // Text('图片加载失败', style: TextStyle(color: Colors.white)), + // ], + // ), + // ); + // }, + ), ), ), ), diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 849baa4cc..c78eb62d3 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -193,62 +193,108 @@ class _AgentViewState extends State with WidgetsBindingObserver { ), body: Stack( children: [ + IntroStepBuilder( + group: 'ai', + order: 3, + getOverlayPosition: ({ + required Offset offset, + required Size screenSize, + required Size size, + }) { + return OverlayPosition( + top: 200.h, + width: screenSize.width, + crossAxisAlignment: CrossAxisAlignment.end, + ); + }, + overlayBuilder: (params) { + return Obx( + () => IntroOverlayBuilder.homeBuilder( + params, + false, + title: controller.newAIReplyCompleted.value + ? '小言已经完成了回答!' + : '小言正在思考中~', + onNext: controller.newAIReplyCompleted.value ? () {} : null, + ), + ); + }, + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + ), + ), // 主要聊天界面 Column( children: [ // 聊天内容 Expanded( - child: Stack( - children: [ - IntroStepBuilder( - group: 'ai', - order: 3, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - controller.noviceGuideService.aiStepsList[ - controller.noviceGuideService.aiStep]['end'], - onFinish: () async { - if (controller.noviceGuideService.aiStep < 5) { - await Future.delayed( - const Duration(milliseconds: 300)); - controller.noviceGuideService.aiStep++; - Intro.of(Get.context!) - .start(group: 'ai', reset: true); - } - }, - ); - }, - builder: (BuildContext context, GlobalKey key) => Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - key: key, - width: double.infinity, - height: 430.w, - ), - ), - ), - Obx( - () => Align( - alignment: Alignment.topCenter, - child: ListView.builder( - controller: controller.scrollController, - shrinkWrap: true, - reverse: true, - padding: EdgeInsets.symmetric( - vertical: 16.h, horizontal: 16.w), - itemCount: controller.messages.length + - (controller.hasMoreMessages.value ? 1 : 0), - itemBuilder: (context, index) { - // 如果是最后一项且有更多消息,显示加载指示器 - if (index == controller.messages.length && - controller.hasMoreMessages.value) { - return _buildLoadMoreIndicator(); - } + child: Obx(() { + return Align( + alignment: Alignment.topCenter, + child: ListView.builder( + controller: controller.scrollController, + shrinkWrap: true, + reverse: true, + padding: EdgeInsets.symmetric( + vertical: 16.h, horizontal: 16.w), + itemCount: controller.messages.length + + (controller.hasMoreMessages.value ? 1 : 0), + itemBuilder: (context, index) { + // 如果是最后一项且有更多消息,显示加载指示器 + if (index == controller.messages.length && + controller.hasMoreMessages.value) { + return _buildLoadMoreIndicator(); + } - final message = controller.messages[ - controller.messages.length - 1 - index]; - return Padding( + final message = controller + .messages[controller.messages.length - 1 - index]; + return index == 0 + ? IntroStepBuilder( + group: 'ai', + order: 4, + overlayBuilder: (params) { + return IntroOverlayBuilder.aiBuilder( + params, + controller.noviceGuideService.aiStepsList[ + controller + .noviceGuideService.aiStep]['end'], + onFinish: () async { + if (controller.noviceGuideService.aiStep < + 5) { + await Future.delayed( + const Duration(milliseconds: 300)); + controller.noviceGuideService.aiStep++; + Intro.of(Get.context!) + .start(group: 'ai', reset: true); + } + }, + ); + }, + builder: + (BuildContext context, GlobalKey key) => + Padding( + key: key, + padding: EdgeInsets.only(bottom: 8.h), + child: GestureDetector( + onLongPressStart: + (LongPressStartDetails details) { + messageActionController + .onLongPressMessage(context, + isDarkMode, details, message); + }, + child: MessageBubble( + isUser: message.isUser, + message: message.text, + timestamp: message.timestamp, + isRecognizing: message.isRecognizing, + hasImage: message.hasImage, + imagePath: message.imagePath, + meta: message.meta, + ), + ), + ), + ) + : Padding( padding: EdgeInsets.only(bottom: 8.h), child: GestureDetector( onLongPressStart: @@ -265,13 +311,12 @@ class _AgentViewState extends State with WidgetsBindingObserver { imagePath: message.imagePath, meta: message.meta, ), - )); - }, - ), - ), + ), + ); + }, ), - ], - ), + ); + }), ), // 输入区域 @@ -685,7 +730,11 @@ class _AgentViewState extends State with WidgetsBindingObserver { return IntroStepBuilder( group: 'ai', order: 2, - onHighlightWidgetTap: () { + onHighlightWidgetTap: () async { + await controller + .noviceGuideService.aiStepsList[ + controller.noviceGuideService + .aiStep]['method'](); widgetParams!.onNext!(); controller.sendMessage(); }, diff --git a/lib/modules/agent/views/weather_card.dart b/lib/modules/agent/views/weather_card.dart index d06f7d97a..ce5026993 100644 --- a/lib/modules/agent/views/weather_card.dart +++ b/lib/modules/agent/views/weather_card.dart @@ -25,17 +25,7 @@ Widget buildWeatherContent(BuildContext context, String city, Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - gradient: isDarkMode - ? const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)], - ) - : const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ), + gradient: _getWeatherGradient(now['dayweather'] ?? '', isDarkMode), borderRadius: BorderRadius.circular(12.r), boxShadow: [ BoxShadow( @@ -63,7 +53,7 @@ Widget buildWeatherContent(BuildContext context, String city, color: Colors.white, ), ), - SizedBox(height: 4), + SizedBox(height: 4.w), Text( _getWeatherDescriptionTranslation(now["dayweather"]), style: TextStyle( @@ -483,3 +473,90 @@ IconData getWeatherIcon(String description) { // 默认或未知天气 return Icons.help_outline; } + +/// 根据天气类型获取背景渐变颜色 +LinearGradient _getWeatherGradient(String weather, bool isDarkMode) { + final weatherType = weather.trim(); + + // 晴天 - 温暖的橙黄色 + if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色 + : [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色 + ); + } + + // 多云/阴天 - 灰蓝色 + if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝 + : [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色 + ); + } + + // 雨天 - 深蓝色 + if (weatherType.contains('雨') || + ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] + .any((rain) => weatherType.contains(rain))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色 + : [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色 + ); + } + + // 雪天 - 冷色调蓝白色 + if (weatherType.contains('雪') || + ['阵雪', '小雪', '中雪', '大雪', '暴雪'] + .any((snow) => weatherType.contains(snow))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝 + : [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色 + ); + } + + // 雾霾天 - 暗黄灰色 + if (weatherType.contains('雾') || + weatherType.contains('霾') || + ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色 + : [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色 + ); + } + + // 大风天 - 青绿色 + if (weatherType.contains('风') || + ['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色 + : [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色 + ); + } + + // 默认 - 原来的蓝色 + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF1E40AF), Color(0xFF1E3A8A)] + : [Color(0xFF60A5FA), Color(0xFF3B82F6)], + ); +} diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 3b0c7f590..b605931ca 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -26,22 +26,6 @@ enum LoginViewState { verification // 验证码页面 } -// 用户渠道枚举 -// enum UserChannel { -// UNKNOWN(0), // 未知渠道 -// CHINA_HUAWEI(1), // 中国-华为 -// CHINA_XIAOMI(2), // 中国-小米 -// CHINA_OPPO(3), // 中国-OPPO -// CHINA_VIVO(4), // 中国-VIVO -// CHINA_HONOR(5), // 中国-荣耀 -// OFFICIAL(100), // 官方渠道 -// GLOBAL_APPLE(101), // 全球-苹果 -// GLOBAL_GOOGLE(102); // 全球-谷歌 - -// const UserChannel(this.value); -// final int value; -// } - class LoginController extends GetxController { static LoginController get to => Get.find(); final GetStorage _storage = GetStorage(); @@ -354,9 +338,32 @@ class LoginController extends GetxController { // 获取验证码 void getVerificationCode() { if (contactController.text.isEmpty) { + Get.snackbar( + 'tip'.tr, //提示 + 'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱 + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + // 根据登录类型验证格式 + if (loginType == 0 && !isEmailContactController()) { Get.snackbar( 'tip'.tr, - 'pleaseEnterContactInfo'.tr, + 'invalidEmailFormat'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + if (loginType == 1 && !isPhoneContactController()) { + Get.snackbar( + 'tip'.tr, + 'invalidPhoneFormat'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -371,40 +378,6 @@ class LoginController extends GetxController { // 使用验证码直接登录 void loginWithVerificationCode() { - //测试期间暂时关闭验证码登录 - // if (contactController.text.isEmpty) { - // Get.snackbar( - // '提示', - // '请输入手机号码或邮箱', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - - // if (verificationCodeController.text.isEmpty) { - // Get.snackbar( - // '提示', - // '请输入验证码', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - - // if (verificationCodeController.text.length < 6) { - // Get.snackbar( - // '提示', - // '请输入完整的验证码', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - _isLoading.value = true; // 模拟验证过程 @@ -481,9 +454,8 @@ class LoginController extends GetxController { // 发送验证码 Future _sendVerificationCode() async { - // 这里应该是实际发送验证码的逻辑 - // 为了演示,我们只是模拟发送 - if (isEmialContactController() || isPhoneContactController()) { + // 实际发送验证码的逻辑 + if (isEmailContactController() || isPhoneContactController()) { _startCountdown(); Logger().d(contactController.text); var response = await Api.getCode({ @@ -497,8 +469,8 @@ class LoginController extends GetxController { } } else { Get.snackbar( - 'tip'.tr, - 'pleaseEnterContactInfo'.tr, + 'tip'.tr, //提示 + 'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱 snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -530,19 +502,39 @@ class LoginController extends GetxController { } // 检查邮箱格式 - bool isEmialContactController() { + bool isEmailContactController() { // 检查每个元素是否为单个字母或数字字符 - final text = contactController.text; - return (text.length > 5 && - RegExp(r'^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') - .hasMatch(text)); + final text = contactController.text.trim(); + if (text.length < 6) return false; + + // 更严格的邮箱验证 + final emailRegex = + RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'); + return emailRegex.hasMatch(text); } -// 检查手机号码格式 + // 检查手机号码格式 bool isPhoneContactController() { // 检查每个元素是否为单个字母或数字字符 - final text = contactController.text; - return RegExp(r'^[0-9]+$').hasMatch(text); + final text = contactController.text.trim(); + if (text.isEmpty) return false; + // 根据国家代码验证手机号长度 + // 长度检查 + if (text.length < 7 || text.length > 15) return false; + + // 必须是纯数字 + if (!RegExp(r'^[0-9]+$').hasMatch(text)) return false; + // 特定国家的特殊验证 + switch (_selectedCountryCode.value) { + case '+86': // 中国 + return text.length == 11 && text.startsWith('1'); + case '+1': // 美国/加拿大 + return text.length == 10; + case '+81': // 日本 + return text.length == 10 || text.length == 11; + default: + return true; // 其他国家只做基本验证 + } } // 检查验证码是否完整 @@ -570,7 +562,7 @@ class LoginController extends GetxController { } // 验证邮箱格式 - if (loginType == 0 && !isEmialContactController()) { + if (loginType == 0 && !isEmailContactController()) { Get.snackbar( 'error'.tr, 'invalidEmailFormat'.tr, diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index 4dcecf302..496e405e3 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -192,7 +192,18 @@ class SettingsController extends GetxController { }, {'type': Permission.bluetooth, 'name': '蓝牙', 'description': '用于连接蓝牙设备'} ] - } + }, + { + 'name': '闹钟权限', + 'icon': Icons.alarm, + 'permissions': [ + { + 'type': Permission.scheduleExactAlarm, + 'name': '闹钟', + 'description': '用于设置精确闹钟' + } + ] + }, ]; // 检查单个权限状态 @@ -453,7 +464,8 @@ class SettingsController extends GetxController { Logger().i('更新agent服务语音识别语言: ${supportedLanguages.join(', ')}'); // 调用agent服务更新语言设置 - final success = await AgentService.setSupportedLanguages(supportedLanguages); + final success = + await AgentService.setSupportedLanguages(supportedLanguages); if (success) { Logger().i('agent服务语言设置更新成功'); } else { diff --git a/lib/modules/settings/views/permissions_view.dart b/lib/modules/settings/views/permissions_view.dart index c6eebac8c..2b0ceaf80 100644 --- a/lib/modules/settings/views/permissions_view.dart +++ b/lib/modules/settings/views/permissions_view.dart @@ -4,6 +4,8 @@ import 'package:permission_handler/permission_handler.dart'; import '../controllers/settings_controller.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'dart:ui'; +import 'dart:io' show Platform; + import '../../../core/utils/permission_util.dart'; // 引入PermissionUtil class PermissionsView extends GetView { @@ -37,7 +39,7 @@ class PermissionsView extends GetView { centerTitle: true, ), body: FutureBuilder>( - future: controller.checkAllPermissions(), + future: controller.checkAllPermissions(), //检查权限当前状态 builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -45,8 +47,8 @@ class PermissionsView extends GetView { if (snapshot.hasError) { return Center( - child: Text('getPermissionStatusFailed'.tr + - ': ${snapshot.error}')); // 获取权限状态失败 + child: Text( + '${'getPermissionStatusFailed'.tr}: ${snapshot.error}')); // 获取权限状态失败 } final permissionStatuses = snapshot.data ?? {}; @@ -54,15 +56,27 @@ class PermissionsView extends GetView { return ListView( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), children: [ - // 通讯权限组 - _buildPermissionSection( - 'communicationPermissions'.tr, // 通讯权限 - Icons.phone_android, - [Permission.phone, Permission.sms], + // 通讯权限组- iOS平台没有通讯权限 + if (Platform.isAndroid) ...[ + _buildPermissionSection( + 'communicationPermissions'.tr, // 通讯权限 + Icons.phone_android, + [Permission.phone, Permission.sms], + permissionStatuses, + isDarkMode), + SizedBox(height: 20.h), + ], + // 闹钟权限组 + if (Platform.isAndroid) ...[ + _buildPermissionSection( + 'alarmPermissions'.tr, // 闹钟权限 + Icons.alarm, + [Permission.scheduleExactAlarm], permissionStatuses, - isDarkMode), - - SizedBox(height: 20.h), + isDarkMode, + ), + SizedBox(height: 20.h), + ], // 日历权限组 _buildPermissionSection( @@ -162,6 +176,8 @@ class PermissionsView extends GetView { return 'calendarFullAccess'.tr; // 日历完整访问 case Permission.location: return 'locationInfo'.tr; // 位置信息 + case Permission.scheduleExactAlarm: + return 'alarm'.tr; // 闹钟 default: return permission.toString(); } @@ -180,6 +196,8 @@ class PermissionsView extends GetView { return 'calendarFullAccessPermissionDesc'.tr; // 用于读取和管理日历事件及提醒 case Permission.location: return 'locationPermissionDesc'.tr; // 用于获取当前位置信息 + case Permission.scheduleExactAlarm: + return 'alarmPermissionDesc'.tr; // 设置精确闹钟需要此权限 default: return 'appFunctionNeeds'.tr; // 应用功能需要 } @@ -355,6 +373,8 @@ class PermissionsView extends GetView { return 'calendarFullAccess'.tr; // 日历完整访问 case Permission.location: return 'locationInfo'.tr; // 位置信息 + case Permission.scheduleExactAlarm: + return 'alarm'.tr; // 闹钟 default: return permission.toString(); } @@ -373,6 +393,8 @@ class PermissionsView extends GetView { return 'calendarFullAccessPermissionDetailDesc'.tr; // 管理日历事件需要完整日历权限 case Permission.location: return 'locationPermissionDetailDesc'.tr; // 获取位置信息需要位置权限 + case Permission.scheduleExactAlarm: + return 'alarmPermissionDetailDesc'.tr; // 设置精确闹钟需要此权限 default: return 'appNeedsPermissionForFullFunction'.tr; // 应用需要此权限以提供完整功能 } 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 0acee79b3..d74db166f 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 @@ -117,7 +117,10 @@ object AgentService : CoroutineScope { // 空闲检测相关 private var idleCheckJob: Job? = null - private val maxIdleSeconds = 10 // 最大空闲秒数 + private val maxIdleSeconds = 10 // 最大空闲秒数(默认值) + + // 语音识别模式 + private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk // 打断防抖相关 private var lastInterruptTime = 0L @@ -409,22 +412,63 @@ object AgentService : CoroutineScope { listeners.clear() } + /** + * 根据模式启动空闲检测 + */ + private fun startIdleCheckForMode(mode: String) { + stopIdleCheck() // 先停止现有的检查 + if (!isRecognitionActive) return + + // 根据模式决定空闲检测策略 + when (mode) { + "phone_call" -> { + // 通话模式:禁用空闲检测,保持持续激活 + Log.d(TAG, "通话模式:禁用空闲检测") + return + } + "ble_wakeup" -> { + // BLE唤醒模式:使用默认的空闲检测 + startIdleCheck(maxIdleSeconds) + } + "push_to_talk" -> { + // 按住说话模式:使用较长的空闲检测时间 + startIdleCheck(maxIdleSeconds) + } + "normal" -> { + // 普通模式:使用默认的空闲检测 + startIdleCheck(maxIdleSeconds) + } + else -> { + // 未知模式:使用默认的空闲检测 + Log.w(TAG, "未知识别模式: $mode,使用默认空闲检测") + startIdleCheck(maxIdleSeconds) + } + } + } + /** * 启动空闲检测 */ - private fun startIdleCheck() { + private fun startIdleCheck(idleSeconds: Int = maxIdleSeconds) { stopIdleCheck() // 先停止现有的检查 if (!isRecognitionActive) return + Log.d(TAG, "启动空闲检测,超时时间: ${idleSeconds}秒,模式: $currentRecognitionMode") + idleCheckJob = launch { try { - delay(maxIdleSeconds * 1000L) + delay(idleSeconds * 1000L) if (isActive && isRecognitionActive && !isTtsSpeaking && !isAiStreaming) { + Log.d(TAG, "空闲检测触发自动停止,模式: $currentRecognitionMode") stopRecognition() sendEvent( "auto_stop", - mapOf("reason" to "idle_timeout", "seconds" to maxIdleSeconds) + mapOf( + "reason" to "idle_timeout", + "seconds" to idleSeconds, + "mode" to currentRecognitionMode + ) ) } } catch (e: CancellationException) { @@ -449,14 +493,16 @@ object AgentService : CoroutineScope { */ private fun restartIdleCheck() { if (isRecognitionActive) { - startIdleCheck() + startIdleCheckForMode(currentRecognitionMode) } } /** * 开始语音识别 + * @param isExternalActive 是否使用外部音频源(蓝牙) + * @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk */ - fun startRecognition(isExternalActive: Boolean): Boolean { + fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean { if (!isInitialized) { Log.d(TAG, "服务未初始化") return false @@ -488,6 +534,10 @@ object AgentService : CoroutineScope { return false } + // 设置当前识别模式 + currentRecognitionMode = mode + Log.d(TAG, "设置语音识别模式: $mode") + _isRecognitionActive.set(true) _hasSpeechDetected.set(false) @@ -500,6 +550,9 @@ object AgentService : CoroutineScope { Log.d("TAG", "azureAsrHelper?.startContinuousRecognition") azureAsrHelper?.startContinuousRecognition(audioSourceType) + // 根据模式启动相应的空闲检测 + startIdleCheckForMode(mode) + return true } catch (e: Exception) { _isRecognitionActive.set(false) @@ -586,7 +639,8 @@ object AgentService : CoroutineScope { override fun onSessionStarted() { sendEvent("recognition_started", mapOf("status" to "started")) - startIdleCheck() + // 使用模式感知的空闲检测启动方法 + startIdleCheckForMode(currentRecognitionMode) audioPlayer?.playAudio(R.raw.start) } @@ -644,16 +698,24 @@ object AgentService : CoroutineScope { fun stopRecognition() { if (!isRecognitionActive) return + Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode") + try { azureAsrHelper?.stopContinuousRecognition() BleService.closeCodec() _isRecognitionActive.set(false) stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" return } catch (e: Exception) { Log.e(TAG, "停止语音识别异常: ${e.message}") _isRecognitionActive.set(false) stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" return } } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index 60b919843..2c22546f3 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -115,7 +115,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream "startConversation" -> { try { - val isStarted = AgentService.startRecognition(false) + val mode = call.argument("mode") ?: "normal" + val isStarted = AgentService.startRecognition(false, mode) result.success(isStarted) } catch (e: Exception) { Log.e(TAG, "启动对话失败", e) diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index dc8a689c4..3c4cb9144 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -66,7 +66,7 @@ object BleAgent : BleService.Callback, AgentServiceListener { return false } Log.d(TAG, "手动启动语音识别: ") - return AgentService.startRecognition(true) + return AgentService.startRecognition(true, "ble_wakeup") } /** @@ -148,11 +148,11 @@ Log.d(TAG, "手动启动语音识别: ") // 停止当前TTS,避免冲突 AgentService.interruptCurrentResponse() // AgentService.speakText("我在!") - // 启动语音识别 - + // 启动语音识别,使用BLE唤醒模式 + // 如果已经在识别,则重新启动 //stopListening() - + //stopListening() startListening() 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 574689e3b..3428b4d70 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 @@ -65,6 +65,9 @@ class AgentServiceImpl: NSObject { private var idleTimer: DispatchSourceTimer? private let maxIdleSeconds: TimeInterval = 10 + // 语音识别模式 + private var currentRecognitionMode: String = "normal" + internal var audioPlayer: AudioPlayer? // private var locationInfo:[String: String]? @@ -280,26 +283,56 @@ class AgentServiceImpl: NSObject { return true } - private func startIdleCheck() { + private func startIdleCheckForMode(mode: String) { stopIdleCheck() - guard isRecognizing else { return } - + + // 根据模式决定空闲检测策略 + switch mode { + case "phone_call": + // 通话模式:禁用空闲检测,保持持续激活 + os_log("通话模式:禁用空闲检测", log: logger, type: .info) + return + case "ble_wakeup": + // BLE唤醒模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + case "push_to_talk": + // 按住说话模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + case "normal": + // 普通模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + default: + // 未知模式:使用默认的空闲检测 + os_log("未知识别模式: %{public}@,使用默认空闲检测", log: logger, type: .error, mode) + startIdleCheck(idleSeconds: maxIdleSeconds) + } + } + + private func startIdleCheck(idleSeconds: TimeInterval = 10) { + stopIdleCheck() + + guard isRecognizing else { return } + + os_log("启动空闲检测,超时时间: %.0f秒,模式: %{public}@", log: logger, type: .info, idleSeconds, currentRecognitionMode) + // 使用全局队列而不是主队列,避免后台挂起问题 let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) idleTimer = timer - - timer.schedule(deadline: .now() + maxIdleSeconds) + + timer.schedule(deadline: .now() + idleSeconds) timer.setEventHandler { [weak self] in // 使用更安全的方式检查self是否存在 DispatchQueue.main.async { [weak self] in guard let self = self else { return } - + if self.isRecognizing && !self.isSpeaking && !self.isAiStreaming { + os_log("空闲检测触发自动停止,模式: %{public}@", log: self.logger, type: .info, self.currentRecognitionMode) self.stopRecognition() self.sendEvent(name: "auto_stop", data: [ - "reason": "idle_timeout", - "seconds": self.maxIdleSeconds + "reason": "idle_timeout", + "seconds": idleSeconds, + "mode": self.currentRecognitionMode ]) } } @@ -314,10 +347,10 @@ class AgentServiceImpl: NSObject { private func restartIdleCheck() { if isRecognizing { - startIdleCheck() + startIdleCheckForMode(mode: currentRecognitionMode) } } - func startRecognition(useBle: Bool = false) -> Bool { + func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool { if !isInitialized { sendError("服务未初始化", code: "NOT_INITIALIZED") return false @@ -333,9 +366,13 @@ class AgentServiceImpl: NSObject { if isRecognizing { stopRecognition() } - + + // 设置当前识别模式 + currentRecognitionMode = mode + os_log("设置语音识别模式: %{public}@", log: logger, type: .info, mode) + let audioSourceType: AzureAsrHelper.AudioSourceType = useBle ? .external : .microphone - + if useBle { AudioSessionHub.shared.begin(.playback) } else { @@ -349,7 +386,10 @@ print("ai启动语音=\(audioSourceType)=\(useBle)") sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") return false } - + + // 根据模式启动相应的空闲检测 + startIdleCheckForMode(mode: mode) + return true } func recognizeCallback() -> Bool { @@ -367,11 +407,18 @@ print("ai启动语音=\(audioSourceType)=\(useBle)") if !isRecognizing { return true } - + + os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode) + guard let success = azureAsrHelper?.stopContinuousRecognition(), success else { return false } - + + stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" + return true } func disableBluetoothAudio() { @@ -1388,10 +1435,10 @@ extension AgentServiceImpl: BleService.Callback { func onWakeupSignalReceived() { stopTts() - + stopRecognition() - - startRecognition(useBle: true) + + startRecognition(useBle: true, mode: "ble_wakeup") } func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) { @@ -1620,7 +1667,8 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_started", data: ["status": "started"]) isRecognizing = true hasSpeechDetected = false - startIdleCheck() + // 使用模式感知的空闲检测启动方法 + startIdleCheckForMode(mode: currentRecognitionMode) audioPlayer?.playStartSound() } 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 fb8baa1f8..4664ba735 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 @@ -41,7 +41,8 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(success) case "startConversation": - impl.startRecognition() + let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal" + impl.startRecognition(mode: mode) result(true) case "recognizeCallback": diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 5d39accce..492c6ea2f 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -238,10 +238,13 @@ class AgentService { /// 开始对话 /// /// 启动语音识别,开始监听用户语音输入 + /// [mode] 语音识别模式:'ble_wakeup'(BLE唤醒), 'phone_call'(通话模式), 'push_to_talk'(按住说话), 'normal'(普通模式) /// 返回是否成功开始对话 - static Future startConversation() async { + static Future startConversation({String mode = 'normal'}) async { try { - final bool result = await _channel.invokeMethod('startConversation'); + final bool result = await _channel.invokeMethod('startConversation', { + 'mode': mode, + }); return result; } on PlatformException catch (e) { throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details); diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt index aa469e62a..a08ed5472 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt @@ -22,7 +22,8 @@ import java.util.concurrent.atomic.AtomicBoolean */ class CustomSseClientTransport( private val client: HttpClient, - private val urlString: String?, + private val serviceidString: String?, + public val urlString: String?, private val reconnectionTime: Duration? = null, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, private val onConnectionLost: (() -> Unit)? = null @@ -88,7 +89,7 @@ class CustomSseClientTransport( Triple(hostUrl, path, params) } catch (e: Exception) { - Log.e(TAG, "解析URL失败: $url, ${e.message}") + Log.e(TAG, "$serviceidString 解析URL失败: $url, ${e.message}") Triple(url, "", emptyMap()) } } @@ -102,9 +103,9 @@ class CustomSseClientTransport( session.incoming.collect { event -> when (event.event) { "error" -> { - Log.e(TAG, "SSE错误: ${event.data}") + Log.e(TAG, "$serviceidString SSE错误: ${event.data}") isConnected.set(false) - val exception = Exception("SSE Error: ${event.data}") + val exception = Exception("$serviceidString SSE Error: ${event.data}") _onError(exception) onConnectionLost?.invoke() throw exception @@ -112,7 +113,7 @@ class CustomSseClientTransport( "open" -> { // SSE连接已打开 - Log.d(TAG, "SSE连接已打开") + Log.d(TAG, "$serviceidString SSE连接已打开") isConnected.set(true) } "ping" -> { @@ -146,7 +147,7 @@ class CustomSseClientTransport( endpoint.complete(endpointWithParams) } catch (e: Exception) { - Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 处理endpoint事件失败: ${e.message}", e) _onError(e) close() error(e) @@ -161,22 +162,22 @@ class CustomSseClientTransport( val message = json.decodeFromString(data) _onMessage(message) } catch (e: Exception) { - Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 解析JSON-RPC消息失败: ${e.message}", e) _onError(e) } } } catch (e: Exception) { - Log.e(TAG, "处理事件失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 处理事件失败: ${e.message}", e) _onError(e) } } } } } catch (e: CancellationException) { - Log.d(TAG, "SSE事件收集被取消") + Log.d(TAG, "$serviceidString SSE事件收集被取消") throw e } catch (e: Exception) { - Log.e(TAG, "SSE连接异常断开: ${e.message}", e) + Log.e(TAG, "$serviceidString SSE连接异常断开: ${e.message}", e) isConnected.set(false) _onError(e) onConnectionLost?.invoke() @@ -199,13 +200,13 @@ class CustomSseClientTransport( // 检查session是否仍然活跃 if (session.coroutineContext[Job]?.isCancelled == true) { - Log.w(TAG, "检测到SSE会话已取消") + Log.w(TAG, "$serviceidString 检测到SSE会话已取消") isConnected.set(false) onConnectionLost?.invoke() break } } catch (e: Exception) { - Log.e(TAG, "连接监控异常: ${e.message}", e) + Log.e(TAG, "$serviceidString 连接监控异常: ${e.message}", e) isConnected.set(false) onConnectionLost?.invoke() break @@ -219,7 +220,7 @@ class CustomSseClientTransport( */ override suspend fun start() { if (!initialized.compareAndSet(false, true)) { - Log.e(TAG, "传输层已经启动,不能重复启动") + Log.e(TAG, "$serviceidString 传输层已经启动,不能重复启动") error("CustomSseClientTransport already started!") } @@ -283,12 +284,30 @@ class CustomSseClientTransport( setBody(jsonString) } - if (!response.status.isSuccess()) { - val text = response.bodyAsText() - // Log.e(TAG, "发送消息失败:URL:${urlString} HTTP ${response.status}, $text") + // 优化处理百度API的202状态码 + when { + response.status.isSuccess() -> { + // 2xx状态码都视为成功 + Log.d(TAG, "$serviceidString 消息发送成功: HTTP ${response.status}") + } + response.status == HttpStatusCode.Accepted -> { + // 202 Accepted - 百度API异步处理中,这是正常状态 + Log.d(TAG, "$serviceidString 消息已被接受,正在异步处理: HTTP ${response.status}") + } + else -> { + // 其他非成功状态码才记录为错误 + val text = response.bodyAsText() + Log.w(TAG, "$serviceidString 发送消息收到非成功状态码: URL:${messageEndpoint} HTTP ${response.status}, $text") + + // 根据具体状态码决定是否抛出异常 + if (response.status.value >= 400) { + // 4xx和5xx错误才抛出异常 + throw Exception("HTTP ${response.status}: $text") + } + } } } catch (e: Exception) { - Log.e(TAG, "发送消息异常: ${e.message}", e) + Log.e(TAG, "$serviceidString 发送消息异常: ${e.message}", e) _onError(e) throw e } @@ -306,7 +325,7 @@ class CustomSseClientTransport( */ override suspend fun close() { if (!initialized.get()) { - Log.e(TAG, "关闭失败: 传输层未初始化") + Log.e(TAG, "$serviceidString 关闭失败: 传输层未初始化") error("CustomSseClientTransport is not initialized!") } @@ -317,6 +336,6 @@ class CustomSseClientTransport( job?.cancelAndJoin() connectionMonitorJob?.cancelAndJoin() - Log.d(TAG, "CustomSseClientTransport已关闭") + Log.d(TAG, "$serviceidString CustomSseClientTransport已关闭") } } \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt index c727a8f82..01b403ada 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt @@ -67,45 +67,54 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { Log.w(TAG, "Failed to initialize system functions", e) } } - + /** * 连接到SSE服务器 * 直接接收完整的JSON配置字符串 - * + * * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 * @return 是否连接成功 */ suspend fun connectToSSE(mcpConfigJson: String): Boolean { // 清除现有连接 closeAllConnections() - + return try { val config = JSONObject(mcpConfigJson) val mcpServers = config.optJSONObject("mcpServers") ?: return false - + var connectedCount = 0 val serverIds = mcpServers.keys() - + while (serverIds.hasNext()) { val serverId = serverIds.next() val serverConfig = mcpServers.optJSONObject(serverId) ?: continue - + val url = serverConfig.optString("url", "") val tools = serverConfig.optString("tools", "") if (url.isEmpty()) continue - val subClient = MCPSubClient(serverId, url,tools, sharedHttpClient) - - if (subClient.connect()) { - subClients[serverId] = subClient - connectedCount++ - } else { - Log.w(TAG, "Failed to connect to MCP server: $serverId") + + Log.d(TAG, "开始连接MCP服务器: $serverId") + val subClient = MCPSubClient(serverId, url, tools, sharedHttpClient) + + // 使用协程并发连接,但每个服务器都会进行重试 + try { + if (subClient.connect()) { + subClients[serverId] = subClient + connectedCount++ + Log.d(TAG, "MCP服务器连接成功: $serverId") + } else { + Log.w(TAG, "MCP服务器连接失败: $serverId") + } + } catch (e: Exception) { + Log.e(TAG, "MCP服务器连接异常: $serverId, ${e.message}", e) } } - + isConnectedFlag = connectedCount > 0 + Log.d(TAG, "MCP连接完成,成功连接 $connectedCount 个服务器") connectedCount > 0 - + } catch (e: Exception) { Log.e(TAG, "Failed to connect to SSE", e) false diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt index cf3a96703..2644dad03 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt @@ -33,7 +33,7 @@ class MCPSubClient( private var heartbeatJob: Job? = null private val heartbeatInterval = 30000L // 30秒 // 重连配置 - private val maxRetryAttempts = 3 + private val maxRetryAttempts = 10 private val initialReconnectDelay = 1000L // 初始重连延迟1秒 private val maxReconnectDelay = 30000L // 最大重连延迟30秒 private var currentReconnectDelay = initialReconnectDelay @@ -45,95 +45,122 @@ class MCPSubClient( private var isConnected = false private var availableTools = mutableListOf() private var transport: CustomSseClientTransport? = null - + /** * 连接到MCP服务器 */ suspend fun connect(): Boolean = connectionMutex.withLock { - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 1") if (isConnected) return true Log.e(TAG, "[$serverId] 开始连接mcp服务器: $serverUrl") - return try { - // 创建MCP客户端实例 - val client = Client( - clientInfo = Implementation( - name = "deep-voice-chat-api", - version = "1.0.0" + + // 重试连接逻辑 + for (attempt in 0 until maxRetryAttempts) { + try { + Log.d(TAG, "[$serverId] 连接尝试 ${attempt + 1}/$maxRetryAttempts") + + // 创建MCP客户端实例 + val client = Client( + clientInfo = Implementation( + name = "deep-voice-chat-api", + version = "1.0.0" + ) ) - ) - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 2") - // 根据URL类型选择传输方式 - val newTransport = when { - serverUrl.startsWith("http://") || serverUrl.startsWith("https://") -> { - // SSE传输 - 使用自定义的CustomSseClientTransport - val mcpHttpClient = httpClient ?: createMcpHttpClient() - CustomSseClientTransport( - client = mcpHttpClient, - urlString = serverUrl, - onConnectionLost = { - // 连接断开回调 - Log.w(TAG, "[$serverId] 检测到连接断开") - scope.launch { - handleConnectionLost() + + // 根据URL类型选择传输方式 + val newTransport = when { + serverUrl.startsWith("http://") || serverUrl.startsWith("https://") -> { + // SSE传输 - 使用自定义的CustomSseClientTransport + val mcpHttpClient = httpClient ?: createMcpHttpClient() + CustomSseClientTransport( + client = mcpHttpClient, + serviceidString = serverId, + urlString = serverUrl, + onConnectionLost = { + // 连接断开回调 + Log.w(TAG, "[$serverId] 检测到连接断开") + scope.launch { + handleConnectionLost() + } } - } - ) - } - else -> { - Log.e(TAG, "[$serverId] 不支持的服务器URL格式: $serverUrl") - return false + ) + } + else -> { + Log.e(TAG, "[$serverId] 不支持的服务器URL格式: $serverUrl") + return false + } } - } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 3") - transport = newTransport - try { - // 连接到服务器 - withTimeout(10000) { // 10秒超时 - client.connect(newTransport) + + transport = newTransport + + // 连接到服务器 - 增加超时时间 + try { + Log.d(TAG, "[$serverId] 尝试建立连接 ${transport?.urlString}") + withTimeout(30000) { // 30秒超时 + client.connect(newTransport) + } + Log.d(TAG, "[$serverId] 连接建立成功") + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "[$serverId] 连接超时 (尝试 ${attempt + 1}/$maxRetryAttempts)") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + continue // 继续下一次重试 + } else { + Log.e(TAG, "[$serverId] 所有连接尝试都超时,连接失败") + return false + } + } catch (e: Exception) { + Log.w(TAG, "[$serverId] 连接异常 (尝试 ${attempt + 1}/$maxRetryAttempts): ${e.message}") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + continue // 继续下一次重试 + } else { + Log.e(TAG, "[$serverId] 所有连接尝试都失败: ${e.message}", e) + return false + } } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 4") - } catch (e: TimeoutCancellationException) { - Log.e(TAG, "liwei---------Mcp [$serverId] 连接超时") - return false - } catch (e: Exception) { - Log.e(TAG, "liwei---------Mcp [$serverId] 连接异常: ${e.message}", e) - return false - } - - // 获取可用工具列表 - try { - val toolsResult = client.listTools() - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 5") - if (toolsResult != null) { - availableTools.clear() - val filtered = toolsResult.tools.filter { tool -> - filterTools.isEmpty() || filterTools.contains(tool.name) + + // 获取可用工具列表 + try { + val toolsResult = client.listTools() + if (toolsResult != null) { + availableTools.clear() + val allToolNames = toolsResult.tools.map { it.name } + Log.d(TAG, "$serverId:所有工具名称列表: $allToolNames") + val filtered = toolsResult.tools.filter { tool -> + filterTools.isEmpty() || filterTools.contains(tool.name) + } + val filteredToolNames = filtered.map { it.name } + Log.d(TAG, "$serverId: 过滤后的工具: $filteredToolNames") + availableTools.addAll(filtered) } - Log.w(TAG, "[$serverId] [${filterTools}] 获取工具列表: ${filtered} 原始列表:${toolsResult.tools}") - availableTools.addAll(filtered) - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 6") + } catch (e: Exception) { + Log.w(TAG, "[$serverId] 获取工具列表失败: ${e.message}") + // 即使获取工具失败,连接也可能是成功的 } + + mcpClient = client + isConnected = true + retryCount = 0 + currentReconnectDelay = initialReconnectDelay + + Log.e(TAG, "[$serverId] 连接mcp服务器成功: $serverUrl") + return true + } catch (e: Exception) { - Log.w(TAG, "[$serverId] 获取工具列表失败: ${e.message}") - // 即使获取工具失败,连接也可能是成功的 + Log.w(TAG, "[$serverId] 连接尝试 ${attempt + 1} 失败: ${e.message}") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + } } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 7") - mcpClient = client - isConnected = true - - retryCount = 0 - currentReconnectDelay = initialReconnectDelay - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 8") - // 启动心跳检测 -// startHeartbeat() - Log.e(TAG, "[$serverId] 连接mcp服务器成功: $serverUrl") - true - } catch (e: Exception) { - Log.e(TAG, "[$serverId] MCP连接失败: ${e.message}", e) - false } + + Log.e(TAG, "[$serverId] 所有重试尝试都失败,MCP连接失败") + return false } - + /** * 检查是否包含指定工具 */ diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index fcdc8ce17..bf217f258 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt @@ -16,6 +16,7 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.json.JSONObject object LocationService { @@ -115,6 +116,15 @@ object LocationService { } initialized = true + //首次请求一次定位 + requestLocation(object : LocationCallback { + override fun onLocationSuccess(latitude: Double, longitude: Double) { + } + + override fun onLocationError(errorCode: Int, errorMessage: String) { + } + }) + Log.d(TAG, "定位服务初始化完成 - 优化高精度模式") } catch (e: Exception) { Log.e(TAG, "高德定位初始化失败: ${e.message}", e) @@ -173,20 +183,12 @@ object LocationService { } Log.d(TAG, "定位结果: 来源=${location.provider}, 精度=${location.accuracy}米, 坐标=(${location.latitude}, ${location.longitude})") - - // 检查定位来源,只接受GPS定位 - if (location.provider != "gps") { - Log.w(TAG, "拒绝非GPS定位,当前来源: ${location.provider}") - // 继续等待GPS定位,不回调 - return - } - if (location.errorCode == AMapLocation.LOCATION_SUCCESS) { val accuracy = location.accuracy - Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") + // Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") // 只接受高精度GPS定位 - if (accuracy <= HIGH_ACCURACY_THRESHOLD) { + // if (accuracy <= HIGH_ACCURACY_THRESHOLD) { Log.d(TAG, "接受高精度GPS定位: ${accuracy}米") // 停止超时计时器 @@ -220,9 +222,9 @@ object LocationService { } // 清空服务回调列表(单次定位) serviceCallbacks.clear() - } else { - Log.w(TAG, "GPS精度不足(${accuracy}米),继续等待更高精度定位") - } + // } else { + // Log.w(TAG, "GPS精度不足(${accuracy}米),继续等待更高精度定位") + // } } else { Log.e(TAG, "GPS定位失败: ${location.errorCode} - ${location.errorInfo}") }