|
|
@ -4,7 +4,6 @@ import 'dart:io'; |
|
|
import 'package:amap_service/amap_service.dart'; |
|
|
import 'package:amap_service/amap_service.dart'; |
|
|
import 'package:deep_voice/data/services/music_manager.dart'; |
|
|
import 'package:deep_voice/data/services/music_manager.dart'; |
|
|
import 'package:deep_voice/data/services/navigation_manager.dart'; |
|
|
import 'package:deep_voice/data/services/navigation_manager.dart'; |
|
|
import 'package:deep_voice/routes/app_routes.dart'; |
|
|
|
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
import 'package:agent_service/agent_service.dart'; |
|
|
import 'package:agent_service/agent_service.dart'; |
|
|
@ -12,32 +11,11 @@ import 'package:chat_storage/chat_storage.dart'; |
|
|
import 'package:image_picker/image_picker.dart'; |
|
|
import 'package:image_picker/image_picker.dart'; |
|
|
import 'package:get_storage/get_storage.dart'; |
|
|
import 'package:get_storage/get_storage.dart'; |
|
|
import 'package:permission_handler/permission_handler.dart'; |
|
|
import 'package:permission_handler/permission_handler.dart'; |
|
|
import 'package:app_settings/app_settings.dart'; |
|
|
|
|
|
import 'package:deep_voice/core/utils/logger.dart'; |
|
|
import 'package:deep_voice/core/utils/logger.dart'; |
|
|
import 'package:path_provider/path_provider.dart'; |
|
|
import 'package:path_provider/path_provider.dart'; |
|
|
import 'package:photo_view/photo_view.dart'; |
|
|
import 'package:photo_view/photo_view.dart'; |
|
|
import '../../../core/utils/permission_util.dart'; |
|
|
import '../../../core/utils/permission_util.dart'; |
|
|
|
|
|
import '../../../data/models/message_model.dart'; |
|
|
class Message { |
|
|
|
|
|
final bool isUser; //标识信息是否来自用户还是AI |
|
|
|
|
|
final String text; //信息文本内容 |
|
|
|
|
|
final DateTime timestamp; // 消息时间戳 |
|
|
|
|
|
final bool isRecognizing; // 是否为语音识别中的临时消息 |
|
|
|
|
|
final bool hasImage; // 是否包含图片 |
|
|
|
|
|
final String? imagePath; // 图片路径 |
|
|
|
|
|
final Map? meta; //卡片数据 |
|
|
|
|
|
|
|
|
|
|
|
Message({ |
|
|
|
|
|
// 构造函数 |
|
|
|
|
|
required this.isUser, |
|
|
|
|
|
required this.text, |
|
|
|
|
|
DateTime? timestamp, |
|
|
|
|
|
this.isRecognizing = false, |
|
|
|
|
|
this.hasImage = false, |
|
|
|
|
|
this.imagePath, |
|
|
|
|
|
this.meta, |
|
|
|
|
|
}) : this.timestamp = timestamp ?? DateTime.now(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
class AgentController extends GetxController { |
|
|
class AgentController extends GetxController { |
|
|
static AgentController get to => Get.find(); //单例访问模式 |
|
|
static AgentController get to => Get.find(); //单例访问模式 |
|
|
@ -214,7 +192,7 @@ class AgentController extends GetxController { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 添加欢迎消息 |
|
|
// 添加初始欢迎消息 |
|
|
void _addWelcomeMessage() { |
|
|
void _addWelcomeMessage() { |
|
|
messages.add(Message( |
|
|
messages.add(Message( |
|
|
isUser: false, |
|
|
isUser: false, |
|
|
@ -418,7 +396,7 @@ class AgentController extends GetxController { |
|
|
break; |
|
|
break; |
|
|
|
|
|
|
|
|
case AgentServiceEventType.functionCallResult: |
|
|
case AgentServiceEventType.functionCallResult: |
|
|
final responseId = event.data['responseId'] ?? ''; |
|
|
final responseId = event.data['responseId'] ?? ''; |
|
|
// final result = event.data['result']; |
|
|
// final result = event.data['result']; |
|
|
final metaStr = event.data['meta'] ?? ''; |
|
|
final metaStr = event.data['meta'] ?? ''; |
|
|
// final functionCall = event.data['function_call'] ?? ''; |
|
|
// final functionCall = event.data['function_call'] ?? ''; |
|
|
@ -521,8 +499,10 @@ class AgentController extends GetxController { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 发送消息(包括所有支持的消息类型) |
|
|
// 发送消息(包括所有支持的消息类型) |
|
|
Future<void> sendTextMessage() async { |
|
|
//场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。 |
|
|
// 如果图片输入是活跃的,使用选中的图片和文本框的内容 |
|
|
//场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。 |
|
|
|
|
|
Future<void> sendMessage() async { |
|
|
|
|
|
// 1. 处理图片+文本的混合输入 |
|
|
if (isImageInputActive.value && selectedImagePath.value != null) { |
|
|
if (isImageInputActive.value && selectedImagePath.value != null) { |
|
|
final text = textController.text.trim(); |
|
|
final text = textController.text.trim(); |
|
|
final imagePath = selectedImagePath.value!; // 使用!强制断言非空 |
|
|
final imagePath = selectedImagePath.value!; // 使用!强制断言非空 |
|
|
@ -533,6 +513,7 @@ class AgentController extends GetxController { |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 2. 处理纯文本消息 |
|
|
final text = textController.text.trim(); |
|
|
final text = textController.text.trim(); |
|
|
if (text.isEmpty) return; |
|
|
if (text.isEmpty) return; |
|
|
|
|
|
|
|
|
@ -541,7 +522,7 @@ class AgentController extends GetxController { |
|
|
textController.clear(); |
|
|
textController.clear(); |
|
|
|
|
|
|
|
|
// 使用reverse:true时通常不需要手动滚动 |
|
|
// 使用reverse:true时通常不需要手动滚动 |
|
|
|
|
|
// 3. 调用AI服务处理 |
|
|
try { |
|
|
try { |
|
|
isProcessing.value = true; |
|
|
isProcessing.value = true; |
|
|
// 标记为新的AI回复 |
|
|
// 标记为新的AI回复 |
|
|
@ -636,36 +617,6 @@ class AgentController extends GetxController { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 保存消息到持久化存储 |
|
|
|
|
|
// Future<void> _saveMessageToStorage(Message message) async { |
|
|
|
|
|
// try { |
|
|
|
|
|
// // 准备元数据,包含图片信息 |
|
|
|
|
|
// Map<String, dynamic> metadata = {}; |
|
|
|
|
|
// if (message.meta != null) { |
|
|
|
|
|
// metadata = Map<String, dynamic>.from(message.meta!); |
|
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
|
|
// // 添加图片相关信息到元数据 |
|
|
|
|
|
// if (message.hasImage) { |
|
|
|
|
|
// metadata['hasImage'] = true; |
|
|
|
|
|
// metadata['imagePath'] = message.imagePath; |
|
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
|
|
// // 保存消息 |
|
|
|
|
|
// await ChatStorage.saveMessage( |
|
|
|
|
|
// sessionId: sessionId, |
|
|
|
|
|
// sender: message.isUser ? 'user' : 'assistant', |
|
|
|
|
|
// message: message.text, |
|
|
|
|
|
// metadata: metadata.isNotEmpty ? jsonEncode(metadata) : null, |
|
|
|
|
|
// //timestamp: message.timestamp.toIso8601String(), |
|
|
|
|
|
// ); |
|
|
|
|
|
|
|
|
|
|
|
// Logger.i(TAG, '消息已保存到持久化存储'); |
|
|
|
|
|
// } catch (e) { |
|
|
|
|
|
// Logger.e(TAG, '保存消息到持久化存储失败: $e'); |
|
|
|
|
|
// } |
|
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
|
|
// 切换输入模式 |
|
|
// 切换输入模式 |
|
|
void toggleInputMode() async { |
|
|
void toggleInputMode() async { |
|
|
// 如果当前在图片输入模式,先清除 |
|
|
// 如果当前在图片输入模式,先清除 |
|
|
@ -703,156 +654,6 @@ class AgentController extends GetxController { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 检查麦克风权限,返回true表示已授权,false表示未授权 |
|
|
|
|
|
Future<bool> _checkMicrophonePermission() async { |
|
|
|
|
|
try { |
|
|
|
|
|
// 1.先获取当前麦克风权限状态 |
|
|
|
|
|
final micStatus = await Permission.microphone.status; |
|
|
|
|
|
|
|
|
|
|
|
// 2.如果麦克风权限已授予,返回true |
|
|
|
|
|
if (micStatus.isGranted) { |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 3.创建待请求权限列表(这里只请求麦克风权限) |
|
|
|
|
|
List<Permission> permissionsToRequest = []; |
|
|
|
|
|
permissionsToRequest.add(Permission.microphone); |
|
|
|
|
|
|
|
|
|
|
|
// 4.弹出权限说明对话框(首次拒绝/还没授权时展示) |
|
|
|
|
|
if (micStatus.isPermanentlyDenied) { |
|
|
|
|
|
_showPermissionDialog(true); |
|
|
|
|
|
} else { |
|
|
|
|
|
_showPermissionDialog(false); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 5.请求权限,并获取返回的权限状态集合 |
|
|
|
|
|
final statuses = await permissionsToRequest.request(); |
|
|
|
|
|
|
|
|
|
|
|
// 6.用于记录被拒绝的权限(便于后续判断) |
|
|
|
|
|
Permission? deniedPermission; |
|
|
|
|
|
|
|
|
|
|
|
// 7.遍历权限状态集合,检查是否有任何权限被拒绝 |
|
|
|
|
|
statuses.forEach((permission, status) { |
|
|
|
|
|
if (!status.isGranted) { |
|
|
|
|
|
deniedPermission = permission; |
|
|
|
|
|
Logger.e(TAG, '权限 ${permission.toString()} 未被授予: $status'); |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// // 8. 如果是“永久拒绝”(用户勾选了“不再询问”) |
|
|
|
|
|
// if (micStatus.isPermanentlyDenied) { |
|
|
|
|
|
// _showPermissionDialog(true); |
|
|
|
|
|
// return false; |
|
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
|
|
// // 9. 普通的拒绝情况,再次弹出说明对话框 |
|
|
|
|
|
// if (deniedPermission != null) { |
|
|
|
|
|
// _showPermissionDialog(false); |
|
|
|
|
|
// } |
|
|
|
|
|
|
|
|
|
|
|
// 10. 如果权限未被授予,返回 false |
|
|
|
|
|
return false; |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
Logger.e(TAG, '检查麦克风权限失败: $e'); |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 显示权限提示对话框 |
|
|
|
|
|
void _showPermissionDialog(bool isPermanent) { |
|
|
|
|
|
Get.generalDialog( |
|
|
|
|
|
barrierDismissible: false, |
|
|
|
|
|
barrierLabel: '权限提示', |
|
|
|
|
|
transitionBuilder: (context, anim1, anim2, child) { |
|
|
|
|
|
return SlideTransition( |
|
|
|
|
|
position: Tween<Offset>( |
|
|
|
|
|
begin: Offset(0, -1), // 从上方滑入 |
|
|
|
|
|
end: Offset(0, 0), |
|
|
|
|
|
).animate(CurvedAnimation(parent: anim1, curve: Curves.easeOut)), |
|
|
|
|
|
child: child, |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
pageBuilder: (context, anim1, anim2) { |
|
|
|
|
|
return SafeArea( |
|
|
|
|
|
child: Align( |
|
|
|
|
|
alignment: Alignment.topCenter, // 关键:设置为顶部对齐 |
|
|
|
|
|
child: Padding( |
|
|
|
|
|
padding: const EdgeInsets.only(top: 80, left: 24, right: 24), |
|
|
|
|
|
child: Material( |
|
|
|
|
|
borderRadius: BorderRadius.circular(12), |
|
|
|
|
|
color: Colors.white, |
|
|
|
|
|
child: Padding( |
|
|
|
|
|
padding: const EdgeInsets.all(16), |
|
|
|
|
|
child: ConstrainedBox( |
|
|
|
|
|
constraints: BoxConstraints(maxWidth: 400), |
|
|
|
|
|
child: Column( |
|
|
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start, |
|
|
|
|
|
children: [ |
|
|
|
|
|
const Text('需要麦克风权限', |
|
|
|
|
|
style: TextStyle( |
|
|
|
|
|
fontSize: 18, fontWeight: FontWeight.bold)), |
|
|
|
|
|
const SizedBox(height: 12), |
|
|
|
|
|
Text(isPermanent |
|
|
|
|
|
? '语音功能需要麦克风权限。请前往设置页面手动开启权限。' |
|
|
|
|
|
: '语音功能需要麦克风权限。请允许使用麦克风权限以继续使用语音功能。'), |
|
|
|
|
|
const SizedBox(height: 16), |
|
|
|
|
|
const Text('权限使用说明:', |
|
|
|
|
|
style: TextStyle(fontWeight: FontWeight.bold)), |
|
|
|
|
|
const SizedBox(height: 8), |
|
|
|
|
|
const Text('• 麦克风权限:用于录制您的语音,实现语音翻译、语音助手对话等核心功能'), |
|
|
|
|
|
const SizedBox(height: 8), |
|
|
|
|
|
const Text('• 我们仅在您主动使用语音功能时访问麦克风'), |
|
|
|
|
|
const SizedBox(height: 8), |
|
|
|
|
|
const Text('• 您可以随时在系统设置中关闭权限'), |
|
|
|
|
|
const SizedBox(height: 20), |
|
|
|
|
|
Row( |
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end, |
|
|
|
|
|
children: [ |
|
|
|
|
|
TextButton( |
|
|
|
|
|
onPressed: () => Get.back(), |
|
|
|
|
|
child: const Text('取消'), |
|
|
|
|
|
), |
|
|
|
|
|
const SizedBox(width: 8), |
|
|
|
|
|
TextButton( |
|
|
|
|
|
onPressed: () { |
|
|
|
|
|
Get.back(); |
|
|
|
|
|
if (isPermanent) { |
|
|
|
|
|
AppSettings.openAppSettings(); |
|
|
|
|
|
} else { |
|
|
|
|
|
Permission.microphone.request(); |
|
|
|
|
|
} |
|
|
|
|
|
}, |
|
|
|
|
|
child: Text(isPermanent ? '前往设置' : '授权'), |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
], |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
), |
|
|
|
|
|
); |
|
|
|
|
|
}, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 获取Android SDK版本 |
|
|
|
|
|
Future<int> _getAndroidSdkVersion() async { |
|
|
|
|
|
if (Platform.isAndroid) { |
|
|
|
|
|
try { |
|
|
|
|
|
// 这里我们简单返回一个较高的值,确保请求所有可能需要的权限 |
|
|
|
|
|
// 在实际应用中,你可能需要使用platform channel获取真实的SDK版本 |
|
|
|
|
|
return 31; // 对应Android 12 |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
Logger.e(TAG, '获取Android SDK版本失败: $e'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
return 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 开始语音输入 |
|
|
// 开始语音输入 |
|
|
Future<void> startVoiceInput() async { |
|
|
Future<void> startVoiceInput() async { |
|
|
if (isListening.value) return; |
|
|
if (isListening.value) return; |
|
|
|