diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt index 8d4f460e9..b6e861db2 100644 --- a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt +++ b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt @@ -19,6 +19,7 @@ class FloatingWindowService : Service() { private var windowManager: WindowManager? = null private var floatingView: View? = null private var isMinimized = true + private lateinit var layoutParams: WindowManager.LayoutParams private lateinit var sourceTextView: TextView private lateinit var translatedTextView: TextView @@ -73,7 +74,7 @@ class FloatingWindowService : Service() { setupClickListeners() // 设置窗口参数 - val layoutParams = WindowManager.LayoutParams().apply { + layoutParams = WindowManager.LayoutParams().apply { width = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -255,26 +256,12 @@ class FloatingWindowService : Service() { } private fun setupClickListeners() { - // 最小化状态点击展开 - minimizedView.setOnClickListener { - isMinimized = false - updateViewState(false) - notifyStateChange("expanded") - } - - // 展开状态点击最小化 - expandedView.setOnClickListener { - isMinimized = true - updateViewState(true) - notifyStateChange("minimized") - } - // 长按关闭 floatingView!!.setOnLongClickListener { closeFloatingWindow() true } - + // 拖拽功能 setupDragListener() } @@ -284,25 +271,40 @@ class FloatingWindowService : Service() { var initialY = 0 var initialTouchX = 0f var initialTouchY = 0f - + var dragging = false + val threshold = (4 * resources.displayMetrics.density).toInt() floatingView!!.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - val layoutParams = floatingView!!.layoutParams as WindowManager.LayoutParams initialX = layoutParams.x initialY = layoutParams.y initialTouchX = event.rawX initialTouchY = event.rawY + dragging = false true } MotionEvent.ACTION_MOVE -> { - val layoutParams = floatingView!!.layoutParams as WindowManager.LayoutParams - layoutParams.x = initialX + (event.rawX - initialTouchX).toInt() - layoutParams.y = initialY + (event.rawY - initialTouchY).toInt() + val dx = (event.rawX - initialTouchX).toInt() + val dy = (event.rawY - initialTouchY).toInt() + if (Math.abs(dx) > threshold || Math.abs(dy) > threshold) dragging = true + val dm = resources.displayMetrics + val maxX = dm.widthPixels - (floatingView?.width ?: 0) + val maxY = dm.heightPixels - (floatingView?.height ?: 0) + layoutParams.x = (initialX + dx).coerceIn(0, maxX) + layoutParams.y = (initialY + dy).coerceIn(0, maxY) windowManager?.updateViewLayout(floatingView, layoutParams) true } - else -> false + MotionEvent.ACTION_UP -> { + if (!dragging) { + isMinimized = !isMinimized + updateViewState(isMinimized) + notifyStateChange(if (isMinimized) "minimized" else "expanded") + } + dragging = false + true + } + else -> true } } } @@ -347,11 +349,10 @@ class FloatingWindowService : Service() { val x = intent.getIntExtra(EXTRA_X, 0) val y = intent.getIntExtra(EXTRA_Y, 0) - floatingView?.let { view -> - val layoutParams = view.layoutParams as WindowManager.LayoutParams + floatingView?.let { layoutParams.x = x layoutParams.y = y - windowManager?.updateViewLayout(view, layoutParams) + windowManager?.updateViewLayout(it, layoutParams) } } diff --git a/assets/images/DefaultPicture.jpg b/assets/images/DefaultPicture.jpg new file mode 100644 index 000000000..ef039c301 Binary files /dev/null and b/assets/images/DefaultPicture.jpg differ diff --git a/assets/images/defaultPicture.png b/assets/images/defaultPicture.png new file mode 100644 index 000000000..57953cc0b Binary files /dev/null and b/assets/images/defaultPicture.png differ diff --git a/assets/images/headphone_new3.png b/assets/images/headphone_new3.png deleted file mode 100644 index 6906348ce..000000000 Binary files a/assets/images/headphone_new3.png and /dev/null differ diff --git a/assets/images/headphone_new4.png b/assets/images/headphone_new4.png deleted file mode 100644 index 7161868f8..000000000 Binary files a/assets/images/headphone_new4.png and /dev/null differ diff --git a/ios/Runner/RunnerProfile.entitlements b/ios/Runner/RunnerProfile.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/ios/Runner/RunnerProfile.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index ae0534031..e25234505 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,5 +1,6 @@ -import 'package:voitrans/data/services/pay_service.dart'; -import 'package:voitrans/data/services/qq_music.dart'; +import '../../data/services/meeting/summary_task_service.dart'; +import '/data/services/pay_service.dart'; +import '/data/services/qq_music.dart'; import '../../../data/services/user_portrait.dart'; @@ -70,6 +71,9 @@ class InitialBinding extends Bindings { // 会议助手任务服务 Get.lazyPut(() => MeetingTaskService(), fenix: true); + // 会议助手总结任务服务 + Get.lazyPut(() => SummaryTaskService(), fenix: true); + // 会议助手音频上传 Get.lazyPut(() => MeetingUploadService(), fenix: true); diff --git a/lib/core/widgets/common_bottom_nav.dart b/lib/core/widgets/common_bottom_nav.dart index 04c9d5b31..c1946f17f 100644 --- a/lib/core/widgets/common_bottom_nav.dart +++ b/lib/core/widgets/common_bottom_nav.dart @@ -17,8 +17,8 @@ class CommonBottomNav extends StatelessWidget { return BottomNavigationBar( currentIndex: currentIndex, onTap: (index) { - // Navigate to the appropriate page based on index - if(index ==currentIndex) return;//如果是当前页面,不跳转 + // 点击当前项不执行跳转 + if (index == currentIndex) return; //如果是当前页面,不跳转 switch (index) { case 0: Get.offAllNamed(Routes.home); diff --git a/lib/data/models/appconfig_model.dart b/lib/data/models/appconfig_model.dart index 65ea7838e..e656a62c9 100644 --- a/lib/data/models/appconfig_model.dart +++ b/lib/data/models/appconfig_model.dart @@ -79,6 +79,7 @@ class DBProduct { final String? productimage; final String scanuuid; final String? version; + bool? isforceupdate; final bool? iscontinuouschat; final String? updatedescription; final String? updatepackageaddress; @@ -91,6 +92,7 @@ class DBProduct { required this.productimage, required this.scanuuid, required this.version, + required this.isforceupdate, //该产品固件版本是否强制更新 required this.iscontinuouschat, required this.updatedescription, required this.updatepackageaddress, diff --git a/lib/data/models/appconfig_model.g.dart b/lib/data/models/appconfig_model.g.dart index f99ba35c6..fc97723ed 100644 --- a/lib/data/models/appconfig_model.g.dart +++ b/lib/data/models/appconfig_model.g.dart @@ -79,6 +79,7 @@ DBProduct _$DBProductFromJson(Map json) => DBProduct( productimage: json['productimage'] as String?, scanuuid: json['scanuuid'] as String, version: json['version'] as String?, + isforceupdate: json['isforceupdate'] as bool?, iscontinuouschat: json['iscontinuouschat'] as bool?, updatedescription: json['updatedescription'] as String?, updatepackageaddress: json['updatepackageaddress'] as String?, @@ -91,6 +92,7 @@ Map _$DBProductToJson(DBProduct instance) => { 'productimage': instance.productimage, 'scanuuid': instance.scanuuid, 'version': instance.version, + 'isforceupdate': instance.isforceupdate, 'iscontinuouschat': instance.iscontinuouschat, 'devicename': instance.devicename, 'updatedescription': instance.updatedescription, diff --git a/lib/data/services/account_service.dart b/lib/data/services/account_service.dart index 419749ee4..c2c9748bd 100644 --- a/lib/data/services/account_service.dart +++ b/lib/data/services/account_service.dart @@ -6,6 +6,7 @@ import '../../modules/login/controllers/googleLogin.dart'; import '../../modules/login/controllers/facebookLogin.dart'; import '../services/network/api.dart'; import 'db/database_helper.dart'; +import 'meeting/meeting_task_service.dart'; /// 账号管理服务 /// @@ -79,6 +80,10 @@ class AccountService extends GetxService { /// 返回操作结果,包含成功状态和错误信息 Future signOut() async { try { + // 清除会议记录任务列表 + final taskService = Get.find(); + taskService.cancelTask(); + // 上传会议记录操作记录 await Synchrodata.uploadOperationRecords(); diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 5b9f3754c..09eb7b842 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -8,8 +8,8 @@ import 'package:get_storage/get_storage.dart'; import 'package:get/get.dart'; import 'package:ble_service/ble_service.dart'; import 'package:agent_service/agent_service.dart'; -import '../../data/models/appconfig_model.dart'; -import '../../data/models/user_Info.dart' as userInfo; +import '/data/models/appconfig_model.dart'; + import '../../data/models/appconfig.dart'; import '../../core/utils/logger.dart'; import 'bluetooth_manager.dart'; @@ -27,6 +27,67 @@ class BleCommands { static const int CONTROL_CODEC = 0x05; } +/// 判断编解码控制指令是否成功 +/// +/// 根据 rawData 第二字节是否为非 0 来判断: +/// - 成功:第二字节不为 0 +/// - 失败或无法解析:返回 false +bool _isCodecControlSuccess(Map data) { + final dynamic raw = data['rawData']; + if (raw == null) return false; + + int? secondByte; + + if (raw is List) { + if (raw.length >= 2) { + final s = raw[1]; + if (s is int) { + secondByte = s; + } else if (s is num) { + secondByte = s.toInt(); + } else if (s is String) { + final t = s.trim(); + if (t.startsWith('0x') || t.startsWith('0X')) { + secondByte = int.tryParse(t.substring(2), radix: 16); + } else { + secondByte = int.tryParse(t); + } + } + } + } else if (raw is String) { + final normalized = raw.trim().replaceAll('[', '').replaceAll(']', ''); + final parts = + normalized.split(RegExp(r'[,\s]+')).where((e) => e.isNotEmpty).toList(); + if (parts.length >= 2) { + final t = parts[1].trim(); + if (t.startsWith('0x') || t.startsWith('0X')) { + secondByte = int.tryParse(t.substring(2), radix: 16); + } else { + secondByte = int.tryParse(t); + } + } + } else if (raw is Map) { + final dynamic s = raw['second'] ?? raw['status2'] ?? raw['result']; + if (s is int) { + secondByte = s; + } else if (s is num) { + secondByte = s.toInt(); + } else if (s is String) { + final t = s.trim(); + if (t.startsWith('0x') || t.startsWith('0X')) { + secondByte = int.tryParse(t.substring(2), radix: 16); + } else { + secondByte = int.tryParse(t); + } + } + } else { + // 无法解析的类型 + return false; + } + + return secondByte != null && secondByte != 0; +} + /// 蓝牙设备信息缓存模型 class DeviceInfo { final String address; @@ -135,6 +196,11 @@ class BleManager extends GetxService { // 设备信息缓存 final deviceInfo = Rx(null); // 使用 Rx 包装 + //添加编码器状态跟踪 + final _isCodecActive = false.obs; + bool get isCodecActive => _isCodecActive.value; + RxBool get isCodecActiveRx => _isCodecActive; + // 订阅 StreamSubscription? _statusSubscription; StreamSubscription? _dataSubscription; @@ -306,7 +372,18 @@ class BleManager extends GetxService { Logger.info('lxm--处理编解码控制命令'); // 处理通话状态 print(" CONTROL_CODEC: $commandId,$data"); - //_isCall.value = data['callStatus'] as bool? ?? false; + + final bool codecSuccess = _isCodecControlSuccess(data); + _isCodecActive.value = codecSuccess; // 更新编解码器状态 + + if (codecSuccess) { + print("lxm--编解码控制成功 (rawData 第二字节非 0)"); + Logger.info('lxm--编解码控制成功 (rawData 第二字节非 0)'); + } else { + print("lxm--编解码控制失败 (rawData 为 [162, 0] 或无法解析)"); + Logger.warning('lxm--编解码控制失败 (rawData 为 [162, 0] 或无法解析)'); + } + break; default: Logger.warning('lxm--未处理的命令ID: $commandId,数据: $data'); @@ -1091,6 +1168,11 @@ class BleManager extends GetxService { List? uuids, }) async { try { + // 在注册新设备前先清除旧设备信息 + if (macAddress != null && macAddress != currentDeviceAddress) { + clearDeviceInfo(); // 清除上一个设备的信息 + } + // 保存设备地址信息 if (macAddress != null) { currentDeviceAddress = macAddress; diff --git a/lib/data/services/deapsound_ai_service.dart b/lib/data/services/deapsound_ai_service.dart index da7181f4f..311af1184 100644 --- a/lib/data/services/deapsound_ai_service.dart +++ b/lib/data/services/deapsound_ai_service.dart @@ -78,6 +78,10 @@ class DeapsoundAIService implements AiService { ..body = jsonEncode(requestBody); final response = await client.send(request); + if (response.statusCode != 200) { + throw Exception('Unknown error'); + } + await for (final chunk in response.stream .transform(utf8.decoder) .transform(const LineSplitter())) { diff --git a/lib/data/services/meeting/meeting_task_service.dart b/lib/data/services/meeting/meeting_task_service.dart index fc6f6b8fc..b17d6adf0 100644 --- a/lib/data/services/meeting/meeting_task_service.dart +++ b/lib/data/services/meeting/meeting_task_service.dart @@ -8,16 +8,16 @@ import 'package:http/http.dart' as http; import '../../../core/utils/logger.dart'; import '../../../core/utils/synchrodata.dart'; import '../db/sqflite_api.dart'; -import '../deapsound_ai_service.dart'; import 'meeting_upload_service.dart'; -import '../usage_stats_service.dart'; -import '../../models/meeting_usage_stats.dart'; +import '../../../data/models/appconfig.dart'; +import 'summary_task_service.dart'; ///会议任务服务类,负责管理会议音频的转写任务处理流程,包括任务提交、状态轮询、结果处理和AI分析等功能。 class MeetingTaskService extends GetxService { final GetStorage _storage = GetStorage(); MeetingUploadService get _uploadService => Get.find(); + SummaryTaskService get _summaryTaskService => Get.find(); List _taskList = []; //待处理的会议任务列表 RxList taskFinishList = [].obs; //已处理的会议任务列表 @@ -26,42 +26,84 @@ class MeetingTaskService extends GetxService { String _userId = ''; + // final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc'; // + // final String _cluster = 'volc_auc_video_flash'; //字节跳动(火山引擎)语音服务配置 - final String _appid = '2316081933'; - final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En'; - final String _cluster = 'volc_auc_video_flash'; - final String _cluster1 = 'volc.bigasr.auc'; - final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc'; // - final String _serviceUrl1 = - 'https://openspeech.bytedance.com/api/v3/auc/bigmodel'; //大模型录音文件识别api地址 + // final String _appid = '2316081933'; + // final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En'; + // final String _cluster1 = 'volc.bigasr.auc'; + + // final String _serviceUrl1 = + // 'https://openspeech.bytedance.com/api/v3/auc/bigmodel'; //大模型录音文件识别api地址 + late final String _appid; + late final String _token; + late final String _cluster1; + late final String _serviceUrl1; + late final String _modelName; + late final String _modelVersion; Map _headers = {}; Map _headers1 = {}; int meetingId = 0; RxInt tasktype = 1.obs; //任务类型,1-转写中,2-总结中,3-完成 List textList = []; //转写结果列表 - String summary = ''; //AI总结内容 - String overview = ''; //AI概览内容 @override void onInit() { super.onInit(); - _headers = { - 'Authorization': 'Bearer; $_token', - 'Content-Type': 'application/json', - }; - _headers1 = { - 'X-Api-App-Key': _appid, - 'X-Api-Access-Key': _token, - 'X-Api-Resource-Id': _cluster1, - 'X-Api-Request-Id': '', - 'X-Api-Sequence': '-1', - }; + + _initialize(); _getAgent(); _getTaskList(); } + void cancelTask() { + _isTask = false; + _taskList.clear(); + _storage.remove("meeting_task_list"); + _storage.remove("meeting_task_finish_list"); + _uploadService.cancelUpload(); + _summaryTaskService.cancelTask(); + } + + Future _initialize() async { + try { + _appid = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? ''; + _token = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? ''; + _cluster1 = AppConfig.env('VOLC_OPENSPEECH_CLUSTER_BIGMODEL') ?? ''; + _serviceUrl1 = AppConfig.env('VOLC_OPENSPEECH_BASE_URL_BIGMODEL') ?? ''; + _modelName = AppConfig.env('VOLC_OPENSPEECH_MODEL_NAME') ?? 'bigmodel'; + _modelVersion = AppConfig.env('VOLC_OPENSPEECH_MODEL_VERSION') ?? '400'; + + if (_appid.isEmpty || + _token.isEmpty || + _cluster1.isEmpty || + _serviceUrl1.isEmpty || + _modelName.isEmpty || + _modelVersion.isEmpty) { + Logger.error('初始化会议任务服务失败:配置参数为空'); + return false; + } + + _headers = { + 'Authorization': 'Bearer; $_token', + 'Content-Type': 'application/json', + }; + _headers1 = { + 'X-Api-App-Key': _appid, + 'X-Api-Access-Key': _token, + 'X-Api-Resource-Id': _cluster1, + 'X-Api-Request-Id': '', + 'X-Api-Sequence': '-1', + }; + } catch (e) { + Logger.error('初始化会议任务服务失败:$e'); + return false; + } + return true; + } + void _getAgent() async { Map userInfo = _storage.read("user_info"); _userId = userInfo['user']['uid']; @@ -188,6 +230,7 @@ class MeetingTaskService extends GetxService { if (isDeleteTask) { _taskList.removeWhere((element) => element['id'] == id); _storage.write("meeting_task_list", _taskList); + _summaryTaskService.removeTask(id); } } @@ -210,8 +253,8 @@ class MeetingTaskService extends GetxService { "format": format, }, "request": { - "model_name": "bigmodel", // 模型名称,目前只有bigmodel - "model_version": "400", // 模型版本,400模型性能略有提升,且ITN有较大优化 + "model_name": _modelName, // 模型名称,目前只有bigmodel + "model_version": _modelVersion, // 模型版本,400模型性能略有提升,且ITN有较大优化 //"enable_itn": true, // 启用文本规范化,默认为true "enable_punc": true, // 启用标点,默认为false //"enable_ddc": false, // 启用语义顺滑,默认为false @@ -226,24 +269,24 @@ class MeetingTaskService extends GetxService { Logger.info("lxm--大模型asr请求体$requestBody1"); - final requestBody = jsonEncode({ - "app": { - "appid": _appid, - "token": _token, - "cluster": _cluster, - }, - "user": { - "uid": _userId, - }, - "audio": { - "format": format, - "url": audiourl, - }, - "additions": { - "language": language, - "with_speaker_info": isSpeaker ? "True" : "False", - } - }); + // final requestBody = jsonEncode({ + // "app": { + // "appid": _appid, + // "token": _token, + // "cluster": _cluster, + // }, + // "user": { + // "uid": _userId, + // }, + // "audio": { + // "format": format, + // "url": audiourl, + // }, + // "additions": { + // "language": language, + // "with_speaker_info": isSpeaker ? "True" : "False", + // } + // }); try { final response = await http.post( @@ -280,12 +323,12 @@ class MeetingTaskService extends GetxService { ///轮询语音转文字任务状态 ///[taskId] 任务ID Future> _queryTask(String taskId) async { - final queryBody = jsonEncode({ - "appid": _appid, - "token": _token, - "id": taskId, - "cluster": _cluster, - }); + // final queryBody = jsonEncode({ + // "appid": _appid, + // "token": _token, + // "id": taskId, + // "cluster": _cluster, + // }); try { _headers1['X-Api-Request-Id'] = taskId; @@ -323,7 +366,8 @@ class MeetingTaskService extends GetxService { Logger.info("lxm---大模型asr响应体结果$respDic['result']['text']"); - if (respDic['result']['text'] != null) { + if (respDic['result']['text'] != null && + respDic['result']['text'] != '') { await _addTextList( item['id'], //会议ID respDic['result']['utterances'], //语音转文字结果 @@ -412,75 +456,6 @@ class MeetingTaskService extends GetxService { } catch (_) {} // 触发AI分析,分别请求总结和概览 - aiServiceMessage(id, text, prompt, outline); - } - - ///触发AI分析,分别请求总结和概览 - ///[id] 会议ID - ///[text] 语音转文字结果 - ///[prompt] AI总结提示词 - ///[outline] AI概览提示词 - void aiServiceMessage(int id, String text, String prompt, String outline) { - int awaitQuantity = 0; - String summaryText = ''; - String overviewText = ''; - DeapsoundAIService().sendMessage( - messages: [ - { - 'role': 'system', - 'content': prompt, - }, - {'role': 'user', 'content': text}, - ], - ).then((value) { - awaitQuantity++; - summaryText = value; - Logger.info("lxm---大模型asr总结结果$summaryText"); - if (awaitQuantity >= 2) { - _aiServiceValue(id, summaryText, overviewText); - } - }); - DeapsoundAIService().sendMessage( - messages: [ - { - 'role': 'system', - 'content': outline, - }, - {'role': 'user', 'content': text}, - ], - ).then((value) { - awaitQuantity++; - overviewText = value; - Logger.info("lxm---大模型asr概览结果$overviewText"); - if (awaitQuantity >= 2) { - _aiServiceValue(id, summaryText, overviewText); - } - }); - } - - ///处理AI分析完成后的结果,包括数据库存储和状态更新 - ///[id] 会议ID - ///[summaryText] 总结结果 - ///[overviewText] 概览结果 - void _aiServiceValue(int id, String summaryText, String overviewText) { - SqfliteApi.editMeeting(id, { - 'tasktype': 3, - 'summary': summaryText, - 'overview': overviewText, - }); - if (id == meetingId) { - summary = summaryText; - overview = overviewText; - tasktype.value = 3; - } - Map taskFinishData = taskFinishList.firstWhere( - (element) => element['id'] == id, - orElse: () => {}, - ); - if (taskFinishData.isNotEmpty) { - taskFinishData['tasktype'] = 3; - taskFinishList.refresh(); - } - _storage.write("meeting_task_finish_list", taskFinishList); + _summaryTaskService.addTaskList(id, text, prompt, outline); } } diff --git a/lib/data/services/meeting/meeting_upload_service.dart b/lib/data/services/meeting/meeting_upload_service.dart index 13a98ef2d..63fde0f02 100644 --- a/lib/data/services/meeting/meeting_upload_service.dart +++ b/lib/data/services/meeting/meeting_upload_service.dart @@ -24,6 +24,13 @@ class MeetingUploadService extends GetxService { _getUploadList(); } + void cancelUpload() { + _isUpload = false; + _handled = 0; + uploadList.clear(); + _storage.remove("meeting_upload_list"); + } + void _getUploadList() { uploadList.value = _storage.read("meeting_upload_list") ?? []; executeUpload(); diff --git a/lib/data/services/meeting/summary_task_service.dart b/lib/data/services/meeting/summary_task_service.dart new file mode 100644 index 000000000..5657c8b74 --- /dev/null +++ b/lib/data/services/meeting/summary_task_service.dart @@ -0,0 +1,160 @@ +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +import '../../../core/utils/logger.dart'; +import '../db/sqflite_api.dart'; +import '../deapsound_ai_service.dart'; +import 'meeting_task_service.dart'; + +class SummaryTaskService extends GetxService { + final _taskService = Get.find(); + + final GetStorage _storage = GetStorage(); + + List _taskList = []; + + bool _isTask = false; + + String summary = ''; //AI总结内容 + String overview = ''; //AI概览内容 + + @override + void onInit() { + super.onInit(); + _getTaskList(); + } + + void cancelTask() { + _taskList.clear(); + _storage.remove("summary_task_list"); + } + + void _getTaskList() { + _taskList = _storage.read("summary_task_list") ?? []; + _executeTask(); + } + + Future addTaskList( + int id, String text, String prompt, String outline) async { + Logger.debug("添加总结任务列表"); + _taskList.add({ + 'id': id, + 'text': text, + 'prompt': prompt, + 'outline': outline, + }); + _storage.write("summary_task_list", _taskList); + _executeTask(); + } + + void _executeTask() async { + if (_taskList.isEmpty) return; + if (_isTask) return; + _isTask = true; + var task = _taskList.first; + _aiServiceMessage( + task['id'], + task['text'], + task['prompt'], + task['outline'], + ); + Logger.debug("执行总结任务列表"); + } + + void removeTask(int id) { + _taskList.removeWhere((element) => element['id'] == id); + _storage.write("summary_task_list", _taskList); + } + + void _aiServiceMessage(int id, String text, String prompt, String outline) { + final ai = DeapsoundAIService(); + String summaryText = ''; + String overviewText = ''; + var summaryDone = false; + var overviewDone = false; + var summarySuccess = false; + var overviewSuccess = false; + + void tryFinish() { + if (summaryDone && overviewDone) { + if (summarySuccess && + overviewSuccess && + summaryText.isNotEmpty && + overviewText.isNotEmpty) { + _aiServiceValue(id, summaryText, overviewText); + } else { + _isTask = false; + _executeTask(); + } + } + } + + final summaryStream = ai.sendMessageStream(messages: [ + {'role': 'system', 'content': prompt}, + {'role': 'user', 'content': text}, + ]).timeout(const Duration(seconds: 120)); + + final overviewStream = ai.sendMessageStream(messages: [ + {'role': 'system', 'content': outline}, + {'role': 'user', 'content': text}, + ]).timeout(const Duration(seconds: 120)); + + summaryStream.listen((chunk) { + summaryText += chunk; + }, onError: (error) { + summaryDone = true; + summarySuccess = false; + tryFinish(); + }, onDone: () { + summaryDone = true; + summarySuccess = true; + tryFinish(); + }, cancelOnError: true); + + overviewStream.listen((chunk) { + overviewText += chunk; + }, onError: (error) { + overviewDone = true; + overviewSuccess = false; + tryFinish(); + }, onDone: () { + overviewDone = true; + overviewSuccess = true; + tryFinish(); + }, cancelOnError: true); + } + + Future _aiServiceValue( + int id, String summaryText, String overviewText) async { + final exists = _taskList.any((item) => item['id'] == id); + if (!exists) { + _isTask = false; + _executeTask(); + return; + } + await SqfliteApi.editMeeting(id, { + 'tasktype': 3, + 'summary': summaryText, + 'overview': overviewText, + }); + if (id == _taskService.meetingId) { + summary = summaryText; + overview = overviewText; + _taskService.tasktype.value = 3; + } + Map taskFinishData = _taskService.taskFinishList.firstWhere( + (element) => element['id'] == id, + orElse: () => {}, + ); + if (taskFinishData.isNotEmpty) { + taskFinishData['tasktype'] = 3; + _taskService.taskFinishList.refresh(); + } + _storage.write("meeting_task_finish_list", _taskService.taskFinishList); + _taskList.removeWhere((element) => element['id'] == id); + _storage.write("summary_task_list", _taskList); + _isTask = false; + _executeTask(); + Logger.debug("总结任务完成"); + } +} diff --git a/lib/data/services/ota_check_service.dart b/lib/data/services/ota_check_service.dart index 5caa31493..13a5f09d5 100644 --- a/lib/data/services/ota_check_service.dart +++ b/lib/data/services/ota_check_service.dart @@ -57,6 +57,7 @@ class OtaCheckService extends GetxService { /// [isFullyConnected] 设备是否完全连接 /// 返回是否成功执行了检查 Future performOtaCheck(bool isFullyConnected) async { + bool isForceUpdate = false; try { // 检查是否应该进行OTA检查 if (!shouldCheckOta()) { @@ -95,7 +96,12 @@ class OtaCheckService extends GetxService { _otaCoreService.isNewVersionAvailable(currentVersion, latestVersion); if (hasUpdate) { - Logger.d(_tag, '发现新版本,准备显示升级提醒'); + isForceUpdate = latestInfo['isForceUpdate'] as bool? ?? false; + if (isForceUpdate) { + Logger.d(_tag, '发现强制更新版本,准备显示升级提醒'); + } else { + Logger.d(_tag, '发现新版本,准备显示升级提醒'); + } // 再次确认当前在Home页面(防止异步执行期间页面切换) if (_isCurrentlyOnHomePage()) { @@ -109,12 +115,18 @@ class OtaCheckService extends GetxService { } // 标记为已检查(无论是否有更新都标记为已检查) - _markAsChecked(); + if (!isForceUpdate) { + _markAsChecked(); + } + _isCheckingInProgress = false; return true; } catch (e) { Logger.e(_tag, '检查OTA升级失败: $e'); // 即使检查失败,也标记为已检查,避免重复尝试 - _markAsChecked(); + if (!isForceUpdate) { + _markAsChecked(); + } + _isCheckingInProgress = false; return false; } } diff --git a/lib/data/services/pay_service.dart b/lib/data/services/pay_service.dart index 79ab577f9..cece8bcab 100644 --- a/lib/data/services/pay_service.dart +++ b/lib/data/services/pay_service.dart @@ -1,13 +1,5 @@ import 'package:get/get.dart'; -import 'dart:convert'; -import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:in_app_purchase/in_app_purchase.dart'; -import 'package:in_app_purchase_android/in_app_purchase_android.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import '../../data/services/network/api.dart'; +import '/data/services/network/api.dart'; import 'package:tobias/tobias.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../data/models/user_Info.dart'; diff --git a/lib/data/services/translation_usage_service.dart b/lib/data/services/translation_usage_service.dart new file mode 100644 index 000000000..5c4c5c72f --- /dev/null +++ b/lib/data/services/translation_usage_service.dart @@ -0,0 +1,545 @@ +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import '../models/translation_usage_stats.dart'; +import '../../core/utils/logger.dart'; + +/// 翻译使用量统计服务 +class TranslationUsageService extends GetxService { + static const String _storageKey = 'translation_usage_stats'; + static const String _dailyStatsKey = 'daily_translation_stats'; + static const String _costConfigKey = 'translation_cost_config'; + + final GetStorage _storage = GetStorage(); + + // 实时统计数据 + final Rx realtimeStats = RealtimeUsageStats().obs; + + // 当日统计数据 + final Rx todayStats = + TranslationUsageStats.empty('').obs; + + // 成本配置 + final RxMap costConfig = {}.obs; + + // 预警阈值 + final RxDouble dailyCostThreshold = 50.0.obs; // 日成本预警阈值 + final RxDouble monthlyCostThreshold = 300.0.obs; // 月成本预警阈值 + + @override + void onInit() { + super.onInit(); + _initializeService(); + } + + /// 初始化服务 + void _initializeService() { + _loadCostConfig(); + _loadTodayStats(); + Logger.info('翻译使用量统计服务初始化完成'); + } + + /// 加载成本配置 + void _loadCostConfig() { + try { + final configData = _storage.read(_costConfigKey); + if (configData != null) { + costConfig.value = Map.from(configData); + } else { + // 设置默认成本配置(每千字符的成本,单位:元) + costConfig.value = { + 'translation_per_1k_chars': 0.05, // 翻译:每千字符0.05元 + 'asr_per_minute': 0.02, // ASR:每分钟0.02元 + 'tts_per_1k_chars': 0.03, // TTS:每千字符0.03元 + 'ai_summary_per_request': 0.01, // AI摘要:每次请求0.01元 + }; + _saveCostConfig(); + } + } catch (e) { + Logger.error('加载成本配置失败: $e'); + } + } + + /// 保存成本配置 + void _saveCostConfig() { + try { + _storage.write(_costConfigKey, costConfig.value); + } catch (e) { + Logger.error('保存成本配置失败: $e'); + } + } + + /// 加载当日统计数据 + void _loadTodayStats() { + try { + final today = DateTime.now(); + final todayKey = + '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; + + final statsData = _storage.read('${_dailyStatsKey}_$todayKey'); + if (statsData != null) { + todayStats.value = TranslationUsageStats.fromJson( + Map.from(statsData)); + } else { + todayStats.value = TranslationUsageStats.empty(getCurrentUserId()); + } + } catch (e) { + Logger.error('加载当日统计数据失败: $e'); + todayStats.value = TranslationUsageStats.empty(getCurrentUserId()); + } + } + + /// 获取当前用户ID + String getCurrentUserId() { + try { + final userInfo = _storage.read('user_info'); + if (userInfo != null && userInfo['user'] != null) { + return userInfo['user']['uid'] ?? 'anonymous'; + } + } catch (e) { + Logger.warning('获取用户ID失败: $e'); + } + return 'anonymous'; + } + + /// 记录翻译API调用 + void recordTranslationApiCall({ + required String sourceText, + required String targetText, + required String sourceLanguage, + required String targetLanguage, + required String mode, + }) { + try { + // 更新实时统计 + realtimeStats.value.translationCount++; + realtimeStats.value.sourceTextCharacters += sourceText.length; + realtimeStats.value.ttsTextCharacters += targetText.length; + realtimeStats.value.currentLanguagePair = + '$sourceLanguage-$targetLanguage'; + realtimeStats.value.currentMode = mode; + realtimeStats.refresh(); + + // 更新当日统计 - 使用多维度统计 + final languagePair = '$sourceLanguage→$targetLanguage'; + + // 更新语言对统计 + final newLanguagePairStats = Map.from( + todayStats.value.languagePairStats); + if (newLanguagePairStats.containsKey(languagePair)) { + final existing = newLanguagePairStats[languagePair]!; + newLanguagePairStats[languagePair] = LanguagePairStats( + languagePair: languagePair, + translationCount: existing.translationCount + 1, + sourceCharacters: existing.sourceCharacters + sourceText.length, + cost: existing.cost + _calculateTranslationCost(sourceText.length), + ); + } else { + newLanguagePairStats[languagePair] = LanguagePairStats( + languagePair: languagePair, + translationCount: 1, + sourceCharacters: sourceText.length, + cost: _calculateTranslationCost(sourceText.length), + ); + } + + // 更新模式统计 + final newModeStats = + Map.from(todayStats.value.modeStats); + if (newModeStats.containsKey(mode)) { + final existing = newModeStats[mode]!; + newModeStats[mode] = existing.copyWith( + apiCalls: existing.apiCalls + 1, + sourceCharacters: existing.sourceCharacters + sourceText.length, + ttsCharacters: existing.ttsCharacters + targetText.length, + cost: existing.cost + _calculateTranslationCost(sourceText.length), + ); + } else { + newModeStats[mode] = TranslationModeStats( + apiCalls: 1, + sourceCharacters: sourceText.length, + ttsCharacters: targetText.length, + cost: _calculateTranslationCost(sourceText.length), + ); + } + + todayStats.value = todayStats.value.copyWith( + translationApiCalls: todayStats.value.translationApiCalls + 1, + sourceTextCharacters: + todayStats.value.sourceTextCharacters + sourceText.length, + ttsTextCharacters: + todayStats.value.ttsTextCharacters + targetText.length, + languagePairStats: newLanguagePairStats, + modeStats: newModeStats, + estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( + translationApiCalls: todayStats.value.translationApiCalls + 1, + sourceTextCharacters: + todayStats.value.sourceTextCharacters + sourceText.length, + ttsTextCharacters: + todayStats.value.ttsTextCharacters + targetText.length, + )), + ); + + _saveTodayStats(); + _checkCostThreshold(); + + Logger.info( + '记录翻译API调用: 源文本${sourceText.length}字符, 目标文本${targetText.length}字符'); + } catch (e) { + Logger.error('记录翻译API调用失败: $e'); + } + } + + /// 记录ASR API调用 - 优化版(精确计费参数) + void recordAsrApiCall({ + required int effectiveDurationSeconds, // 有效语音时长(VAD检测的实际语音) + required int totalDurationSeconds, // 总会话时长(包含静音) + required String language, + required String mode, // 翻译模式 + String? audioFormat, // 音频格式(可选) + int? sampleRate, // 采样率(可选) + }) { + try { + // 更新实时统计(使用有效语音时长作为主要计费依据) + realtimeStats.value.effectiveAudioDuration += effectiveDurationSeconds; + realtimeStats.value.totalSessionDuration += totalDurationSeconds; + realtimeStats.refresh(); + + // 更新模式统计 + final newModeStats = + Map.from(todayStats.value.modeStats); + if (newModeStats.containsKey(mode)) { + final existing = newModeStats[mode]!; + newModeStats[mode] = existing.copyWith( + effectiveAudioDuration: + existing.effectiveAudioDuration + effectiveDurationSeconds, + cost: existing.cost + _calculateAsrCost(effectiveDurationSeconds), + ); + } else { + newModeStats[mode] = TranslationModeStats( + effectiveAudioDuration: effectiveDurationSeconds, + cost: _calculateAsrCost(effectiveDurationSeconds), + ); + } + + // 更新当日统计 + todayStats.value = todayStats.value.copyWith( + asrApiCalls: todayStats.value.asrApiCalls + 1, + effectiveAudioDuration: + todayStats.value.effectiveAudioDuration + effectiveDurationSeconds, + totalSessionDuration: + todayStats.value.totalSessionDuration + totalDurationSeconds, + modeStats: newModeStats, + estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( + asrApiCalls: todayStats.value.asrApiCalls + 1, + effectiveAudioDuration: todayStats.value.effectiveAudioDuration + + effectiveDurationSeconds, + )), + ); + + _saveTodayStats(); + + Logger.info( + 'ASR统计: 有效语音${effectiveDurationSeconds}秒, 总时长${totalDurationSeconds}秒, 语言$language, 模式$mode'); + } catch (e) { + Logger.error('记录ASR API调用失败: $e'); + } + } + + /// 记录TTS API调用 + void recordTtsApiCall({ + required String text, + required int durationSeconds, + required String language, + }) { + try { + // 更新实时统计 + realtimeStats.value.totalSessionDuration += durationSeconds; + realtimeStats.refresh(); + + // 更新当日统计(TTS按字符数计费,不是按时长) + todayStats.value = todayStats.value.copyWith( + ttsApiCalls: todayStats.value.ttsApiCalls + 1, + ttsTextCharacters: todayStats.value.ttsTextCharacters + text.length, + totalSessionDuration: + todayStats.value.totalSessionDuration + durationSeconds, + estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( + ttsApiCalls: todayStats.value.ttsApiCalls + 1, + ttsTextCharacters: todayStats.value.ttsTextCharacters + text.length, + )), + ); + + _saveTodayStats(); + + Logger.info('记录TTS API调用: 文本${text.length}字符, 时长${durationSeconds}秒'); + } catch (e) { + Logger.error('记录TTS API调用失败: $e'); + } + } + + /// 记录AI摘要API调用 + void recordAiSummaryApiCall() { + try { + // 更新当日统计 + todayStats.value = todayStats.value.copyWith( + aiSummaryApiCalls: todayStats.value.aiSummaryApiCalls + 1, + estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( + aiSummaryApiCalls: todayStats.value.aiSummaryApiCalls + 1, + )), + ); + + _saveTodayStats(); + + Logger.info('记录AI摘要API调用'); + } catch (e) { + Logger.error('记录AI摘要API调用失败: $e'); + } + } + + /// 开始新会话 + void startNewSession() { + realtimeStats.value.reset(); + + // 更新当日会话数 + todayStats.value = todayStats.value.copyWith( + sessionCount: todayStats.value.sessionCount + 1, + ); + + _saveTodayStats(); + Logger.info('开始新的翻译会话'); + } + + /// 计算预估成本 - 优化版(基于精确计费参数) + double _calculateEstimatedCost(TranslationUsageStats stats) { + double cost = 0.0; + + // 翻译成本:基于源文本字符数 + cost += (stats.sourceTextCharacters / 1000) * + (costConfig['translation_per_1k_chars'] ?? 0.05); + + // ASR成本:基于有效音频时长 + cost += (stats.effectiveAudioDuration / 60) * + (costConfig['asr_per_minute'] ?? 0.02); + + // TTS成本:基于TTS文本字符数 + cost += (stats.ttsTextCharacters / 1000) * + (costConfig['tts_per_1k_chars'] ?? 0.03); + + // AI摘要成本(已废弃但保留兼容性) + cost += stats.aiSummaryApiCalls * + (costConfig['ai_summary_per_request'] ?? 0.01); + + return cost; + } + + /// 计算翻译成本(单次调用) + double _calculateTranslationCost(int sourceCharacters) { + return (sourceCharacters / 1000) * + (costConfig['translation_per_1k_chars'] ?? 0.05); + } + + /// 计算ASR成本(单次调用) + double _calculateAsrCost(int durationSeconds) { + return (durationSeconds / 60) * (costConfig['asr_per_minute'] ?? 0.02); + } + + /// 计算TTS成本(单次调用) + double _calculateTtsCost(int textCharacters) { + return (textCharacters / 1000) * (costConfig['tts_per_1k_chars'] ?? 0.03); + } + + /// 保存当日统计数据 + void _saveTodayStats() { + try { + final today = DateTime.now(); + final todayKey = + '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; + _storage.write('${_dailyStatsKey}_$todayKey', todayStats.value.toJson()); + } catch (e) { + Logger.error('保存当日统计数据失败: $e'); + } + } + + /// 检查成本阈值 + void _checkCostThreshold() { + final todayCost = todayStats.value.estimatedCost; + + if (todayCost >= dailyCostThreshold.value) { + _showCostWarning('今日翻译成本已达到预警阈值:¥${todayCost.toStringAsFixed(2)}'); + } + } + + /// 显示成本预警 + void _showCostWarning(String message) { + Get.snackbar( + '成本预警', + message, + snackPosition: SnackPosition.TOP, + backgroundColor: Get.theme.colorScheme.error.withOpacity(0.1), + colorText: Get.theme.colorScheme.error, + duration: const Duration(seconds: 5), + ); + } + + /// 获取历史统计数据 + Future> getHistoryStats({ + required DateTime startDate, + required DateTime endDate, + }) async { + try { + final List historyStats = []; + + for (DateTime date = startDate; + date.isBefore(endDate) || date.isAtSameMomentAs(endDate); + date = date.add(const Duration(days: 1))) { + final dateKey = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + final statsData = _storage.read('${_dailyStatsKey}_$dateKey'); + + if (statsData != null) { + historyStats.add(TranslationUsageStats.fromJson( + Map.from(statsData))); + } + } + + return historyStats; + } catch (e) { + Logger.error('获取历史统计数据失败: $e'); + return []; + } + } + + /// 清理过期数据(保留最近90天) + void cleanupOldData() { + try { + final cutoffDate = DateTime.now().subtract(const Duration(days: 90)); + final allKeys = _storage.getKeys(); + + for (final key in allKeys) { + if (key.startsWith(_dailyStatsKey)) { + final dateStr = key.replaceFirst('${_dailyStatsKey}_', ''); + try { + final date = DateTime.parse(dateStr); + if (date.isBefore(cutoffDate)) { + _storage.remove(key); + Logger.info('清理过期统计数据: $key'); + } + } catch (e) { + // 忽略解析错误的键 + } + } + } + } catch (e) { + Logger.error('清理过期数据失败: $e'); + } + } + + /// 更新成本配置 + void updateCostConfig(Map newConfig) { + costConfig.value = newConfig; + _saveCostConfig(); + + // 重新计算当日成本 + todayStats.value = todayStats.value.copyWith( + estimatedCost: _calculateEstimatedCost(todayStats.value), + ); + _saveTodayStats(); + + Logger.info('更新成本配置完成'); + } + + /// 更新预警阈值 + void updateThresholds({ + double? dailyThreshold, + double? monthlyThreshold, + }) { + if (dailyThreshold != null) { + dailyCostThreshold.value = dailyThreshold; + } + if (monthlyThreshold != null) { + monthlyCostThreshold.value = monthlyThreshold; + } + + // 保存阈值配置 + _storage.write('daily_cost_threshold', dailyCostThreshold.value); + _storage.write('monthly_cost_threshold', monthlyCostThreshold.value); + + Logger.info('更新预警阈值完成'); + } + + /// 获取月度统计汇总 + Future getMonthlyStats(int year, int month) async { + try { + final startDate = DateTime(year, month, 1); + final endDate = DateTime(year, month + 1, 0); // 月末 + + final dailyStats = await getHistoryStats( + startDate: startDate, + endDate: endDate, + ); + + if (dailyStats.isEmpty) { + return TranslationUsageStats.empty(getCurrentUserId()); + } + + // 汇总月度数据 + int totalTranslationCalls = 0; + int totalAsrCalls = 0; + int totalTtsCalls = 0; + int totalAiSummaryCalls = 0; + int totalSourceChars = 0; + int totalTargetChars = 0; + int totalRecordingDuration = 0; + int totalTtsPlayDuration = 0; + int totalSessions = 0; + double totalCost = 0.0; + + final Map combinedLanguagePairUsage = {}; + final Map combinedModeUsage = {}; + + for (final stats in dailyStats) { + totalTranslationCalls += stats.translationApiCalls; + totalAsrCalls += stats.asrApiCalls; + totalTtsCalls += stats.ttsApiCalls; + totalAiSummaryCalls += stats.aiSummaryApiCalls; + totalSourceChars += stats.sourceCharacterCount; + totalTargetChars += stats.targetCharacterCount; + totalRecordingDuration += stats.audioRecordingDuration; + totalTtsPlayDuration += stats.ttsPlaybackDuration; + totalSessions += stats.sessionCount; + totalCost += stats.estimatedCost; + + // 合并语言对使用统计 + stats.languagePairUsage.forEach((key, value) { + combinedLanguagePairUsage[key] = + (combinedLanguagePairUsage[key] ?? 0) + value; + }); + + // 合并模式使用统计 + stats.modeUsage.forEach((key, value) { + combinedModeUsage[key] = (combinedModeUsage[key] ?? 0) + value; + }); + } + + return TranslationUsageStats( + id: 'monthly_${year}_$month', + date: startDate, + userId: getCurrentUserId(), + timeGranularity: 'month', + translationApiCalls: totalTranslationCalls, + asrApiCalls: totalAsrCalls, + ttsApiCalls: totalTtsCalls, + aiSummaryApiCalls: totalAiSummaryCalls, + sourceTextCharacters: totalSourceChars, + effectiveAudioDuration: totalRecordingDuration, + ttsTextCharacters: totalTargetChars, + totalSessionDuration: totalTtsPlayDuration, + sessionCount: totalSessions, + estimatedCost: totalCost, + ); + } catch (e) { + Logger.error('获取月度统计失败: $e'); + return TranslationUsageStats.empty(getCurrentUserId()); + } + } +} diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index bb93d0627..7a845e44c 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -1,9 +1,9 @@ -import 'package:voitrans/core/utils/logger.dart'; -import 'package:voitrans/modules/agent/views/google_navigation_card.dart'; -import 'package:voitrans/modules/agent/views/music_card.dart'; -import 'package:voitrans/modules/agent/views/qqmusic_card.dart'; -import 'package:voitrans/modules/agent/views/weather_baidu.dart'; -import 'package:voitrans/modules/agent/views/weather_hefeng.dart'; +import '/core/utils/logger.dart'; +import '/modules/agent/views/google_navigation_card.dart'; +import '/modules/agent/views/music_card.dart'; +import '/modules/agent/views/qqmusic_card.dart'; +import '/modules/agent/views/weather_baidu.dart'; +import '/modules/agent/views/weather_hefeng.dart'; import '../../../data/models/song_model.dart'; import '../../../modules/agent/views/navigation_card.dart'; diff --git a/lib/modules/agent/views/qqmusic_card.dart b/lib/modules/agent/views/qqmusic_card.dart index 749c8ef14..5e4086c96 100644 --- a/lib/modules/agent/views/qqmusic_card.dart +++ b/lib/modules/agent/views/qqmusic_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import 'package:voitrans/modules/agent/widgets/circular_progress_button.dart'; +import '/modules/agent/widgets/circular_progress_button.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/qq_music.dart'; import '../../../data/models/song_model.dart'; diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 6718af00e..afca3899d 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -1,6 +1,6 @@ // import 'dart:ffi'; // Not supported on web; not needed here -import 'package:voitrans/data/models/appconfig_model.dart'; +import '/data/models/appconfig_model.dart'; import '/core/utils/logger.dart'; import '/data/models/user_Info.dart'; diff --git a/lib/modules/devices/views/devices_view.dart b/lib/modules/devices/views/devices_view.dart index 846004a4e..7a69dd5b0 100644 --- a/lib/modules/devices/views/devices_view.dart +++ b/lib/modules/devices/views/devices_view.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import '../../../routes/app_routes.dart'; import '/modules/devices/controllers/devices_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -201,103 +202,107 @@ class DevicesView extends GetView { required DeviceType deviceType, required bool isDarkMode, }) { - return Container( - margin: EdgeInsets.only(bottom: 12.h), - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - // 设备图标 - Container( - width: 48.w, - height: 48.w, - decoration: BoxDecoration( - color: - isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], - borderRadius: BorderRadius.circular(12.r), + return GestureDetector( + onTap: () => Get.toNamed(Routes.eqSettings), + child: Container( + margin: EdgeInsets.only(bottom: 12.h), + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isDarkMode ? AppColors.cardBackground : Colors.white, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), ), - child: Icon( - deviceType.icon, - color: isDarkMode ? Colors.white : Colors.black87, - size: 24.sp, + ], + ), + child: Row( + children: [ + // 设备图标 + Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[100], + borderRadius: BorderRadius.circular(12.r), + ), + child: Icon( + deviceType.icon, + color: isDarkMode ? Colors.white : Colors.black87, + size: 24.sp, + ), ), - ), - SizedBox(width: 16.w), - // 设备信息 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - device.devicename.isNotEmpty - ? device.devicename - : "deviceTypeUnknown".tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - ), - ), - SizedBox(height: 4.h), - Text( - device.devicemac.isNotEmpty - ? device.devicemac - : "noMacAddress".tr, - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white70 : Colors.grey[600], + SizedBox(width: 16.w), + // 设备信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.devicename.isNotEmpty + ? device.devicename + : "deviceTypeUnknown".tr, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), ), - ), - if (device.license.isNotEmpty) ...[ SizedBox(height: 4.h), Text( - "${"licenseCode".tr}: ${device.license}", + device.devicemac.isNotEmpty + ? device.devicemac + : "noMacAddress".tr, style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white54 : Colors.grey[500], + fontSize: 14.sp, + color: isDarkMode ? Colors.white70 : Colors.grey[600], ), ), + if (device.license.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + "${"licenseCode".tr}: ${device.license}", + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white54 : Colors.grey[500], + ), + ), + ], ], - ], - ), - ), - // 解绑按钮 - GestureDetector( - onTap: () => _showUnbindDialog(device, isDarkMode), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 12.w, - vertical: 6.h, ), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular(6.r), - border: Border.all( - color: Colors.red.withOpacity(0.3), - width: 1, + ), + // 解绑按钮 + GestureDetector( + onTap: () => _showUnbindDialog(device, isDarkMode), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.h, ), - ), - child: Text( - "unbind".tr, - style: TextStyle( - fontSize: 12.sp, - color: Colors.red, - fontWeight: FontWeight.w500, + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(6.r), + border: Border.all( + color: Colors.red.withOpacity(0.3), + width: 1, + ), + ), + child: Text( + "unbind".tr, + style: TextStyle( + fontSize: 12.sp, + color: Colors.red, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/modules/eq_settings/bindings/eq_settings_binding.dart b/lib/modules/eq_settings/bindings/eq_settings_binding.dart new file mode 100644 index 000000000..1f2884bd2 --- /dev/null +++ b/lib/modules/eq_settings/bindings/eq_settings_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; + +import '../controllers/eq_settings_controller.dart'; + +class EqSettingsBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => EqSettingsController()); + } +} diff --git a/lib/modules/eq_settings/controllers/eq_settings_controller.dart b/lib/modules/eq_settings/controllers/eq_settings_controller.dart new file mode 100644 index 000000000..12a373c0c --- /dev/null +++ b/lib/modules/eq_settings/controllers/eq_settings_controller.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +import '../../../core/utils/logger.dart'; +import '../../../routes/app_routes.dart'; +import '../views/widgets/row_confirm_widget.dart'; +import '../views/widgets/row_equalizer_widget.dart'; +import '../views/widgets/row_grade_widget.dart'; +import '../views/widgets/row_input_widget.dart'; +import '../views/widgets/row_keypress_widget.dart'; +import '../views/widgets/row_radio_widget.dart'; +import '../views/widgets/row_switch_widget.dart'; +import '../views/widgets/row_timer_widget.dart'; + +class EqSettingsController extends GetxController { + final RxList uiList = [ + { + 'title': '音效', + 'templates': [ + { + 'type': 'equalizer', + 'name': '均衡器', + 'value': [], + 'options': [ + { + 'label': '默认', + 'value': [1, 2, 3, 4, 5, 4, 3, 2, 1, 0], + 'default': true, + }, + { + 'label': '流行', + 'value': [1, 0, 3, 4, 5, 4, 1, 2, 1, 0], + 'default': true, + }, + { + 'label': '摇滚', + 'value': [2, 3, 4, 5, 4, 3, 2, 1, 0, -1], + 'default': true, + }, + { + 'label': '爵士', + 'value': [3, 4, 0, 4, 3, 2, 1, 0, -1, -2], + 'default': true, + }, + { + 'label': '经典', + 'value': [3, 4, 2, 4, 3, 2, 1, 0, -1, -2], + 'default': true, + }, + { + 'label': '乡村', + 'value': [3, 4, 1, 4, 3, 2, 1, 0, -1, -2], + 'default': true, + }, + ], + 'xAxis': [ + '8', + '31', + '62', + '125', + '250', + '500', + '1K', + '2K', + '4K', + '16K' + ], + 'onCallback': (List value) { + Logger.d('equalizer', '均衡器:$value'); + }, + } + ], + }, + { + 'title': '按键功能', + 'templates': [ + { + 'type': 'keypress', + 'data': [ + {'key': 'key1', 'label': '单击', 'left': 1, 'right': 1}, + {'key': 'key2', 'label': '双击', 'left': 1, 'right': 1}, + {'key': 'key3', 'label': '三击', 'left': 1, 'right': 1}, + {'key': 'key4', 'label': '长按', 'left': 1, 'right': 1}, + ], + 'options': [ + {'label': '无作用', 'value': 0}, + {'label': '回拨电话', 'value': 1}, + {'label': '语音助手', 'value': 2}, + {'label': '上一曲', 'value': 3}, + {'label': '下一曲', 'value': 4}, + {'label': '音量加', 'value': 5}, + {'label': '音量减', 'value': 6}, + {'label': '播放暂停', 'value': 7}, + {'label': '游戏模式', 'value': 8}, + {'label': '降噪设置', 'value': 9}, + {'label': 'EQ切换', 'value': 10}, + ], + 'onCallback': (Map value) { + Logger.d('keypress', '按键功能:$value'); + }, + }, + ], + }, + { + 'title': '耳机功能', + 'templates': [ + { + 'type': 'input', + 'name': '修改名称', + 'value': 'voitrans', + 'onCallback': (String value) { + Logger.d('input', '修改名称:$value'); + }, + }, + { + 'type': 'radio', + 'name': '工作模式', + 'value': 1, + 'options': [ + {'label': '游戏模式', 'value': 1}, + {'label': '娱乐模式', 'value': 2}, + {'label': '睡眠模式', 'value': 3}, + ], + 'onCallback': (int value) { + Logger.d('radio', '工作模式:$value'); + }, + }, + { + 'type': 'switch', + 'name': '闪灯开关', + 'value': false, + 'onCallback': (bool value) { + Logger.d('switch', '闪灯开关:$value'); + }, + }, + { + 'type': 'switch', + 'name': '查找设备', + 'value': false, + 'description': '在设备上播放声音', + 'onCallback': (bool value) { + Logger.d('switch', '查找设备:$value'); + }, + }, + { + 'type': 'grade', + 'name': '降噪等级', + 'value': 0, + 'onCallback': (int value) { + Logger.d('grade', '降噪等级:$value'); + }, + }, + { + 'type': 'timer', + 'name': '定时关机', + 'value': 0, + 'onCallback': (int value) { + Logger.d('timer', '定时关机:$value'); + }, + }, + ], + }, + { + 'templates': [ + { + 'type': 'confirm', + 'name': '升级耳机固件', + 'onCallback': () => Get.toNamed(Routes.OTA_UPGRADE), + }, + { + 'type': 'confirm', + 'name': '恢复出厂设置', + 'tips': '确定要恢复出厂设置吗?', + 'onCallback': () { + Logger.d('confirm', '恢复出厂设置'); + }, + }, + { + 'type': 'confirm', + 'name': '清除配对记录', + 'tips': '确定要清除配对记录吗?', + 'onCallback': () { + Logger.d('confirm', '清除配对记录'); + }, + }, + ], + } + ].obs; + + final GetStorage _storage = GetStorage(); + + @override + void onInit() { + super.onInit(); + final List equalizerOptions = _storage.read('equalizer_options') ?? []; + if (equalizerOptions.isNotEmpty) { + List options = + equalizerOptions.map((e) => Map.from(e)).toList(); + uiList[0]['templates'][0]['options'].addAll(options); + } + } + + Widget getTemplate(Map data) { + return switch (data['type']) { + 'equalizer' => RowEqualizerWidget(data), + 'keypress' => RowKeypressWidget(data), + 'input' => RowInputWidget(data), + 'radio' => RowRadioWidget(data), + 'switch' => RowSwitchWidget(data), + 'grade' => RowGradeWidget(data), + 'timer' => RowTimerWidget(data), + 'confirm' => RowConfirmWidget(data), + _ => const SizedBox.shrink(), + }; + } +} diff --git a/lib/modules/eq_settings/views/eq_settings_view.dart b/lib/modules/eq_settings/views/eq_settings_view.dart new file mode 100644 index 000000000..67f0403e9 --- /dev/null +++ b/lib/modules/eq_settings/views/eq_settings_view.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '../controllers/eq_settings_controller.dart'; + +class EqSettingsView extends GetView { + const EqSettingsView({super.key}); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: _buildAppBar(isDarkMode), + body: _buildBody(isDarkMode), + ); + } + + // 构建应用栏 + PreferredSizeWidget _buildAppBar(bool isDarkMode) { + return AppBar( + backgroundColor: isDarkMode ? Colors.black : Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0.5, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + onPressed: () => Get.back(), + ), + title: Text( + '设备设置', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + centerTitle: true, + ); + } + + // 构建主体内容 + Widget _buildBody(bool isDarkMode) { + return Obx( + () => ListView.builder( + itemCount: controller.uiList.length, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), + itemBuilder: (BuildContext context, int index) { + Map data = controller.uiList[index]; + return _item(isDarkMode, data); + }, + ), + ); + } + + Widget _item(bool isDarkMode, Map data) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), + margin: EdgeInsets.symmetric(vertical: 5.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.white.withValues(alpha: 0.05) : Colors.white, + borderRadius: BorderRadius.circular(8.w), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data['title'] != null) + Text( + data['title'], + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ...data['templates'].map((e) => controller.getTemplate(e)).toList(), + ], + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart b/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart new file mode 100644 index 000000000..ce680b1d0 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +class RowConfirmWidget extends StatefulWidget { + final Map templateData; + + const RowConfirmWidget(this.templateData, {super.key}); + + @override + State createState() => _RowConfirmWidgetState(); +} + +class _RowConfirmWidgetState extends State { + Map get _templateData => widget.templateData; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () { + if (_templateData['tips'] != null) { + _alertDialog(isDarkMode); + } else { + _templateData['onCallback'](); + } + }, + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Icon( + Icons.chevron_right, + size: 26.w, + color: Colors.blue, + ), + ], + ), + ), + ); + } + + void _alertDialog(bool isDarkMode) { + Get.dialog( + AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + title: Text( + _templateData['name'], + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + content: Text( + _templateData['tips'], + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + TextButton( + onPressed: () { + _templateData['onCallback'](); + Get.back(); + }, + child: Text( + 'confirm'.tr, // 确定 + style: TextStyle( + color: isDarkMode ? Colors.red[300] : Colors.red[600], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart b/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart new file mode 100644 index 000000000..cccd24734 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import 'select_equalizer_widget.dart'; + +class RowEqualizerWidget extends StatefulWidget { + final Map templateData; + + const RowEqualizerWidget(this.templateData, {super.key}); + + @override + State createState() => _RowEqualizerWidgetState(); +} + +class _RowEqualizerWidgetState extends State { + Map get _templateData => widget.templateData; + + String _label = ''; + + @override + void initState() { + super.initState(); + _label = _templateData['options'].firstWhere( + (option) => listEquals(_templateData['value'], option['value']), + orElse: () => {'label': ''}, + )['label']; + } + + void _setLabel() { + setState(() { + _label = _templateData['options'].firstWhere( + (option) => listEquals(_templateData['value'], option['value']), + orElse: () => {'label': ''}, + )['label']; + }); + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () async { + var result = await Get.to(() => SelectEqualizerWidget(_templateData)); + _setLabel(); + if (result != null) { + _templateData['onCallback'](_templateData['value']); + } + }, + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Text( + _label, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + Icon( + Icons.chevron_right, + size: 26.w, + color: Colors.blue, + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_grade_widget.dart b/lib/modules/eq_settings/views/widgets/row_grade_widget.dart new file mode 100644 index 000000000..8abd2d327 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_grade_widget.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class RowGradeWidget extends StatefulWidget { + final Map templateData; + + const RowGradeWidget(this.templateData, {super.key}); + + @override + State createState() => _RowGradeWidgetState(); +} + +class _RowGradeWidgetState extends State { + Map get _templateData => widget.templateData; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Slider( + value: _templateData['value'].toDouble(), + min: 0, + max: 10, + divisions: 10, + activeColor: Colors.blue, + onChanged: (value) { + setState(() { + _templateData['value'] = value.toInt(); + }); + }, + onChangeEnd: (value) { + _templateData['onCallback'](_templateData['value']); + }, + ), + SizedBox( + width: 15.w, + child: Text( + _templateData['value'].toString(), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_input_widget.dart b/lib/modules/eq_settings/views/widgets/row_input_widget.dart new file mode 100644 index 000000000..339b79ce8 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_input_widget.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import 'settings_input_widget.dart'; + +class RowInputWidget extends StatefulWidget { + final Map templateData; + + const RowInputWidget(this.templateData, {super.key}); + + @override + State createState() => _RowInputWidgetState(); +} + +class _RowInputWidgetState extends State { + Map get _templateData => widget.templateData; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _inputBottomSheet(isDarkMode), + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Text( + _templateData['value'], + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + Icon( + Icons.chevron_right, + size: 26.w, + color: Colors.blue, + ), + ], + ), + ), + ); + } + + void _inputBottomSheet(bool isDarkMode) { + Get.bottomSheet( + SettingsInputWidget(_templateData['name'], _templateData['value']), + isScrollControlled: true, + backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ).then((value) { + if (value != null) { + setState(() { + _templateData['value'] = value; + }); + _templateData['onCallback'](value); + } + }); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart b/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart new file mode 100644 index 000000000..2e7722017 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +class RowKeypressWidget extends StatefulWidget { + final Map templateData; + + const RowKeypressWidget(this.templateData, {super.key}); + + @override + State createState() => _RowKeypressWidgetState(); +} + +class _RowKeypressWidgetState extends State { + Map get _templateData => widget.templateData; + + String _getLabel(int value) { + return _templateData['options'].firstWhere((item) => item['value'] == value, + orElse: () => {'label': ''})['label']; + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Row( + children: [ + const Spacer(), + Expanded( + child: Text( + '左', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Expanded( + child: Text( + '右', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + ], + ), + ), + ..._templateData['data'] + .map((item) => _rowItem(isDarkMode, item)) + .toList(), + ], + ); + } + + Widget _rowItem(bool isDarkMode, Map item) { + return Row( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Text( + item['label'], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => _radioDialog(isDarkMode, item, 'left'), + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Text( + _getLabel(item['left']), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => _radioDialog(isDarkMode, item, 'right'), + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Text( + _getLabel(item['right']), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + ), + ), + ), + ], + ); + } + + void _radioDialog(bool isDarkMode, Map item, String key) { + Get.dialog( + Dialog( + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + child: Padding( + padding: EdgeInsets.all(12.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${item['label']}-${key == 'left' ? '左' : '右'}', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 16.h), + Container( + constraints: BoxConstraints(maxHeight: 0.5.sh), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: _templateData['options'] + .map((option) => + _radioItem(isDarkMode, option, item, key)) + .toList(), + ), + ), + ), + SizedBox(height: 16.h), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _radioItem(bool isDarkMode, Map option, Map item, String key) { + return GestureDetector( + onTap: () { + setState(() { + item[key] = option['value']; + }); + Map data = { + 'key': item['key'], + key: option['value'], + }; + _templateData['onCallback'](data); + Get.back(); + }, + child: Container( + margin: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.all(10.w), + decoration: BoxDecoration( + color: item[key] == option['value'] + ? (isDarkMode + ? Colors.teal[900]!.withValues(alpha: 0.3) + : Colors.teal[50]!) + : (isDarkMode + ? Colors.white.withValues(alpha: 0.05) + : Colors.grey[100]!), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: item[key] == option['value'] + ? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!) + : Colors.transparent, + width: 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + option['label'], + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + width: 24.w, + height: 24.w, + decoration: BoxDecoration( + color: item[key] == option['value'] + ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) + : (isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.grey[300]), + shape: BoxShape.circle, + ), + child: item[key] == option['value'] + ? Icon( + Icons.check, + size: 16.w, + color: Colors.white, + ) + : null, + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_radio_widget.dart b/lib/modules/eq_settings/views/widgets/row_radio_widget.dart new file mode 100644 index 000000000..6dbfd46f1 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_radio_widget.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +class RowRadioWidget extends StatefulWidget { + final Map templateData; + + const RowRadioWidget(this.templateData, {super.key}); + + @override + State createState() => _RowRadioWidgetState(); +} + +class _RowRadioWidgetState extends State { + Map get _templateData => widget.templateData; + + String _label = ''; + + @override + void initState() { + super.initState(); + _label = _templateData['options'].firstWhere( + (option) => option['value'] == _templateData['value'], + orElse: () => {'label': ''}, + )['label']; + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _radioDialog(isDarkMode), + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + Text( + _label, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + Icon( + Icons.chevron_right, + size: 26.w, + color: Colors.blue, + ), + ], + ), + ), + ); + } + + void _radioDialog(bool isDarkMode) { + Get.dialog( + Dialog( + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + child: Padding( + padding: EdgeInsets.all(12.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _templateData['name'], + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 16.h), + Container( + constraints: BoxConstraints(maxHeight: 0.5.sh), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: _templateData['options'] + .map((option) => _item(isDarkMode, option)) + .toList(), + ), + ), + ), + SizedBox(height: 16.h), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _item(bool isDarkMode, Map option) { + return GestureDetector( + onTap: () { + setState(() { + _label = option['label']; + }); + _templateData['value'] = option['value']; + _templateData['onCallback'](option['value']); + Get.back(); + }, + child: Container( + margin: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.all(10.w), + decoration: BoxDecoration( + color: _templateData['value'] == option['value'] + ? (isDarkMode + ? Colors.teal[900]!.withValues(alpha: 0.3) + : Colors.teal[50]!) + : (isDarkMode + ? Colors.white.withValues(alpha: 0.05) + : Colors.grey[100]!), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: _templateData['value'] == option['value'] + ? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!) + : Colors.transparent, + width: 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + option['label'], + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + width: 24.w, + height: 24.w, + decoration: BoxDecoration( + color: _templateData['value'] == option['value'] + ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) + : (isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.grey[300]), + shape: BoxShape.circle, + ), + child: _templateData['value'] == option['value'] + ? Icon( + Icons.check, + size: 16.w, + color: Colors.white, + ) + : null, + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_switch_widget.dart b/lib/modules/eq_settings/views/widgets/row_switch_widget.dart new file mode 100644 index 000000000..e21223ba5 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_switch_widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class RowSwitchWidget extends StatefulWidget { + final Map templateData; + + const RowSwitchWidget(this.templateData, {super.key}); + + @override + State createState() => _RowSwitchWidgetState(); +} + +class _RowSwitchWidgetState extends State { + Map get _templateData => widget.templateData; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + if (_templateData['description'] != null) + Text( + _templateData['description'], + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + SizedBox( + height: 26.w, + child: Transform.scale( + scale: 0.8, + alignment: Alignment.centerRight, + child: Switch( + value: _templateData['value'], + onChanged: (bool value) { + setState(() { + _templateData['value'] = value; + }); + _templateData['onCallback'](value); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/row_timer_widget.dart b/lib/modules/eq_settings/views/widgets/row_timer_widget.dart new file mode 100644 index 000000000..2a950f60e --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/row_timer_widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import 'select_timer_widget.dart'; + +class RowTimerWidget extends StatefulWidget { + final Map templateData; + + const RowTimerWidget(this.templateData, {super.key}); + + @override + State createState() => _RowTimerWidgetState(); +} + +class _RowTimerWidgetState extends State { + Map get _templateData => widget.templateData; + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _timerBottomSheet(isDarkMode), + behavior: HitTestBehavior.translucent, + child: Padding( + padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w), + child: Row( + children: [ + Expanded( + child: Text( + _templateData['name'], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ), + if (_templateData['value'] > 0) + Text( + '${_templateData['value']}分钟', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + Icon( + Icons.chevron_right, + size: 26.w, + color: Colors.blue, + ), + ], + ), + ), + ); + } + + void _timerBottomSheet(bool isDarkMode) { + Get.bottomSheet( + SelectTimerWidget(_templateData['name'], _templateData['value']), + isScrollControlled: true, + backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ).then((value) { + if (value != null) { + setState(() { + _templateData['value'] = value; + }); + _templateData['onCallback'](value); + } + }); + } +} diff --git a/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart b/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart new file mode 100644 index 000000000..ea04be1f5 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart @@ -0,0 +1,305 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +import 'settings_input_widget.dart'; + +class SelectEqualizerWidget extends StatefulWidget { + final Map templateData; + + const SelectEqualizerWidget(this.templateData, {super.key}); + + @override + State createState() => _SelectEqualizerWidgetState(); +} + +class _SelectEqualizerWidgetState extends State { + Map get _templateData => widget.templateData; + + List _values = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + bool isEdit = false; + + final GetStorage _storage = GetStorage(); + List _equalizerOptions = []; + + @override + void initState() { + super.initState(); + if (_templateData['value'].isNotEmpty) { + _values = List.from(_templateData['value']); + } + _equalizerOptions = _storage.read('equalizer_options') ?? []; + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: _buildAppBar(isDarkMode), + body: _buildBody(isDarkMode), + ); + } + + // 构建应用栏 + PreferredSizeWidget _buildAppBar(bool isDarkMode) { + return AppBar( + backgroundColor: isDarkMode ? Colors.black : Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0.5, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + onPressed: () => Get.back(), + ), + title: Text( + _templateData['name'], + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + centerTitle: true, + actions: [ + GestureDetector( + onTap: () { + setState(() { + isEdit = !isEdit; + }); + }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.w, + ), + child: Text( + isEdit ? 'cancel'.tr : 'edit'.tr, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 16.sp, + ), + ), + ), + ), + ], + ); + } + + // 构建主体内容 + Widget _buildBody(bool isDarkMode) { + bool isAdd = true; + + return ListView( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w), + children: [ + _equalizer(isDarkMode), + Wrap( + spacing: 10.w, + runSpacing: 10.w, + children: [ + ..._templateData['options'].map((Map option) { + bool isSelected = listEquals(_values, option['value']); + if (isSelected) { + isAdd = false; + } + return _item(isDarkMode, option, isSelected); + }).toList(), + if (isAdd) + GestureDetector( + onTap: () => _inputBottomSheet(isDarkMode), + child: Container( + width: 110.w, + height: 110.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withValues(alpha: 0.05) + : Colors.white, + borderRadius: BorderRadius.circular(8.w), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.add, + size: 40.sp, + color: Colors.green, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _equalizer(bool isDarkMode) { + return Container( + height: 220.w, + margin: EdgeInsets.only(bottom: 5.w), + child: Row( + children: List.generate( + _templateData['xAxis'].length, + (index) => Expanded( + child: Stack( + children: [ + RotatedBox( + quarterTurns: 3, + child: StatefulBuilder( + builder: (BuildContext context, setStateful) { + return Slider( + value: _values[index].toDouble(), + min: -5, + max: 5, + divisions: 10, + activeColor: Colors.green, + onChanged: (value) { + setStateful(() { + _values[index] = value.toInt(); + }); + }, + onChangeEnd: (value) { + setState(() {}); + }, + ); + }, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Text( + _templateData['xAxis'][index], + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.grey, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _item(bool isDarkMode, Map option, bool isSelected) { + return GestureDetector( + onTap: () { + _templateData['value'] = option['value']; + Get.back(result: true); + }, + child: Stack( + children: [ + Container( + width: 110.w, + height: 110.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSelected + ? Colors.green + : isDarkMode + ? Colors.white.withValues(alpha: 0.05) + : Colors.white, + borderRadius: BorderRadius.circular(8.w), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + option['label'], + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white + : isDarkMode + ? Colors.white + : Colors.black, + ), + ), + ), + if (isEdit && !option['default']) + Positioned( + top: 4.w, + right: 4.w, + child: GestureDetector( + onTap: () { + if (isSelected) { + _templateData['value'] = []; + _templateData['onCallback']([]); + } + _equalizerOptions.removeWhere( + (e) => listEquals(e['value'], option['value'])); + _storage.write('equalizer_options', _equalizerOptions); + setState(() { + _templateData['options'].remove(option); + }); + }, + child: Container( + width: 24.w, + height: 24.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12.w), + ), + child: Icon( + Icons.close, + size: 16.sp, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ); + } + + void _inputBottomSheet(bool isDarkMode) { + Get.bottomSheet( + const SettingsInputWidget('添加均衡器', ''), + isScrollControlled: true, + backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ).then((value) { + if (value != null) { + Map option = { + 'label': value, + 'value': List.from(_values), + 'default': false, + }; + _equalizerOptions.add(option); + _storage.write('equalizer_options', _equalizerOptions); + setState(() { + _templateData['options'].add(option); + }); + } + }); + } +} diff --git a/lib/modules/eq_settings/views/widgets/select_timer_widget.dart b/lib/modules/eq_settings/views/widgets/select_timer_widget.dart new file mode 100644 index 000000000..ec082d4e2 --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/select_timer_widget.dart @@ -0,0 +1,231 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +class SelectTimerWidget extends StatefulWidget { + final String title; + final int time; + + const SelectTimerWidget(this.title, this.time, {super.key}); + + @override + State createState() => _SelectTimerWidgetState(); +} + +class _SelectTimerWidgetState extends State { + late FixedExtentScrollController _hourScrollController; + late FixedExtentScrollController _minuteScrollController; + + int _hour = 0; + int _minute = 0; + + @override + void initState() { + super.initState(); + _hour = widget.time ~/ 60; + _minute = widget.time % 60; + _hourScrollController = FixedExtentScrollController(initialItem: _hour); + _minuteScrollController = FixedExtentScrollController(initialItem: _minute); + } + + @override + void dispose() { + super.dispose(); + _hourScrollController.dispose(); + _minuteScrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: 16.sp, + color: Colors.black87, + fontWeight: FontWeight.bold, + ), + ), + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 26.w, + height: 26.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(26.r), + ), + child: Icon( + Icons.close, + size: 18.w, + color: Colors.grey[400], + ), + ), + ) + ], + ), + ), + Divider( + height: 1, + color: Colors.grey[300], + ), + SizedBox( + height: 220.w, + child: Row( + children: [ + Expanded( + child: CupertinoPicker.builder( + itemExtent: 40.w, + diameterRatio: 1.1, + squeeze: 1.25, + childCount: 24, + selectionOverlay: _selectionOverlayWidget( + isRadiusLeft: true, + ), + scrollController: _hourScrollController, + itemBuilder: (BuildContext context, int index) { + return Container( + padding: EdgeInsets.only(right: 10.w), + alignment: Alignment.centerRight, + child: Text( + '$index时', + style: _hour == index + ? TextStyle( + color: const Color(0xFFFF5561), + fontSize: 15.sp, + fontWeight: FontWeight.w500, + ) + : TextStyle( + color: const Color(0xFF9D9D9D), + fontSize: 15.sp, + ), + ), + ); + }, + onSelectedItemChanged: (int index) { + setState(() { + _hour = index; + }); + }, + ), + ), + Expanded( + child: CupertinoPicker.builder( + itemExtent: 40.w, + diameterRatio: 1.1, + squeeze: 1.25, + childCount: 60, + selectionOverlay: _selectionOverlayWidget( + isRadiusRight: true, + ), + scrollController: _minuteScrollController, + itemBuilder: (BuildContext context, int index) { + return Container( + padding: EdgeInsets.only(left: 10.w), + alignment: Alignment.centerLeft, + child: Text( + '$index分', + style: _minute == index + ? TextStyle( + color: const Color(0xFFFF5561), + fontSize: 15.sp, + fontWeight: FontWeight.w500, + ) + : TextStyle( + color: const Color(0xFF9D9D9D), + fontSize: 15.sp, + ), + ), + ); + }, + onSelectedItemChanged: (int index) { + setState(() { + _minute = index; + }); + }, + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 20.w), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Get.back(), + child: Container( + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + fontSize: 16.sp, + color: Colors.black, + ), + ), + ), + ), + ), + 10.horizontalSpace, + Expanded( + child: GestureDetector( + onTap: () { + int time = _hour * 60 + _minute; + Get.back(result: time); + }, + child: Container( + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + 'confirm'.tr, // 确认 + style: TextStyle( + fontSize: 16.sp, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _selectionOverlayWidget({ + bool isRadiusLeft = false, + bool isRadiusRight = false, + }) { + return Container( + decoration: BoxDecoration( + color: const Color.fromARGB(45, 255, 85, 97), + borderRadius: BorderRadius.horizontal( + left: isRadiusLeft ? Radius.circular(8.w) : Radius.zero, + right: isRadiusRight ? Radius.circular(8.w) : Radius.zero, + ), + ), + ); + } +} diff --git a/lib/modules/eq_settings/views/widgets/settings_input_widget.dart b/lib/modules/eq_settings/views/widgets/settings_input_widget.dart new file mode 100644 index 000000000..ea41dd49c --- /dev/null +++ b/lib/modules/eq_settings/views/widgets/settings_input_widget.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +class SettingsInputWidget extends StatefulWidget { + final String title; + final String inputText; + + const SettingsInputWidget(this.title, this.inputText, {super.key}); + + @override + State createState() => _SettingsInputWidgetState(); +} + +class _SettingsInputWidgetState extends State { + final TextEditingController _inputController = TextEditingController(); + + @override + void initState() { + super.initState(); + _inputController.text = widget.inputText; + } + + @override + void dispose() { + _inputController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: 16.sp, + color: Colors.black87, + fontWeight: FontWeight.bold, + ), + ), + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 26.w, + height: 26.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(26.r), + ), + child: Icon( + Icons.close, + size: 18.w, + color: Colors.grey[400], + ), + ), + ) + ], + ), + ), + Divider( + height: 1, + color: Colors.grey[300], + ), + Container( + margin: EdgeInsets.symmetric(vertical: 20.w), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.w), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _inputController, + style: TextStyle( + color: Colors.black87, + fontSize: 16.sp, + ), + decoration: InputDecoration( + hintText: 'maxCharacters'.tr, + hintStyle: TextStyle( + color: Colors.black54, + fontSize: 16.sp, + ), + isDense: true, + fillColor: Colors.transparent, + border: InputBorder.none, + contentPadding: EdgeInsets.all(10.w), + ), + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 20.w), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Get.back(), + child: Container( + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + fontSize: 16.sp, + color: Colors.black, + ), + ), + ), + ), + ), + 10.horizontalSpace, + Expanded( + child: GestureDetector( + onTap: () { + if (_inputController.text.isNotEmpty) { + Get.back(result: _inputController.text); + } + }, + child: Container( + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + 'confirm'.tr, // 确认 + style: TextStyle( + fontSize: 16.sp, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 466b4792b..a7729e491 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -277,7 +277,7 @@ class HomeController extends GetxController { // 连接成功后检查OTA升级 if (noviceGuideService.isHomeGuide) { - Future.delayed(const Duration(seconds: 2), () { + Future.delayed(const Duration(seconds: 1), () { _checkOtaUpgrade(); }); } @@ -542,9 +542,7 @@ class HomeController extends GetxController { } // 使用默认耳机图片 - return isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png'; + return 'assets/images/defaultPicture.png'; } /// 判断是否为网络图片 diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 9e7ca2838..04c3d7de0 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -85,10 +85,12 @@ class HomeView extends GetView { // 顶部标题栏 _buildAppBar(isDarkMode), + SizedBox(height: 10.h), + // 顶部图片 _buildEarbudsImage(), - SizedBox(height: 20.h), + SizedBox(height: 10.h), // 设备状态卡片 IntroStepBuilder( @@ -210,26 +212,10 @@ class HomeView extends GetView { height: 220.h, child: PageView( children: [ - // Center( - // child: ClipRRect( - // // 添加 ClipRRect 来实现圆角 - // borderRadius: BorderRadius.circular(16.r), // 使用与其他部件一致的圆角大小 - // child: SizedBox( - // width: double.infinity, - // height: 200.h, - // child: Image.asset( - // isDarkMode - // ? 'assets/images/robot_light.png' - // : 'assets/images/robot_light.png', - // fit: BoxFit.fitWidth, - // ), - // ), - // ), - // ), Center( child: ClipRRect( // 添加 ClipRRect - borderRadius: BorderRadius.circular(16.r), + borderRadius: BorderRadius.circular(20.r), child: GetBuilder( builder: (controller) { final imagePath = controller.getDeviceImagePath(isDarkMode); @@ -238,16 +224,16 @@ class HomeView extends GetView { return isNetwork ? Image.network( imagePath, + width: double.infinity, height: 220.h, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { // 网络图片加载失败时使用默认图片 return Image.asset( - isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png', + 'assets/images/defaultPicture.png', + width: double.infinity, height: 220.h, - fit: BoxFit.contain, + fit: BoxFit.cover, ); }, loadingBuilder: (context, child, loadingProgress) { @@ -268,16 +254,16 @@ class HomeView extends GetView { ) : Image.asset( imagePath, + width: double.infinity, height: 220.h, - fit: BoxFit.contain, + fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { // 本地图片加载失败时使用默认图片 return Image.asset( - isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png', + 'assets/images/defaultPicture.png', + width: double.infinity, height: 220.h, - fit: BoxFit.contain, + fit: BoxFit.cover, ); }, ); @@ -285,22 +271,6 @@ class HomeView extends GetView { ), ), ), - // Center( - // child: ClipRRect( - // // 添加 ClipRRect - // borderRadius: BorderRadius.circular(16.r), - // child: SizedBox( - // width: double.infinity, - // height: 200.h, - // child: Image.asset( - // isDarkMode - // ? 'assets/images/glasses_light.png' - // : 'assets/images/glasses_light.png', - // fit: BoxFit.fitWidth, - // ), - // ), - // ), - // ), ], ), ); diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index aa679d154..a65074b23 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -15,6 +15,7 @@ import '../../../core/utils/synchrodata.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../data/services/meeting/meeting_task_service.dart'; import '../../../data/services/meeting/meeting_upload_service.dart'; +import '../../../data/services/meeting/summary_task_service.dart'; import '../model/meeting_details_model.dart'; import '../model/meeting_model.dart'; @@ -30,6 +31,7 @@ class MeetingDetailsController extends GetxController final _meetingUploadService = Get.find(); final _meetingTaskService = Get.find(); + final _summaryTaskService = Get.find(); final PlayerController playerController = PlayerController(); @@ -97,8 +99,8 @@ class MeetingDetailsController extends GetxController if (value == 2) { textList.value = _meetingTaskService.textList; } else if (value == 3) { - meetingDetails.value.summary = _meetingTaskService.summary; - meetingDetails.value.overview = _meetingTaskService.overview; + meetingDetails.value.summary = _summaryTaskService.summary; + meetingDetails.value.overview = _summaryTaskService.overview; summaryNumberModifications.value++; } } @@ -127,6 +129,7 @@ class MeetingDetailsController extends GetxController final data = await SqfliteApi.getMeetingDetails(id); meetingData.value = MeetingModel.fromJson(data); meetingDetails.value = MeetingDetailsModel.fromJson(data['details']); + _everType = meetingDetails.value.tasktype + 1; _meetingTaskService.tasktype.value = meetingDetails.value.tasktype; if (meetingDetails.value.tasktype >= 3) { dragOffset.value = 0; @@ -274,6 +277,6 @@ class MeetingDetailsController extends GetxController _everType = 3; await SqfliteApi.editMeeting(id, {'tasktype': 2}); _meetingTaskService.tasktype.value = 2; - _meetingTaskService.aiServiceMessage(id, text, prompt, outline); + _summaryTaskService.addTaskList(id, text, prompt, outline); } } diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index cd8c1f98a..db2ae56cc 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -20,6 +20,7 @@ import 'package:share_plus/share_plus.dart'; import '../../../data/services/meeting/meeting_task_service.dart'; import '../../../data/services/meeting/meeting_upload_service.dart'; +import '../../../data/services/meeting/summary_task_service.dart'; import '../../../routes/app_routes.dart'; import 'meeting_controller.dart'; @@ -28,6 +29,7 @@ class MeetingHomeController extends GetxController { final meetingController = Get.find(); final meetingUploadService = Get.find(); final meetingTaskService = Get.find(); + final summaryTaskService = Get.find(); final AudioPlayer _audioPlayer = AudioPlayer(); @@ -135,6 +137,7 @@ class MeetingHomeController extends GetxController { .toList(); } +//未使用 void loadTranslatAudio() async { try { final appDir = await appDocDir; // 先 await 获取目录 @@ -289,9 +292,9 @@ class MeetingHomeController extends GetxController { } } - // 新增:将翻译音频归档到 LocalAudio 并返回新路径(优先 rename,失败再 copy+delete) + // 新增(2025-11-04):将翻译音频归档到 LocalAudio 并返回新路径(优先 rename,失败再 copy+delete) Future _archiveTranslatToLocal(String srcPath) async { - // 中文注释:获取应用文档目录并确保 LocalAudio 目录存在 + // 获取应用文档目录并确保 LocalAudio 目录存在 final appDir = await appDocDir; final destDir = Directory(path.join(appDir.path, 'LocalAudio')); if (!await destDir.exists()) { @@ -327,8 +330,9 @@ class MeetingHomeController extends GetxController { for (final originalPath in selected) { try { // 中文注释:根据开关决定是否将翻译音频迁移到 LocalAudio - final usePath = - archiveToLocal ? await _archiveTranslatToLocal(originalPath) : originalPath; + final usePath = archiveToLocal + ? await _archiveTranslatToLocal(originalPath) + : originalPath; // 中文注释:计算音频时长与创建时间 await _audioPlayer.setAudioSource(AudioSource.file(usePath)); @@ -370,7 +374,8 @@ class MeetingHomeController extends GetxController { Logger.error('importFailed' .tr .replaceAll('{filePath}', originalPath) - .replaceAll('{error}', e.toString())); // 对应中文:导入文件失败: {filePath}, error: {error} + .replaceAll('{error}', + e.toString())); // 对应中文:导入文件失败: {filePath}, error: {error} } } EasyLoading.dismiss(); @@ -381,12 +386,30 @@ class MeetingHomeController extends GetxController { String fileName = ""; bool isParamProvided = filePath != null; // 更清晰的变量名 String audioType = 'IMPORT'; // 默认类型为IMPORT + String effectivePath = filePath ?? ''; if (filePath != null) { // 正确截取文件名(跨平台安全方式) fileName = path.basename(filePath); - // 检查是否来自翻译模块 - if (filePath.contains('/TranslatAudio/')) { - audioType = 'TRANSLAT'; // 设置为翻译音频类型 + // 检查是否来自翻译模块(兼容不同平台路径分隔符) + final isFromTranslat = filePath.contains('/TranslatAudio/') || + filePath.contains('\\TranslatAudio\\'); + if (isFromTranslat) { + try { + // 实现——导入前将翻译音频归档到LocalAudio + effectivePath = await _archiveTranslatToLocal(filePath); + audioType = 'TRANSLAT'; // 归档后统一标记为TRANSLAT类型 + fileName = path.basename(effectivePath); + } catch (e) { + // 归档失败则回退为原路径、保持TRANSLAT类型以避免中断导入 + audioType = 'TRANSLAT'; + effectivePath = filePath; + Logger.error('importFailed' + .tr + .replaceAll('{filePath}', filePath) + .replaceAll('{error}', e.toString())); // 翻译键代表的中文文本内容 + } + } else { + effectivePath = filePath; } } else { FilePickerResult? result = await FilePicker.platform.pickFiles( @@ -410,7 +433,7 @@ class MeetingHomeController extends GetxController { } // 使用文件路径而不是字节数据 - filePath = file.path ?? await _copyFileToLocal(file); + effectivePath = file.path ?? await _copyFileToLocal(file); fileName = file.name; } else { EasyLoading.dismiss(); @@ -419,7 +442,7 @@ class MeetingHomeController extends GetxController { } final meetingId = - await insertMeetingAudio(fileName, filePath, type: audioType); + await insertMeetingAudio(fileName, effectivePath, type: audioType); if (isParamProvided) { Get.toNamed(Routes.meetingDetails, arguments: { @@ -449,10 +472,12 @@ class MeetingHomeController extends GetxController { }; final meetingId = await SqfliteApi.insertMeeting(data); if (meetingId != null) { + // 中文注释:根据类型选择上传分类;LOCAL 走 LocalAudio,其它走 ExternalAudio + final uploadBucket = type == 'LOCAL' ? 'LocalAudio' : 'ExternalAudio'; meetingUploadService.addUpload( meetingId, filePath, - 'ExternalAudio', + uploadBucket, isUpload: isUpload, ); originalDataList.insert(0, { diff --git a/lib/modules/meeting/controllers/mind_map_controller.dart b/lib/modules/meeting/controllers/mind_map_controller.dart index 997ec75bb..09b91b613 100644 --- a/lib/modules/meeting/controllers/mind_map_controller.dart +++ b/lib/modules/meeting/controllers/mind_map_controller.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -19,6 +20,8 @@ class MindMapController extends GetxController { final double _width = 1.sw - 25.w; bool isWebViewReady = false; + int imageType = 1; + late final Worker _summaryNumberListener; @override @@ -170,18 +173,29 @@ class MindMapController extends GetxController { final String dataUrl = message.message; final String base64Str = dataUrl.split(',').last; final Uint8List bytes = base64.decode(base64Str); - final String fileName = - '${_meetingDetailsController.meetingData.value.title}_mindmap.png'; - final tempDir = await getTemporaryDirectory(); - final File file = File('${tempDir.path}/$fileName'); - await file.writeAsBytes(bytes); - final result = await SharePlus.instance.share(ShareParams( - text: 'shareMindMap'.tr, //对应中文:分享思维导图 - - files: [XFile(file.path)], - )); - if (result.status == ShareResultStatus.success) { - EasyLoading.showToast('shareSuccess'.tr); //对应中文:分享成功 + if (imageType == 1) { + // 保存图片 + final result = await ImageGallerySaverPlus.saveImage( + bytes, + quality: 100, + ); + if (result['isSuccess']) { + EasyLoading.showToast('保存成功'); + } + } else { + // 分享图片 + final String fileName = + '${_meetingDetailsController.meetingData.value.title}_mindmap.png'; + final tempDir = await getTemporaryDirectory(); + final File file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(bytes); + final result = await SharePlus.instance.share(ShareParams( + text: 'shareMindMap'.tr, //对应中文:分享思维导图 + files: [XFile(file.path)], + )); + if (result.status == ShareResultStatus.success) { + EasyLoading.showToast('shareSuccess'.tr); //对应中文:分享成功 + } } } } diff --git a/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart index 7705997f9..1f7d48615 100644 --- a/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart @@ -209,6 +209,33 @@ class _ShareBottomSheetState extends State { ); } : null), + _shareItem( + Icons.save_alt, + '保存思维导图', + onTap: _controller.meetingDetails.value.tasktype >= 3 + ? () { + Get.back(); + final mindMapController = + Get.find(); + mindMapController.imageType = 1; + if (mindMapController.isWebViewReady) { + mindMapController.webViewController + .runJavaScript('captureImage()'); + } else { + mindMapController.webViewController + .setNavigationDelegate( + NavigationDelegate( + onPageFinished: (String url) { + mindMapController.isWebViewReady = true; + mindMapController.webViewController + .runJavaScript('captureImage()'); + }, + ), + ); + } + } + : null, + ), _shareItem( Icons.lan, 'shareMindMap'.tr, // 对应中文:分享思维导图 @@ -218,6 +245,7 @@ class _ShareBottomSheetState extends State { Get.back(); final mindMapController = Get.find(); + mindMapController.imageType = 2; if (mindMapController.isWebViewReady) { mindMapController.webViewController .runJavaScript('captureImage()'); diff --git a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart index cd05687f0..146645d90 100644 --- a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart @@ -525,17 +525,20 @@ class OtaCoreService { } } + product.isforceupdate = product.version?.contains("+"); + return { - 'latestVersion': product.version ?? "", + 'latestVersion': product.version?.split('+').first ?? "", 'updateDescription': product.updatedescription ?? "", 'updatePackageAddress': product.updatepackageaddress ?? "", + 'isForceUpdate': product.isforceupdate ?? false, }; } /// 比较版本号,判断是否有新版本可用 /// [currentVersion] 当前版本号 /// [latestVersion] 最新版本号 - /// 返回true表示有新版本可用(支持升级和降级) + /// 返回true表示有新版本可用(只支持升级) bool isNewVersionAvailable(String currentVersion, String latestVersion) { try { List currentParts = @@ -555,10 +558,12 @@ class OtaCoreService { latestParts.add(0); } - // 比较每一段,只要有不同就返回true(支持升级和降级) + // 从高位到低位比较版本号 for (int i = 0; i < maxLength; i++) { - if (latestParts[i] != currentParts[i]) { + if (latestParts[i] > currentParts[i]) { return true; + } else if (latestParts[i] < currentParts[i]) { + return false; } } diff --git a/lib/modules/ota_upgrade/views/ota_upgrade_view.dart b/lib/modules/ota_upgrade/views/ota_upgrade_view.dart index 18dc7485f..1bc1caf0f 100644 --- a/lib/modules/ota_upgrade/views/ota_upgrade_view.dart +++ b/lib/modules/ota_upgrade/views/ota_upgrade_view.dart @@ -13,7 +13,7 @@ class OtaUpgradeView extends GetView { return WillPopScope( onWillPop: () async { - if (controller.isUpgrade.value) { + if (controller.isUpgrade.value && controller.showDetailedStatus.value) { // 显示确认弹窗 await Get.dialog( AlertDialog( @@ -54,7 +54,8 @@ class OtaUpgradeView extends GetView { color: isDarkMode ? Colors.white : Colors.black, ), onPressed: () { - if (controller.isUpgrade.value) { + if (controller.isUpgrade.value && + controller.showDetailedStatus.value) { // 显示确认弹窗 Get.dialog( AlertDialog( @@ -172,7 +173,7 @@ class OtaUpgradeView extends GetView { // 设备图标 Widget _buildDeviceIcon(bool isDarkMode) { return Container( - padding: EdgeInsets.all(16.w), + padding: EdgeInsets.all(6.w), decoration: BoxDecoration( color: isDarkMode ? Colors.white.withValues(alpha: 0.05) @@ -190,46 +191,52 @@ class OtaUpgradeView extends GetView { final imagePath = controller.lastConnectedDeviceImage; if (imagePath == null || imagePath.isEmpty) { - return Image.asset( - isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png', - height: 100.h, - fit: BoxFit.contain, + return ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: SizedBox( + width: 120.h, + height: 120.h, + child: Image.asset( + 'assets/images/defaultPicture.jpg', + fit: BoxFit.cover, + ), + ), ); } - // 网络图片 - return Image.network( - imagePath, - height: 100.h, - fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return SizedBox( - height: 100.h, - child: Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - strokeWidth: 2, - color: isDarkMode ? Colors.white : Colors.blue, - ), - ), - ); - }, - errorBuilder: (context, error, stackTrace) { - // 网络图片加载失败时使用默认图片 - return Image.asset( - isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png', - height: 100.h, + // 网络图片(正方形裁剪) + return ClipRRect( + borderRadius: BorderRadius.circular(16.r), + child: SizedBox( + width: 120.h, + height: 120.h, + child: Image.network( + imagePath, fit: BoxFit.contain, - ); - }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return SizedBox( + height: 100.h, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + strokeWidth: 2, + color: isDarkMode ? Colors.white : Colors.blue, + ), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/defaultPicture.jpg', + fit: BoxFit.cover, + ); + }, + ), + ), ); }, ), @@ -827,7 +834,7 @@ class OtaUpgradeView extends GetView { // 更新按钮 Widget _buildUpdateButton(bool isDarkMode) { return Obx(() { - if (controller.isUpgrade.value) { + if (controller.isUpgrade.value && controller.showDetailedStatus.value) { return _buildProgressIndicator(isDarkMode); } diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index c523ac2a2..19d7714d2 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'dart:async'; import 'package:app_settings/app_settings.dart'; -import 'package:voitrans/data/models/appconfig_model.dart'; +import '/data/models/appconfig_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -512,7 +512,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { /// 开始初始检查 void _startInitialCheck() { - _transition(PairingState.checking); + // _transition(PairingState.checking); _checkCurrentStatus(); } diff --git a/lib/modules/qqmusic/views/qqmusic_playlist_view.dart b/lib/modules/qqmusic/views/qqmusic_playlist_view.dart index 8d5a2539b..af7a2556b 100644 --- a/lib/modules/qqmusic/views/qqmusic_playlist_view.dart +++ b/lib/modules/qqmusic/views/qqmusic_playlist_view.dart @@ -1,9 +1,9 @@ import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../agent/widgets/circular_progress_button.dart'; -import '../controllers/qqmusic_playlist_controller.dart'; -import '/../../data/models/song_model.dart'; // Use the data model instead +import '/modules/agent/widgets/circular_progress_button.dart'; +import '/modules/qqmusic/controllers/qqmusic_playlist_controller.dart'; +import '/data/models/song_model.dart'; // Use the data model instead import '../../../core/utils/logger.dart'; class QqmusicPlaylistView extends GetView { diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 60631b889..eabfbbadb 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -10,6 +10,9 @@ import 'package:http/http.dart' as http; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; import '../../../data/models/user_Info.dart'; +import '../../../data/services/meeting/meeting_task_service.dart'; +import '../../../data/services/meeting/meeting_upload_service.dart'; +import '../../../data/services/meeting/summary_task_service.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/network/api.dart'; import '../../../data/services/version_update_service.dart'; @@ -266,8 +269,16 @@ class SplashController extends GetxController { final isAppConfigInitialized = AppConfig.isInitialized(); // 检查登录状态和配置初始化状态 if (token != null && token.isNotEmpty && isAppConfigInitialized) { + Get.find(); + Get.find(); + Get.find(); Get.offAllNamed(Routes.pairing); } else { + _storage.remove('meeting_task_list'); + _storage.remove('meeting_task_finish_list'); + _storage.remove('meeting_upload_list'); + _storage.remove('operation_records'); + _storage.remove('operation_delids'); Get.offAllNamed(Routes.login); } } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index bf53d8f36..230a4369a 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -2,12 +2,15 @@ import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:intl/intl.dart'; +import 'package:native_plugin/native_plugin.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:pip/pip.dart'; import '../../../data/services/ast_service.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/music_manager.dart'; @@ -48,8 +51,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver { final BluetoothManager bluetoothManager = Get.find(); final BleManager bleManager = Get.find(); final _musiceManager = Get.find(); - late final UsageStatsService _usageService; //用量统计服务 + late final TranslationUsageService _usageService; + // 安卓悬浮窗管理器 late final FloatingWindowManager _floatingWindowManager; + // 苹果悬浮窗管理器 + late final Pip _pip; + late final NativePlugin _nativePlugin; + int _pipContentView = 0; // ==================== 悬浮窗相关 ==================== final isFloatingWindowEnabled = false.obs; // 悬浮窗状态 @@ -79,6 +87,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { RxInt activeSpeaker = 0.obs; // 0=无激活, 1=按钮1, 2=按钮2 RxInt lastActiveSpeaker = 0.obs; // 上一次状态 final isRecognizing = false.obs; + final isPreparing = false.obs; final isTranslating = false.obs; final isTtsEnabled = true.obs; final isRecording = false.obs; @@ -135,7 +144,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 底部控制栏高度 double _bottomBarHeight = 0.0; - /// 初始化悬浮窗管理器 + /// 初始化安卓悬浮窗管理器 void _initFloatingWindowManager() { try { if (!Get.isRegistered()) { @@ -148,15 +157,70 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } + /// 初始化苹果悬浮窗管理器 + Future initPlatformState() async { + _pip = Pip(); + _nativePlugin = NativePlugin(); + try { + isFloatingWindowEnabled.value = await _pip.isActived(); + await _pip.registerStateChangedObserver( + PipStateChangedObserver( + onPipStateChanged: (state, error) { + isFloatingWindowEnabled.value = state == PipState.pipStateStarted; + + if (state == PipState.pipStateFailed) { + _pip.dispose(); + } + }, + ), + ); + } on PlatformException { + isFloatingWindowEnabled.value = false; + } + } + /// 切换悬浮窗功能 Future toggleFloatingWindow() async { if (isFloatingWindowEnabled.value) { - await _disableFloatingWindow(); + if (Platform.isAndroid) { + await _disableFloatingWindow(); + } else if (Platform.isIOS) { + await _pip.dispose(); + } } else { - await _enableFloatingWindow(); + if (Platform.isAndroid) { + await _enableFloatingWindow(); + } else if (Platform.isIOS) { + await _startPip(); + } } } + Future _startPip() async { + if (_pipContentView == 0) { + _pipContentView = await _nativePlugin.createPipContentView(); + } + final options = PipOptions( + autoEnterEnabled: true, + aspectRatioX: 375, + aspectRatioY: 400, + sourceRectHintLeft: 0, + sourceRectHintTop: 0, + sourceRectHintRight: 0, + sourceRectHintBottom: 0, + seamlessResizeEnabled: true, + useExternalStateMonitor: true, + externalStateMonitorInterval: 100, + contentView: _pipContentView, + preferredContentWidth: 375, + preferredContentHeight: 400, + controlStyle: 2, + ); + + await _pip.setup(options); + await _pip.start(); + } + /// 启用悬浮窗功能 Future _enableFloatingWindow() async { // 检查是否在支持悬浮窗的模式 @@ -217,16 +281,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _languageManager.getChineseNameByAsrCode(item.targetLanguageCode) ?? 'unknown'; - final data = FloatingWindowData( - sourceText: item.sourceText, - translatedText: item.translatedText, - sourceLanguage: sourceLanguageName, - targetLanguage: targetLanguageName, - timestamp: item.timestamp, - isIntermediate: item.isIntermediate, - ); - - await _floatingWindowManager.updateFloatingWindowContent(data); + if (Platform.isAndroid) { + final data = FloatingWindowData( + sourceText: item.sourceText, + translatedText: item.translatedText, + sourceLanguage: sourceLanguageName, + targetLanguage: targetLanguageName, + timestamp: item.timestamp, + isIntermediate: item.isIntermediate, + ); + await _floatingWindowManager.updateFloatingWindowContent(data); + } else if (Platform.isIOS) { + await _nativePlugin.updatePipContentView({ + 'viewId': _pipContentView, + 'cn': item.sourceText, + 'en': item.translatedText, + 'lang': '$sourceLanguageName -> $targetLanguageName', + }); + } } /// 更改翻译模式 @@ -234,6 +306,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Future changeTranslationMode(String mode) async { currentMode.value = mode; currentModeTitle.value = _getModeTitle(mode); + _historyManager.setMode(mode); + loadTranslationHistory(); //stopAll(); // 检查悬浮窗支持情况 @@ -376,8 +450,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _timerManager = RecordTimerController(); _historyManager = TranslationHistoryManager(); - // 初始化悬浮窗管理器 - _initFloatingWindowManager(); + if (Platform.isAndroid) { + // 初始化安卓悬浮窗管理器 + _initFloatingWindowManager(); + } else if (Platform.isIOS) { + // 初始化苹果悬浮窗管理器 + initPlatformState(); + } // 预先检查权限,避免在用户交互时阻塞 _preCheckPermissions(); @@ -401,7 +480,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { targetLanguageCode.value = targetAsrCode; } - // 加载历史记录 + // 初始化翻译模式(用于历史隔离的初始加载) + final initMode = Get.arguments?['translationId'] ?? 'simultaneous'; + _historyManager.setMode(initMode); + + // 按初始模式加载历史 loadTranslationHistory(); // 初始化服务 @@ -410,9 +493,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 初始化历史管理器 _historyManager.initialize(); - // 初始化翻译模式,根据跳转参数 - final mode = Get.arguments?['translationId'] ?? 'simultaneous'; - changeTranslationMode(mode); + // 应用模式逻辑(含ASR/TTS配置等) + changeTranslationMode(initMode); // 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker WidgetsBinding.instance.addObserver(this); @@ -592,10 +674,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _translationService.initialize(); // 初始化 ASR 服务时,明确指定需要支持的语言 - final List asrSupportedLanguages = [ - sourceLanguageCode.value, - targetLanguageCode.value - ]; + // final List asrSupportedLanguages = [ + // sourceLanguageCode.value, + // targetLanguageCode.value + // ]; //Logger.info('2初始化ASR服务,支持语言: $asrSupportedLanguages'); //await _asrService.initialize(supportedLanguages: asrSupportedLanguages); @@ -664,6 +746,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // _asrService.dispose(); // _astService.dispose(); + if (Platform.isAndroid) { + _disableFloatingWindow(); + } else if (Platform.isIOS) { + _pip.dispose(); + if (_pipContentView != 0) { + _nativePlugin.disposePipContentView(_pipContentView); + } + } + // 释放管理器资源 _timerManager.dispose(); _historyManager.dispose(); @@ -758,10 +849,16 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _initializeSession(); await _configureAudioMode(); if (currentMode.value != 'faceToFace') { + if ((currentMode.value == 'audioVideo' || + currentMode.value == 'call') && + !bleManager.isCodecActive) { + return; + } await _startAsrService(); await _finalizeRecognitionStart(); } } catch (e) { + isPreparing.value = false; isRecognizing.value = false; Logger.error('启动语音识别失败: ${e.toString()}'); } @@ -807,7 +904,18 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 配置音视频模式 Future _configureAudioVideoMode() async { - bleManager.openDecoder(); + isPreparing.value = true; + await bleManager.openDecoder(); + int attempts = 0; + while (!bleManager.isCodecActive && attempts < 10) { + await Future.delayed(const Duration(milliseconds: 150)); + attempts++; + } + isPreparing.value = false; + if (!bleManager.isCodecActive) { + Get.snackbar('tip'.tr, 'operationFailedPleaseRetry'.tr); + throw Exception('lxm--BLE编解码打开失败'); + } _audioSourceType = true; isTtsEnabled.value = false; Logger.info('发送ble音乐或者通话远端声音'); @@ -815,7 +923,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 配置通话模式 Future _configureCallMode() async { - bleManager.openA2DPDecoder(); + final List callModeLanguages = [ + sourceLanguageCode.value, + targetLanguageCode.value + ]; + Logger.info('1初始化AST服务,支持语言: $callModeLanguages'); //ast=asr+translate + await _astService.initialize(supportedLanguages: callModeLanguages); + isPreparing.value = true; + await bleManager.openA2DPDecoder(); + int attempts = 0; + while (!bleManager.isCodecActive && attempts < 10) { + await Future.delayed(const Duration(milliseconds: 150)); + attempts++; + } + isPreparing.value = false; + if (!bleManager.isCodecActive) { + Get.snackbar('tip'.tr, 'operationFailedPleaseRetry'.tr); + throw Exception('lxm--BLE编解码打开失败'); + } _audioSourceType = true; isTtsEnabled.value = false; Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); @@ -845,53 +970,64 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 处理语音识别事件 /// [event] 识别事件 void _handleRecognitionEvent(RecognitionEvent event) { - if (event.type == RecognitionEventType.finalResult && - event.text.isNotEmpty) { - detectedLanguageCode = event.detectedLanguage; - print("处理识别完成:${event.text}"); - if (currentMode.value != 'faceToFace') { - handleFinalResult(event.text); - } else { - faceToFaceRecognitionText.value += event.text; - // 添加日志打印 - Logger.info('lxm-面对面翻译识别文本更新: ${faceToFaceRecognitionText.value}'); - } - if (currentMode.value == 'faceToFace' && activeSpeaker.value == 0) { - handleFinalResult(faceToFaceRecognitionText.value); - faceToFaceRecognitionText.value = ''; - } - } else if (event.type == RecognitionEventType.finalResult1 && - event.text.isNotEmpty) { - // 通话模式下:己方(麦克风)识别完成事件 - // 若原生未返回检测语言,则兜底为源语言 - detectedLanguageCode = event.detectedLanguage.isNotEmpty - ? event.detectedLanguage - : sourceLanguageCode.value; - print("处理己方识别完成:${event.text}"); - if (currentMode.value != 'faceToFace') { + // Logger.info("处理识别事件:${event}"); + switch (event.type) { + case RecognitionEventType.finalResult: + if (event.text.isEmpty) break; + // 通话模式下:目标语言即为识别语言,无需判断 + detectedLanguageCode = currentMode.value == 'call' + ? targetLanguageCode.value + : event.detectedLanguage; + Logger.info("处理识别完成:${event.text}"); + if (currentMode.value != 'faceToFace') { + handleFinalResult(event.text); + } else { + faceToFaceRecognitionText.value += event.text; + Logger.info('lxm-面对面翻译识别文本更新: ${faceToFaceRecognitionText.value}'); + } + if (currentMode.value == 'faceToFace' && activeSpeaker.value == 0) { + handleFinalResult(faceToFaceRecognitionText.value); + faceToFaceRecognitionText.value = ''; + } + break; + + case RecognitionEventType.finalResult1: + // 通话模式下:己方(麦克风)识别完成事件(对应ast服务) + if (event.text.isEmpty) break; + //通话模式下:己方语言即为源语言,无需判断 + detectedLanguageCode = sourceLanguageCode.value; + Logger.info("处理己方识别完成:${event.text}"); handleFinalResult(event.text); - } else { - faceToFaceRecognitionText.value += event.text; - Logger.info('lxm-面对面翻译识别文本更新(己方): ${faceToFaceRecognitionText.value}'); - } - if (currentMode.value == 'faceToFace' && activeSpeaker.value == 0) { - handleFinalResult(faceToFaceRecognitionText.value); - faceToFaceRecognitionText.value = ''; - } - } else if (event.type == RecognitionEventType.intermediateResult && - event.text.isNotEmpty) { - if (event.detectedLanguage.isNotEmpty) { - detectedLanguageCode = event.detectedLanguage; - } - print("处理识别中事件:${event.text}"); - handleIntermediateResult(event.text); - } else if (event.type == RecognitionEventType.error) { - Get.snackbar('error'.tr, event.error); - isRecognizing.value = false; - Logger.error('语音识别错误: ${event.error}'); - } else if (event.type == RecognitionEventType.canceled) { - isRecognizing.value = false; - Logger.error('语音识别取消: ${event.error}'); + break; + + case RecognitionEventType.intermediateResult: + if (event.text.isEmpty) break; + // 通话模式下:目标语言即为识别语言,无需判断 + detectedLanguageCode = currentMode.value == 'call' + ? targetLanguageCode.value + : event.detectedLanguage; + Logger.info("处理识别中事件:${event.text}"); + handleIntermediateResult(event.text); + break; + + case RecognitionEventType.error: + Get.snackbar('error'.tr, event.error); + isRecognizing.value = false; + Logger.error('语音识别错误: ${event.error}'); + break; + + case RecognitionEventType.canceled: + isRecognizing.value = false; + Logger.error('语音识别取消: ${event.error}'); + break; + + case RecognitionEventType.sessionStarted: + // 可按需处理会话开始事件 + break; + + case RecognitionEventType.sessionStopped: + // 可按需处理会话结束事件 + break; } } @@ -924,18 +1060,21 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// [text] 识别的文本 void handleIntermediateResult(String text) { if (text.isEmpty) return; - print( + Logger.info( "handleIntermediateResult:${translationHistory.isEmpty}${translationHistory.isNotEmpty ? translationHistory.last.isIntermediate : 'N/A'}"); if (translationHistory.isEmpty || (translationHistory.isNotEmpty && !translationHistory.last.isIntermediate)) { currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); + // 关键调整:使用与最终结果一致的语言方向,避免左右“跳变” + final languageCodes = _determineLanguageCodes(); + final newItem = TranslationItem( sourceText: text, translatedText: '', - sourceLanguageCode: sourceLanguageCode.value, - targetLanguageCode: targetLanguageCode.value, + sourceLanguageCode: languageCodes['source']!, + targetLanguageCode: languageCodes['target']!, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || @@ -1279,6 +1418,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 停止所有操作 Future stopAll() async { try { + isPreparing.value = false; if (currentMode.value == "audioVideo" || currentMode.value == "call") { bleManager.closeCodec(); Logger.info('关闭ble编解码器'); @@ -1466,27 +1606,34 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } - /// 重新初始化ASR服务以支持新的语言 + /// 重新初始化ASR服务 Future _reinitializeAsrService() async { try { + //1.统一先停止旧识别 await stopRecognition(); - final List asrSupportedLanguages = [ - sourceLanguageCode.value, - targetLanguageCode.value - ]; - - Logger.info('重新初始化ASR服务,支持语言: $asrSupportedLanguages'); + late final List asrSupportedLanguages; - await _asrService.initialize(supportedLanguages: asrSupportedLanguages); - - var recognitionStream = await _asrService.recognizeCallback(); - _recognitionSubscription?.cancel(); - _recognitionSubscription = - recognitionStream.listen(_handleRecognitionEvent); + if (currentMode.value == 'audioVideo') { + asrSupportedLanguages = [sourceLanguageCode.value]; + } else { + asrSupportedLanguages = [ + sourceLanguageCode.value, + targetLanguageCode.value + ]; + } if (currentMode.value == "call") { _initializeCallModeTranslationService(); + } else { + Logger.info('重新初始化ASR服务,支持语言: $asrSupportedLanguages'); + + await _asrService.initialize(supportedLanguages: asrSupportedLanguages); + + var recognitionStream = await _asrService.recognizeCallback(); + _recognitionSubscription?.cancel(); + _recognitionSubscription = + recognitionStream.listen(_handleRecognitionEvent); } Logger.info('ASR服务重新初始化成功'); } catch (e) { diff --git a/lib/modules/translation/controllers/translation_history_controller.dart b/lib/modules/translation/controllers/translation_history_controller.dart index b7e7d15b3..f94f1f7a6 100644 --- a/lib/modules/translation/controllers/translation_history_controller.dart +++ b/lib/modules/translation/controllers/translation_history_controller.dart @@ -15,6 +15,12 @@ class TranslationHistoryManager extends GetxController { static const int _maxStorageItems = 1000; // 存储限制 static const int _maxDisplayItems = 20; // 显示限制 + String _currentMode = 'simultaneous'; // 当前模式 + String _getHistoryKey() => '${_historyKey}_$_currentMode'; // 模式化存储键 + void setMode(String mode) { + _currentMode = mode; + } + final GetStorage _storage = GetStorage(); // 翻译历史数据 @@ -40,7 +46,7 @@ class TranslationHistoryManager extends GetxController { /// 加载翻译历史记录 /// 初始化时只加载最近的显示条数,提高性能 void loadTranslationHistory() { - final historyJson = _storage.read(_historyKey); + final historyJson = _storage.read(_getHistoryKey()); if (historyJson != null) { try { final List historyList = jsonDecode(historyJson); @@ -64,15 +70,15 @@ class TranslationHistoryManager extends GetxController { Logger.info('翻译历史加载成功: ${translationHistory.length}条记录'); } catch (e) { - _storage.remove(_historyKey); + _storage.remove(_getHistoryKey()); Logger.error('加载翻译历史失败: ${e.toString()}'); } } else { - // 没有历史数据时初始化分页状态 hasMoreHistory.value = false; currentPage.value = 1; isLoadingMore.value = false; isLoadingMoreError.value = false; + translationHistory.clear(); } } @@ -94,7 +100,7 @@ class TranslationHistoryManager extends GetxController { // 保存到本地存储 final List> historyJson = historyToSave.map((item) => item.toJson()).toList(); - _storage.write(_historyKey, jsonEncode(historyJson)); + _storage.write(_getHistoryKey(), jsonEncode(historyJson)); Logger.info('翻译历史保存成功: 存储${historyToSave.length}条记录'); } catch (e) { @@ -141,7 +147,7 @@ class TranslationHistoryManager extends GetxController { Logger.i(TAG, '正在加载第 $nextPage 页翻译历史...'); // 从存储中获取所有历史记录 - final historyJson = _storage.read(_historyKey); + final historyJson = _storage.read(_getHistoryKey()); if (historyJson != null) { final List historyList = jsonDecode(historyJson); final List allHistory = @@ -194,8 +200,8 @@ class TranslationHistoryManager extends GetxController { final List result = []; final Set existingIds = {}; - // 添加存储的历史记录 - final historyJson = _storage.read(_historyKey); + // 添加存储的历史记录(按当前模式) + final historyJson = _storage.read(_getHistoryKey()); if (historyJson != null) { final List historyList = jsonDecode(historyJson); final List storedHistory = @@ -235,7 +241,7 @@ class TranslationHistoryManager extends GetxController { /// 清除所有翻译历史 void clearTranslationHistory() { translationHistory.clear(); - _storage.remove(_historyKey); + _storage.remove(_getHistoryKey()); Logger.info('翻译历史已清除'); } @@ -247,8 +253,8 @@ class TranslationHistoryManager extends GetxController { translationHistory.removeWhere((item) => item.sessionId == sessionId); final removedFromMemory = originalCount - translationHistory.length; - // 从存储中删除 - final historyJson = _storage.read(_historyKey); + // 从存储中删除(按当前模式) + final historyJson = _storage.read(_getHistoryKey()); int removedFromStorage = 0; if (historyJson != null) { @@ -263,7 +269,7 @@ class TranslationHistoryManager extends GetxController { // 重新保存 final List> updatedHistoryJson = storedHistory.map((item) => item.toJson()).toList(); - _storage.write(_historyKey, jsonEncode(updatedHistoryJson)); + _storage.write(_getHistoryKey(), jsonEncode(updatedHistoryJson)); } translationHistory.refresh(); @@ -287,8 +293,8 @@ class TranslationHistoryManager extends GetxController { timestamp.millisecondsSinceEpoch); final removedFromMemory = originalCount - translationHistory.length; - // 从存储中删除 - final historyJson = _storage.read(_historyKey); + // 从存储中删除(按当前模式) + final historyJson = _storage.read(_getHistoryKey()); int removedFromStorage = 0; if (historyJson != null) { @@ -306,7 +312,7 @@ class TranslationHistoryManager extends GetxController { // 重新保存 final List> updatedHistoryJson = storedHistory.map((item) => item.toJson()).toList(); - _storage.write(_historyKey, jsonEncode(updatedHistoryJson)); + _storage.write(_getHistoryKey(), jsonEncode(updatedHistoryJson)); } translationHistory.refresh(); diff --git a/lib/modules/translation/views/dialogs/translation_long_press_menu.dart b/lib/modules/translation/views/dialogs/translation_long_press_menu.dart index 7463c6f1f..b1750145e 100644 --- a/lib/modules/translation/views/dialogs/translation_long_press_menu.dart +++ b/lib/modules/translation/views/dialogs/translation_long_press_menu.dart @@ -174,7 +174,7 @@ class TranslationLongPressMenu { 'copySuccess'.tr, // 复制成功 backgroundColor: Colors.blue.withOpacity(0.8), colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, + snackPosition: SnackPosition.TOP, duration: const Duration(milliseconds: 1200), icon: Icon( Icons.check_circle_outline, diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 39a68f818..595126454 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -17,6 +17,7 @@ import '../../common/widgets/language_selector_bar.dart'; // 导入拆分后的组件 import 'widgets/face_to_face_layout.dart'; // 面对面翻译布局 import 'widgets/draggable_floating_button.dart'; // 可拖拽的悬浮按钮 +import 'widgets/translation_item_widget.dart'; // 翻译项组件 import 'dialogs/language_selection_dialog.dart'; // 语言选择对话框 import 'dialogs/translation_mode_selector.dart'; // 翻译模式选择器 import 'dialogs/translation_long_press_menu.dart'; // 长按菜单 @@ -27,6 +28,17 @@ import 'dialogs/translation_long_press_menu.dart'; // 长按菜单 class TranslationView extends GetView { const TranslationView({Key? key}) : super(key: key); + // 本地交互状态:当前显示操作按钮的消息时间戳(不改动控制器) + static final Rx _activeActionTimestamp = Rx(null); + + // 切换当前操作按钮展示的项(再次点击同一项则隐藏) + void _toggleActiveActionItem(TranslationItem item) { + final DateTime ts = item.timestamp; + final DateTime? current = _activeActionTimestamp.value; + _activeActionTimestamp.value = + (current != null && current.isAtSameMomentAs(ts)) ? null : ts; + } + @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; @@ -68,6 +80,31 @@ class TranslationView extends GetView { controller: controller, ), + Positioned( + top: 2, + left: 0, + right: 0, + child: SafeArea( + top: true, + child: Container( + height: 56.h, + padding: EdgeInsets.symmetric(horizontal: 8.w), + color: Colors.transparent, + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + onPressed: () => Get.back(), + ), + ), + ), + ), + ), + // 顶部按钮(倒过来) Positioned( top: 50.h, @@ -137,7 +174,7 @@ class TranslationView extends GetView { size: 20.sp, ), onPressed: () { - // Stop recognition and TTS before navigating back + // 点击返回时,停止所有语音识别和TTS controller.stopAll(); Get.back(); }, @@ -198,28 +235,27 @@ class TranslationView extends GetView { return Row( children: [ // 悬浮窗切换按钮(仅在支持的模式下显示) - if (Platform.isAndroid) - Obx(() { - if (controller.currentMode.value == 'call' || - controller.currentMode.value == 'audioVideo') { - return IconButton( - icon: Icon( - controller.isFloatingWindowEnabled.value - ? Icons.picture_in_picture - : Icons.picture_in_picture_alt_outlined, - color: controller.isFloatingWindowEnabled.value - ? Colors.blue - : (isDarkMode ? Colors.white : Colors.black), - size: 24.sp, - ), - onPressed: controller.toggleFloatingWindow, - tooltip: controller.isFloatingWindowEnabled.value - ? '禁用悬浮窗' - : '启用悬浮窗', - ); - } - return const SizedBox.shrink(); - }), + Obx(() { + if (controller.currentMode.value == 'call' || + controller.currentMode.value == 'audioVideo') { + return IconButton( + icon: Icon( + controller.isFloatingWindowEnabled.value + ? Icons.picture_in_picture + : Icons.picture_in_picture_alt_outlined, + color: controller.isFloatingWindowEnabled.value + ? Colors.blue + : (isDarkMode ? Colors.white : Colors.black), + size: 24.sp, + ), + onPressed: controller.toggleFloatingWindow, + tooltip: controller.isFloatingWindowEnabled.value + ? '禁用悬浮窗' + : '启用悬浮窗', + ); + } + return const SizedBox.shrink(); + }), IconButton( icon: Icon( Icons.radio_button_checked, @@ -271,10 +307,8 @@ class TranslationView extends GetView { children: [ // 统一通用语言选择栏组件 LanguageSelectorBar( - sourceText: () => - controller.sourceLanguage.value.split(' ')[0], - targetText: () => - controller.targetLanguage.value.split(' ')[0], + sourceText: () => controller.sourceLanguage.value.split(' ')[0], + targetText: () => controller.targetLanguage.value.split(' ')[0], onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode), onTapTarget: () => @@ -286,9 +320,7 @@ class TranslationView extends GetView { ? Icon( Icons.phone_in_talk, size: 20.sp, - color: isDarkMode - ? Colors.white70 - : Colors.grey[600], + color: isDarkMode ? Colors.white70 : Colors.grey[600], ) : null, ), @@ -396,128 +428,18 @@ class TranslationView extends GetView { final historyItem = controller.translationHistory[historyIndex]; // 构建项目widget - return _buildTranslationItemWidget(context, historyItem); + return TranslationItemWidget( + item: historyItem, + activeActionTimestamp: _activeActionTimestamp, + onToggleActionItem: _toggleActiveActionItem, + onLongPressStart: _onLongPressTranslationItem, + onDeleteItemConfirm: _showDeleteItemConfirmDialog, + ); }, ); }); } - Widget _buildTranslationItemWidget( - BuildContext context, TranslationItem item) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - // 定义自适应颜色 - final selfColor = isDarkMode - ? Colors.blue[800]!.withOpacity(0.15) // 深色模式:深蓝+透明度 - : Colors.blue[50]!; // 浅色模式:浅蓝 - - final otherColor = isDarkMode - ? Colors.green[800]!.withOpacity(0.15) // 深色模式:深绿+透明度 - : Colors.green[50]!; // 浅色模式:浅绿 - - return GestureDetector( - onLongPressStart: (details) => _onLongPressTranslationItem( - context, - isDarkMode, - details, - item, - ), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: item.isIntermediate - ? Colors.grey.withOpacity(isDarkMode ? 0.15 : 0.1) // 中间结果 - : (item.sourceLanguageCode == controller.sourceLanguageCode.value - ? selfColor // 自己说的话 - : otherColor), // 对方说的话 - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: item.isIntermediate - ? Colors.grey.withOpacity(isDarkMode ? 0.3 : 0.2) - : Colors.transparent, - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 源文本和删除按钮行 - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 源文本 - Expanded( - child: Text( - item.sourceText, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - ), - ), - // 删除按钮 - 只对完成态记录显示,与源文本平齐 - if (!item.isIntermediate) - GestureDetector( - onTap: () => _showDeleteItemConfirmDialog(item), - child: Container( - padding: EdgeInsets.all(4.w), - child: Icon( - Icons.delete_outline, - size: 18.sp, - color: Colors.grey[500], - ), - ), - ), - ], - ), - if (item.translatedText.isNotEmpty) ...[ - const SizedBox(height: 8), - // 翻译文本 - Text( - item.translatedText, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - fontWeight: FontWeight.w500, - ), - ), - ], - // 语言信息和时间戳 - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - '${item.sourceLanguageName.tr} → ${item.targetLanguageName.tr}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).textTheme.bodySmall?.color, - ), - ), - ), - // 播放按钮 - if (!item.isIntermediate) - GestureDetector( - onTap: () => controller.playTranslationItem(item), - child: Container( - padding: EdgeInsets.all(4.w), - child: Icon( - Icons.volume_up, - size: 20.sp, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } - Widget _buildControlBar(bool isDarkMode) { // 使用GlobalKey来获取控制栏的高度 final controlBarKey = GlobalKey(); @@ -545,134 +467,122 @@ class TranslationView extends GetView { ), ], ), - child: Obx(() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 原有的按钮区域 - if (controller.currentMode.value == 'faceToFace') - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 耳机图标按钮 (说话者1)-显示源语言 - Obx(() => _buildHoldToTalkButton( - isDarkMode: isDarkMode, - speakerId: 1, - activeSpeaker: controller.activeSpeaker.value, - icon: Icons.mic_none, - label: controller.sourceLanguage.value - .split(' ')[0] - .tr, // 源语言名称 - )), - SizedBox(width: 16.w), - // 手机图标按钮 (说话者2)-显示目标语言 - Obx(() => _buildHoldToTalkButton( - isDarkMode: isDarkMode, - speakerId: 2, - activeSpeaker: controller.activeSpeaker.value, - icon: Icons.mic_none, - label: controller.targetLanguage.value - .split(' ')[0] - .tr, // 目标语言名称 - )), - ], - ) - else - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 主要控制按钮 - 开始/暂停语音识别 - Obx(() { - final isRecognizing = controller.isRecognizing.value; - return GestureDetector( - onTap: () { - if (isRecognizing) { - controller.stopAll(); - } else { - controller.startRecognition(); - } - }, - child: Container( - width: 60.w, - height: 60.w, - decoration: BoxDecoration( - color: isRecognizing - ? (isDarkMode ? Colors.red[700] : Colors.red) - : (isDarkMode ? AppColors.primary : Colors.blue), - shape: BoxShape.circle, - ), - child: Icon( - isRecognizing ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 30.sp, - ), - ), - ); - }), - ], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 原有的按钮区域 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 主要控制按钮 - 开始/暂停语音识别 + Obx(() { + final isRecognizing = controller.isRecognizing.value; + final isPreparing = controller.isPreparing.value; + return GestureDetector( + onTap: () { + if (isRecognizing) { + controller.stopAll(); + } else { + controller.startRecognition(); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: 60.w, + height: 60.w, + transform: isPreparing + ? (Matrix4.identity()..scale(1.08)) + : Matrix4.identity(), + decoration: BoxDecoration( + color: isRecognizing + ? (isDarkMode ? Colors.red[700] : Colors.red) + : (isDarkMode ? AppColors.primary : Colors.blue), + shape: BoxShape.circle, + boxShadow: isPreparing + ? [ + BoxShadow( + color: (isDarkMode + ? AppColors.primary + : Colors.blue) + .withOpacity(0.5), + blurRadius: 12, + spreadRadius: 2, + ) + ] + : [], + ), + child: Icon( + isRecognizing ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 30.sp, + ), + ), + ); + }), + ], + ), - // 添加间距 - //SizedBox(height: 16.h), + // 添加间距 + //SizedBox(height: 16.h), - // 语音识别结果显示框 - // Container( - // width: double.infinity, - // padding: EdgeInsets.all(12.w), - // decoration: BoxDecoration( - // color: isDarkMode ? Colors.grey[800] : Colors.grey[100], - // borderRadius: BorderRadius.circular(8.r), - // border: Border.all( - // color: isDarkMode ? Colors.grey[600]! : Colors.grey[300]!, - // width: 1, - // ), - // ), - // child: Obx(() { - // // 显示最新的语音识别结果 - // final recognitionText = controller.latestRecognitionText.value; - // final detectedLanguage = controller.detectedLanguageCode1.value; + // 语音识别结果显示框 + // Container( + // width: double.infinity, + // padding: EdgeInsets.all(12.w), + // decoration: BoxDecoration( + // color: isDarkMode ? Colors.grey[800] : Colors.grey[100], + // borderRadius: BorderRadius.circular(8.r), + // border: Border.all( + // color: isDarkMode ? Colors.grey[600]! : Colors.grey[300]!, + // width: 1, + // ), + // ), + // child: Obx(() { + // // 显示最新的语音识别结果 + // final recognitionText = controller.latestRecognitionText.value; + // final detectedLanguage = controller.detectedLanguageCode1.value; - // return Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // // 标题 - // Text( - // '麦头语音识别结果', - // style: TextStyle( - // fontSize: 12.sp, - // color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - // fontWeight: FontWeight.w500, - // ), - // ), - // SizedBox(height: 4.h), - // // 识别文本 - 这里显示 finalResult1 的内容 - // Text( - // recognitionText.isEmpty ? '等待语音输入...' : recognitionText, - // style: TextStyle( - // fontSize: 14.sp, - // color: isDarkMode ? Colors.white : Colors.black87, - // height: 1.3, - // ), - // ), - // // 检测到的语言(如果有) - // if (detectedLanguage.isNotEmpty) ...[ - // SizedBox(height: 4.h), - // Text( - // '检测语言: $detectedLanguage', - // style: TextStyle( - // fontSize: 10.sp, - // color: - // isDarkMode ? Colors.grey[500] : Colors.grey[500], - // ), - // ), - // ], - // ], - // ); - // }), - // ), - ], - ); - }), + // return Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // // 标题 + // Text( + // '麦头语音识别结果', + // style: TextStyle( + // fontSize: 12.sp, + // color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + // fontWeight: FontWeight.w500, + // ), + // ), + // SizedBox(height: 4.h), + // // 识别文本 - 这里显示 finalResult1 的内容 + // Text( + // recognitionText.isEmpty ? '等待语音输入...' : recognitionText, + // style: TextStyle( + // fontSize: 14.sp, + // color: isDarkMode ? Colors.white : Colors.black87, + // height: 1.3, + // ), + // ), + // // 检测到的语言(如果有) + // if (detectedLanguage.isNotEmpty) ...[ + // SizedBox(height: 4.h), + // Text( + // '检测语言: $detectedLanguage', + // style: TextStyle( + // fontSize: 10.sp, + // color: + // isDarkMode ? Colors.grey[500] : Colors.grey[500], + // ), + // ), + // ], + // ], + // ); + // }), + // ), + ], + ), ); } @@ -781,10 +691,10 @@ class TranslationView extends GetView { mainAxisSize: MainAxisSize.min, children: [ SizedBox( - width: 24, - height: 24, + width: 24.w, + height: 24.h, child: CircularProgressIndicator( - strokeWidth: 2.5, + strokeWidth: 2.5.w, valueColor: const AlwaysStoppedAnimation( AppColors.primary, ), @@ -792,7 +702,7 @@ class TranslationView extends GetView { ), const SizedBox(height: 12), Text( - '正在加载历史记录...', + 'loading'.tr, //加载中 style: TextStyle( fontSize: 14.sp, color: Colors.grey[600], @@ -828,7 +738,7 @@ class TranslationView extends GetView { ), SizedBox(width: 8.w), Text( - '加载失败,点击重试', + 'loadFailedClickRetry'.tr, style: TextStyle( fontSize: 14.sp, color: Colors.red[600], @@ -862,7 +772,7 @@ class TranslationView extends GetView { ), SizedBox(width: 4.w), Text( - '向上滑动查看更多历史', + 'swipeUpForMore'.tr, style: TextStyle( fontSize: 12.sp, color: Colors.grey[600], diff --git a/lib/modules/translation/views/widgets/face_to_face_layout.dart b/lib/modules/translation/views/widgets/face_to_face_layout.dart index 562a543b9..efc6140a3 100644 --- a/lib/modules/translation/views/widgets/face_to_face_layout.dart +++ b/lib/modules/translation/views/widgets/face_to_face_layout.dart @@ -7,7 +7,7 @@ import '../../../../core/theme/app_colors.dart'; import '../dialogs/language_selection_dialog.dart'; /// 面对面翻译布局组件 -class FaceToFaceLayout extends StatelessWidget { +class FaceToFaceLayout extends StatefulWidget { final bool isDarkMode; final TranslationController controller; @@ -17,13 +17,60 @@ class FaceToFaceLayout extends StatelessWidget { required this.controller, }) : super(key: key); + @override + State createState() => _FaceToFaceLayoutState(); +} + +class _FaceToFaceLayoutState extends State { + final ScrollController _topScrollController = ScrollController(); + final ScrollController _bottomScrollController = ScrollController(); + + @override + void dispose() { + _topScrollController.dispose(); + _bottomScrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom({bool animate = false}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_topScrollController.hasClients) { + final m = _topScrollController.position.maxScrollExtent; + if (animate) { + _topScrollController.animateTo( + m, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } else { + _topScrollController.jumpTo(m); + } + } + if (_bottomScrollController.hasClients) { + final m2 = _bottomScrollController.position.maxScrollExtent; + if (animate) { + _bottomScrollController.animateTo( + m2, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } else { + _bottomScrollController.jumpTo(m2); + } + } + }); + } + @override Widget build(BuildContext context) { + final isDarkMode = widget.isDarkMode; + final controller = widget.controller; return Obx(() { // 获取最新的翻译记录 final latestItem = controller.translationHistory.isNotEmpty ? controller.translationHistory.last : null; + _scrollToBottom(animate: false); return Column( children: [ @@ -33,7 +80,7 @@ class FaceToFaceLayout extends StatelessWidget { angle: 3.14159, // 180度 = π弧度 child: Container( width: double.infinity, - padding: EdgeInsets.all(24.w), + padding: EdgeInsets.fromLTRB(24.w, 24.w, 24.w, 24.w + 96.h), decoration: BoxDecoration( color: isDarkMode ? AppColors.cardBackground.withValues(alpha: 0.5) @@ -45,24 +92,22 @@ class FaceToFaceLayout extends StatelessWidget { ), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 上半部的文本 - Text( - latestItem?.targetLanguageCode == - controller.targetLanguageCode.value - ? latestItem?.translatedText ?? '' //如果为null则显示空字符 - : latestItem?.sourceText ?? '', - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.4, - ), - textAlign: TextAlign.center, + child: SingleChildScrollView( + controller: _topScrollController, + physics: const BouncingScrollPhysics(), + child: Text( + latestItem?.targetLanguageCode == + controller.targetLanguageCode.value + ? latestItem?.translatedText ?? '' + : latestItem?.sourceText ?? '', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + height: 1.4, ), - ], + textAlign: TextAlign.center, + ), ), ), ), @@ -75,33 +120,31 @@ class FaceToFaceLayout extends StatelessWidget { Expanded( child: Container( width: double.infinity, - padding: EdgeInsets.all(24.w), + padding: EdgeInsets.fromLTRB(24.w, 24.w, 24.w, 24.w + 96.h), decoration: BoxDecoration( color: isDarkMode ? AppColors.cardBackground.withValues(alpha: 0.5) : Colors.green[50]?.withValues(alpha: 0.3), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 下半部的文本 - Text( - latestItem?.sourceLanguageCode == - controller.sourceLanguageCode.value - ? latestItem?.sourceText ?? - (controller.faceToFaceRecognitionText.value.isEmpty - ? '' - : controller.faceToFaceRecognitionText.value) - : latestItem?.translatedText ?? '', - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black87, - height: 1.4, - ), - textAlign: TextAlign.center, + child: SingleChildScrollView( + controller: _bottomScrollController, + physics: const BouncingScrollPhysics(), + child: Text( + latestItem?.sourceLanguageCode == + controller.sourceLanguageCode.value + ? latestItem?.sourceText ?? + (controller.faceToFaceRecognitionText.value.isEmpty + ? '' + : controller.faceToFaceRecognitionText.value) + : latestItem?.translatedText ?? '', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + height: 1.4, ), - ], + textAlign: TextAlign.center, + ), ), ), ), @@ -146,7 +189,7 @@ class FaceToFaceLayout extends StatelessWidget { borderRadius: BorderRadius.circular(16.r), ), child: Text( - '${controller.sourceLanguage.value.split(' ')[0]}'.tr, + '${widget.controller.sourceLanguage.value.split(' ')[0]}'.tr, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, @@ -161,7 +204,7 @@ class FaceToFaceLayout extends StatelessWidget { // 语言互换按钮 GestureDetector( - onTap: controller.swapLanguages, + onTap: widget.controller.swapLanguages, child: Container( margin: EdgeInsets.symmetric(horizontal: 8.w), padding: EdgeInsets.all(4.r), @@ -194,7 +237,8 @@ class FaceToFaceLayout extends StatelessWidget { child: Transform.rotate( angle: 3.14159, // 旋转180度 child: Text( - '${controller.targetLanguage.value.split(' ')[0]}'.tr, + '${widget.controller.targetLanguage.value.split(' ')[0]}' + .tr, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, diff --git a/lib/modules/translation/views/widgets/translation_item_widget.dart b/lib/modules/translation/views/widgets/translation_item_widget.dart new file mode 100644 index 000000000..56ae1b264 --- /dev/null +++ b/lib/modules/translation/views/widgets/translation_item_widget.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../controllers/translation_controller.dart'; +import '../../models/translation_models.dart'; +import '../dialogs/translation_long_press_menu.dart'; + +class TranslationItemWidget extends GetView { + // 传入的翻译条目 + final TranslationItem item; + + // 传入用于控制操作按钮显隐的时间戳状态 + final Rx activeActionTimestamp; + + // 点击气泡时切换操作按钮显隐 + final void Function(TranslationItem item) onToggleActionItem; + + // 长按时弹出菜单(需要上下文、暗色模式、手势详情、条目) + final void Function( + BuildContext context, + bool isDarkMode, + LongPressStartDetails details, + TranslationItem item, + ) onLongPressStart; + + // 删除按钮点击时的确认对话框 + final void Function(TranslationItem item) onDeleteItemConfirm; + + const TranslationItemWidget({ + Key? key, + required this.item, + required this.activeActionTimestamp, + required this.onToggleActionItem, + required this.onLongPressStart, + required this.onDeleteItemConfirm, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + // 定义自适应颜色(与原实现保持一致) + final selfColor = + isDarkMode ? Colors.blue[800]!.withOpacity(0.15) : Colors.blue[50]!; + final otherColor = + isDarkMode ? Colors.green[800]!.withOpacity(0.15) : Colors.green[50]!; + + // 判断是否是己方的翻译项(依据控制器当前源语言) + final bool isSelf = + item.sourceLanguageCode == controller.sourceLanguageCode.value; + // 新增:通话模式标记,用于控制右侧显示与隐藏中间态 + final bool isCallMode = controller.currentMode.value == 'call'; + + return GestureDetector( + onTap: () => onToggleActionItem(item), // 触碰气泡时切换按钮显隐 + onLongPressStart: (details) => + onLongPressStart(context, isDarkMode, details, item), + child: Align( + alignment: isCallMode + ? (isSelf ? Alignment.centerLeft : Alignment.centerRight) + : item.isIntermediate + ? Alignment.center + : (isSelf ? Alignment.centerLeft : Alignment.centerRight), + child: Container( + margin: EdgeInsets.only(bottom: 8.h), + padding: EdgeInsets.all(12.w), + constraints: BoxConstraints(maxWidth: 0.8.sw, minWidth: 0.35.sw), + decoration: BoxDecoration( + color: item.isIntermediate + ? Colors.grey.withOpacity(isDarkMode ? 0.15 : 0.1) // 中间结果 + : (isSelf ? selfColor : otherColor), // 自己 / 对方 + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: item.isIntermediate + ? Colors.grey.withOpacity(isDarkMode ? 0.3 : 0.2) + : Colors.transparent, + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 源文本 + Text( + item.sourceText, + style: TextStyle( + fontSize: 16.sp, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + SizedBox(height: 8.h), + // 分隔线 + 译文 + Container( + padding: EdgeInsets.only(top: 8.h), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: item.isIntermediate + ? (isDarkMode + ? Colors.grey[400]!.withOpacity(0.15) + : Colors.grey[400]!.withOpacity(0.25)) + : (isSelf + ? (isDarkMode + ? Colors.blue[300]!.withOpacity(0.15) + : Colors.blue[300]!.withOpacity(0.25)) + : (isDarkMode + ? Colors.green[300]!.withOpacity(0.15) + : Colors.green[300]!.withOpacity(0.25))), + width: 1, + ), + ), + ), + child: Text( + item.translatedText, + style: TextStyle( + fontSize: 16.sp, + color: Theme.of(context).textTheme.bodyMedium?.color, + fontWeight: FontWeight.w500, + ), + ), + ), + // 操作按钮:复制 / 播放 / 删除(仅非中间态且显隐状态为激活时显示) + Obx(() { + final bool isActive = activeActionTimestamp.value + ?.isAtSameMomentAs(item.timestamp) ?? + false; + if (!item.isIntermediate && isActive) { + return Column( + children: [ + SizedBox(height: 8.h), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 复制按钮(复用长按菜单的复制逻辑) + GestureDetector( + onTap: () => + TranslationLongPressMenu.copyTranslationItem( + item), + child: Container( + padding: EdgeInsets.all(4.w), + child: Tooltip( + message: 'copy'.tr, // 翻译键代表的中文文本内容 + child: Icon( + Icons.copy, + size: 20.sp, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + SizedBox(width: 8.w), + // 播放按钮(调用控制器原有方法) + GestureDetector( + onTap: () => controller.playTranslationItem(item), + child: Container( + padding: EdgeInsets.all(4.w), + child: Icon( + Icons.volume_up, + size: 20.sp, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + SizedBox(width: 8.w), + // 删除按钮(弹出确认) + GestureDetector( + onTap: () => onDeleteItemConfirm(item), + child: Container( + padding: EdgeInsets.all(4.w), + child: Tooltip( + message: 'delete'.tr, // 翻译键代表的中文文本内容 + child: Icon( + Icons.delete_outline, + size: 20.sp, + color: Colors.grey[500], + ), + ), + ), + ), + ], + ) + ], + ); + } else { + return const SizedBox.shrink(); + } + }), + ], + ), + ), + ), + ); + } +} diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index f76c442cb..64b3900bf 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -19,6 +19,7 @@ abstract class Routes { static const settings = '/settings'; // 系统设置 static const permissions = '/permissions'; // 权限管理 static const recording = '/recording'; // 录音功能 + static const eqSettings = '/eq_settings'; // 设备设置 // 会议相关 static const meeting = '/meeting'; diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt index 44bb4f630..fba29431d 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt @@ -183,8 +183,13 @@ class BleCommandSender { } /** - * 处理设备响应数据 - 从BleService移动过来 - * @param data 响应数据 + * 处理设备响应数据(统一兼容失败无长度的场景) + * + * 帧格式通常为: [HEADER(0)=0xBB][CMD(1)][LEN/STATUS(2)][DATA(len)][CRC(最后1字节)] + * - 当 LEN/STATUS 为 0(例如收到 0xA2, 0x00),表示该命令执行失败,直接回调失败并返回。 + * - 正常响应时进行 CRC 校验(若帧长度不足则跳过校验并告警),拷贝数据使用安全长度以避免越界。 + * + * @param data 设备返回的原始字节数组 */ @SuppressLint("StaticFieldLeak") @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) @@ -200,63 +205,64 @@ class BleCommandSender { val header = data[0] // 0xBB val cmdType = data[1].toInt() and 0xFF - - // 判断命令失败响应(长度为0) - if (data.size == 4 && data[2].toInt() and 0xFF == 0) { - Log.w(TAG, "命令 0x${cmdType.toString(16)} 执行失败") - // 创建失败结果Map - val resultMap = mapOf( - "success" to false, - "command" to cmdType, - "rawData" to byteArrayOf() - ) - callback?.notifyDeviceInfoReceived(cmdType, resultMap) - // 通知指令发送器处理响应 - onCommandResponse(cmdType) - return - } - - // 正常响应处理 - val length = data[2].toInt() and 0xFF - - // 检查数据长度 - if (data.size != length + 4) { // 帧头 + 命令 + 长度 + 数据 + CRC - Log.e(TAG, "设备响应数据长度不匹配: 期望=${length + 4}, 实际=${data.size}") - return + val lenByte = data[2].toInt() and 0xFF + + + + // 正常响应处理:尽量进行 CRC 校验 + var crcOk = true + if (data.size >= 4) { + val payload = data.sliceArray(0 until data.size - 1) + val calculatedCrc = calculateCrc8Maxim(payload, payload.size) + val receivedCrc = data[data.size - 1].toInt() and 0xFF + crcOk = (calculatedCrc.toInt() and 0xFF) == receivedCrc + if (!crcOk) { + Log.e( + TAG, + "设备响应CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc}" + ) + return + } + } else { + Log.w(TAG, "设备响应长度不足以包含CRC,跳过CRC校验") } + // 统一失败判定 + if (lenByte != data.size-4) { + Log.w(TAG, "命令 0x${cmdType.toString(16)} 执行失败(无数据长度)") + val resultMap = mapOf( + "success" to true, + "command" to cmdType, + "rawData" to byteArrayOf(data[2], data[data.size - 2]) // 取第二位和倒数第二位 + ) + callback?.notifyDeviceInfoReceived(cmdType, resultMap) + onCommandResponse(cmdType) + return + } + // 安全提取响应数据,避免越界 + val actualPayloadLen = (data.size - 4).coerceAtLeast(0) + val copyLen = minOf(lenByte, actualPayloadLen) + val responseData = if (copyLen > 0) ByteArray(copyLen) else ByteArray(0) - // 计算CRC校验 - 修正:计算除最后一个CRC字节外的所有字节 - val payload = data.sliceArray(0 until data.size - 1) - val calculatedCrc = calculateCrc8Maxim(payload, payload.size) - val receivedCrc = data[data.size - 1].toInt() and 0xFF - - if (calculatedCrc.toInt() and 0xFF != receivedCrc) { - Log.e( + if (copyLen < lenByte) { + Log.w( TAG, - "设备响应CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc}" + "响应长度字段(${lenByte})与实际负载长度(${actualPayloadLen})不一致,按实际长度处理" ) - return } - // 提取响应数据 - val responseData = if (length > 0) ByteArray(length) else ByteArray(0) - if (length > 0) { - System.arraycopy(data, 3, responseData, 0, length) + if (copyLen > 0) { + System.arraycopy(data, 3, responseData, 0, copyLen) } Log.d( TAG, "收到设备响应: CMD=0x${cmdType.toString(16)}, 数据=${ - responseData.joinToString(", ") { - "0x${ - (it.toInt() and 0xFF).toString(16) - }" - } + responseData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" } }" ) - // 通知指令发送器处理响应 + onCommandResponse(cmdType) - // 根据命令类型处理不同的响应 + when (cmdType) { BleConst.CMD_GET_VERSION -> { val resultMap = mutableMapOf( diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 52c2706d6..e0d572ed6 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -21,7 +21,8 @@ object BleConst { /** 通话写入音频特征UUID - 文档中定义为0000ABC1-0001-1111-2222-123456789ABC */ val CALL_WRITE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") - + /** 通话接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ + val CALL_RECEIVE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ABC2-0001-1111-2222-123456789ABC") /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 040a87204..02f2c6cc6 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -75,6 +75,7 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null + private var callAudioChar: BluetoothGattCharacteristic? = null private var callWriteChar: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null var recordfile1: RecordingFile? = null @@ -702,6 +703,7 @@ object BleService { notifyChar = null writeChar = null audioChar = null + callAudioChar = null callWriteChar = null } } @@ -849,6 +851,7 @@ object BleService { // 获取通话音频服务特征 val callAudioSvc = g.getService(BleConst.CALL_AUDIO_SERVICE_UUID) callWriteChar = callAudioSvc?.getCharacteristic(BleConst.CALL_WRITE_AUDIO_CHAR_UUID) + callAudioChar = callAudioSvc?.getCharacteristic(BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID) if (callWriteChar == null) { Log.e(TAG, "未找到通话音频主服务所需特征") updateConnectionState(BleConst.STATE_ERROR) @@ -860,12 +863,15 @@ object BleService { callWriteChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE //要先设置音频服务的通知,否则接收不到 // 设置音频服务的通知(如果存在) - if (audioChar != null) { + if (callAudioChar != null && audioChar != null) { + setupNotifications(g, callAudioChar) setupNotifications(g, audioChar) - Log.i(TAG, "音频服务特征找到并设置通知") + + + Log.i(TAG, "通话音频服务特征找到并设置通知") } else { - Log.w(TAG, "音频服务特征未找到") + Log.w(TAG, "通话音频服务特征未找到") } setupNotifications(g, notifyChar) @@ -887,6 +893,14 @@ object BleService { // 统计接收到的字节数 bytesReceivedInCurrentSecond += data.size processAudioData(data) + Log.i(TAG, "lxm--收到音频特征数据, 字节数=${data.size}") + } + BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID -> { + + // 统计接收到的字节数 + bytesReceivedInCurrentSecond += data.size + processAudioData(data) + Log.i(TAG, "lxm--收到通话音频特征数据, 字节数=${data.size}") } // 通知特征数据(命令和控制) BleConst.NOTIFY_CHAR_UUID -> { diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt index 110d502dc..f21c6f431 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/OpusAudioManager.kt @@ -82,11 +82,13 @@ class OpusAudioManager { // ====================================================================================================== // Opus解码器实例 - private var opusManager: OpusManager? = null - private var option: OpusOption? = null + //解码对象 + private var decodeOpusManager: OpusManager? = null + private var decodeOption: OpusOption? = null - private var opusManager1: OpusManager? = null - private var option1: OpusOption? = null + //编码对象 + private var encodeOpusManager: OpusManager? = null + private var encodeOption: OpusOption? = null private val mainHandler = Handler(Looper.getMainLooper()) // 解码音频数据队列和处理线程 @@ -124,11 +126,11 @@ class OpusAudioManager { try { // 初始化OpusManager和OpusOption - opusManager = OpusManager() - option = OpusOption() + decodeOpusManager = OpusManager() + decodeOption = OpusOption() - opusManager1 = OpusManager() - option1 = OpusOption() + encodeOpusManager = OpusManager() + encodeOption = OpusOption() // 启动队列处理 startAudioQueueProcessing() @@ -178,10 +180,10 @@ class OpusAudioManager { audioSendQueue.clear() // 清理Opus相关资源 - opusManager = null - option = null - opusManager1 = null - option1 = null + decodeOpusManager = null + decodeOption = null + encodeOpusManager = null + encodeOption = null callback = null isInitialized = false @@ -206,14 +208,14 @@ class OpusAudioManager { sampleRate: Int = 16000, packetSize: Int = 40 ): Boolean { - if (!isInitialized || opusManager == null) { + if (!isInitialized || decodeOpusManager == null) { Log.e(TAG, "OpusManager未初始化,无法开始解码") return false } // 如果已经在解码流,先停止 - if (opusManager?.isDecodeStream == true) { - opusManager?.stopDecodeStream() + if (decodeOpusManager?.isDecodeStream == true) { + decodeOpusManager?.stopDecodeStream() Log.d(TAG, "已停止正在进行的Opus解码流") } @@ -224,7 +226,7 @@ class OpusAudioManager { } // 配置解码参数 - option?.apply { + decodeOption?.apply { setHasHead(hasHeader) setChannel(channel) setSampleRate(sampleRate) @@ -234,10 +236,10 @@ class OpusAudioManager { Log.d(TAG, "准备开始Opus数据流解码, 参数: hasHeader=$hasHeader, channel=$channel, sampleRate=$sampleRate, packetSize=$packetSize") try { - opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { + decodeOpusManager?.startDecodeStream(decodeOption, object : OnDecodeStreamCallback { override fun onDecodeStream(data: ByteArray?) { if (data != null) { - val channelCount = option?.getChannel() ?: 1 + val channelCount = decodeOption?.getChannel() ?: 1 if (channelCount >= 1) { callback?.onAudioDataDecoded(data, channelCount) } else { @@ -276,8 +278,8 @@ class OpusAudioManager { * @return 是否成功停止解码 */ fun stopDecodeStream(): Boolean { - if (opusManager?.isDecodeStream == true) { - opusManager?.stopDecodeStream() + if (decodeOpusManager?.isDecodeStream == true) { + decodeOpusManager?.stopDecodeStream() // 清理音频数据缓存 synchronized(audioDataBuffer) { @@ -303,7 +305,7 @@ class OpusAudioManager { return } - if (opusManager?.isDecodeStream == true) { + if (decodeOpusManager?.isDecodeStream == true) { // 将数据加入队列进行异步处理 audioDataQueue.offer(data) } else { @@ -320,19 +322,19 @@ class OpusAudioManager { * @return 是否成功开始编码 */ fun startEncodeStream(): Boolean { - if (!isInitialized || opusManager1 == null) { + if (!isInitialized || encodeOpusManager == null) { Log.e(TAG, "OpusManager未初始化,无法开始编码") return false } // 如果已经在编码流,先停止 - if (opusManager1?.isEncodeStream == true) { + if (encodeOpusManager?.isEncodeStream == true) { Log.d(TAG, "Opus编码流已在运行") return true } // 配置解码参数 - option1?.apply { + encodeOption?.apply { setHasHead(false) setChannel(1) setSampleRate(16000) @@ -340,7 +342,7 @@ class OpusAudioManager { } try { Log.d(TAG, "准备开始Opus数据流编码") - opusManager1?.startEncodeStream(object : OnEncodeStreamCallback { + encodeOpusManager?.startEncodeStream(object : OnEncodeStreamCallback { override fun onEncodeStream(data: ByteArray?) { Log.d(TAG, "Opus编码回调收到数据,大小: ${data?.size ?: 0} bytes") if (data != null) { @@ -386,8 +388,8 @@ class OpusAudioManager { * @return 是否成功停止编码 */ fun stopEncodeStream(): Boolean { - if (opusManager1?.isEncodeStream == true) { - opusManager1?.stopEncodeStream() + if (encodeOpusManager?.isEncodeStream == true) { + encodeOpusManager?.stopEncodeStream() stopAudioSendThread() // 停止音频发送线程 Log.i(TAG, "已停止Opus数据流编码") callback?.onEncodeStreamStateChanged(false) @@ -404,16 +406,16 @@ class OpusAudioManager { * @param data 音频数据字节数组,通常为PCM格式 */ fun writeExternalAudioData(data: ByteArray) { - if (!isInitialized || opusManager1 == null) { + if (!isInitialized || encodeOpusManager == null) { Log.e(TAG, "OpusManager未初始化") return } // 如果已经在编码流中,直接写入数据 - if (opusManager1?.isEncodeStream == true) { + if (encodeOpusManager?.isEncodeStream == true) { Log.d(TAG, "正在进行Opus编码流,写入音频数据") // 将外部音频数据写入编码流 - opusManager1?.writeEncodeStream(data) + encodeOpusManager?.writeEncodeStream(data) } else { Log.w(TAG, "Opus编码流未启动,无法写入音频数据") } @@ -428,7 +430,7 @@ class OpusAudioManager { * @return 是否正在解码 */ fun isDecoding(): Boolean { - return opusManager?.isDecodeStream == true + return decodeOpusManager?.isDecodeStream == true } /** @@ -436,7 +438,7 @@ class OpusAudioManager { * @return 是否正在编码 */ fun isEncoding(): Boolean { - return opusManager1?.isEncodeStream == true + return encodeOpusManager?.isEncodeStream == true } /** @@ -488,7 +490,7 @@ class OpusAudioManager { val audioData = audioDataQueue.take() // 阻塞等待 // 将音频数据写入Opus解码流 - opusManager?.writeAudioStream(audioData) + decodeOpusManager?.writeAudioStream(audioData) } catch (e: InterruptedException) { Log.d(TAG, "音频队列处理线程被中断") diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift index 33d92f331..1ac452c00 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift @@ -26,6 +26,8 @@ class BleConst { /** 通话写入音频特征UUID - 文档中定义为0000ABC1-0001-1111-2222-123456789ABC */ static let CALL_WRITE_AUDIO_CHAR_UUID = CBUUID(string: "0000abc1-0001-1111-2222-123456789abc") + /** 通话接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ + static let CALL_RECEIVE_AUDIO_CHAR_UUID = CBUUID(string: "0000abc2-0001-1111-2222-123456789abc") /** 客户端特征配置描述符UUID */ static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb") diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index 46dd97445..d757e18ca 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -41,11 +41,12 @@ public class BleService: NSObject { private var centralManager: CBCentralManager? private var peripheralDevice: CBPeripheral? private var writeCharacteristic: CBCharacteristic? - private var callAudioCharacteristic: CBCharacteristic? + private var callWriteCharacteristic: CBCharacteristic? private var notifyCharacteristic: CBCharacteristic? private var audioCharacteristic: CBCharacteristic? - + private var callAudioCharacteristic: CBCharacteristic? + // 添加录制文件管理器 private var recordingFile: RecordingFile? @@ -281,22 +282,11 @@ private var cmdReplyType: UInt8 = 0 // 处理传入的UUID参数 for uuidStr in uuids { - do { - let cbuuid: CBUUID - if uuidStr.count == 4 { - // 短UUID,直接使用 - cbuuid = CBUUID(string: uuidStr) - } else if uuidStr.count == 36 { - // 完整UUID - cbuuid = CBUUID(string: uuidStr) - } else { - os_log("⚠️ 无效的UUID格式: %{public}@", log: logger, type: .default, uuidStr) - continue - } + if let cbuuid = parseCBUUID(from: uuidStr) { serviceUUIDs.append(cbuuid) os_log("✅ 添加扫描过滤器UUID: %{public}@", log: logger, type: .info, uuidStr) - } catch { - os_log("❌ 构造UUID失败: %{public}@, 错误: %{public}@", log: logger, type: .error, uuidStr, error.localizedDescription) + } else { + os_log("⚠️ 无效的UUID格式或字符: %{public}@", log: logger, type: .error, uuidStr) } } @@ -318,6 +308,40 @@ private var cmdReplyType: UInt8 = 0 } return true } + + /// 验证并解析 UUID 字符串为 CBUUID + /// - Parameter uuidStr: UUID 字符串(支持 4 位短 UUID、36 位完整 UUID;扩展支持 32 位不带短横线 UUID) + /// - Returns: 解析成功返回 CBUUID,否则返回 nil + private func parseCBUUID(from uuidStr: String) -> CBUUID? { + let trimmed = uuidStr.trimmingCharacters(in: .whitespacesAndNewlines) + + // 4 位短 UUID:仅允许十六进制字符,通过正则校验 + if trimmed.count == 4 { + if trimmed.range(of: "^[0-9a-fA-F]{4}$", options: .regularExpression) != nil { + return CBUUID(string: trimmed.lowercased()) + } else { + return nil + } + } + + // 36 位完整 UUID:使用系统 UUID 进行格式校验 + if trimmed.count == 36, let nsuuid = UUID(uuidString: trimmed) { + return CBUUID(nsuuid: nsuuid) + } + + // 32 位完整 UUID(不带短横线):按 8-4-4-4-12 格式插入短横线后校验 + if trimmed.count == 32, + trimmed.range(of: "^[0-9a-fA-F]{32}$", options: .regularExpression) != nil { + let lower = trimmed.lowercased() + let formatted = "\(lower.prefix(8))-\(lower.dropFirst(8).prefix(4))-\(lower.dropFirst(12).prefix(4))-\(lower.dropFirst(16).prefix(4))-\(lower.dropFirst(20))" + if let nsuuid = UUID(uuidString: formatted) { + return CBUUID(nsuuid: nsuuid) + } + } + + // 其他长度不支持 + return nil + } /// 停止扫描 public func stopScan() { @@ -418,10 +442,11 @@ private var cmdReplyType: UInt8 = 0 // 清空特征引用,强制重新发现 writeCharacteristic = nil - callAudioCharacteristic = nil + callWriteCharacteristic = nil notifyCharacteristic = nil audioCharacteristic = nil - + callAudioCharacteristic = nil + os_log("🔍 恢复会话时重新发现服务和特征", log: logger, type: .info) peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID, BleConst.CALL_AUDIO_SERVICE_UUID]) @@ -866,22 +891,27 @@ private var cmdReplyType: UInt8 = 0 } /// 处理接收到的响应数据 + /// + /// - 功能: + /// - 解析设备响应帧,校验头、长度与 CRC + /// - 与 Android 保持一致的失败判定:当长度字段与实际长度不匹配时,上报 rawData = [length, data[data.count-2]] + /// - 在 CMD_CONTROL_CODEC 分支对齐安卓对错误响应的处理逻辑 private func handleResponseData(_ data: Data) { - guard let characteristic = writeCharacteristic, + guard let characteristic = writeCharacteristic, connectionState == BleConst.STATE_CONNECTED else { os_log(" 设备未连接", log: logger, type: .error) - return + return } guard data.count >= 4 else { os_log("响应数据格式错误:数据长度过短", log: logger, type: .error) return } - + // 提取帧信息 let header = data[0] let cmd = data[1] let length = data[2] - + // 检查帧头,确保是设备响应 guard header == BleConst.DEVICE_RESPONSE_HEADER else { // 如果是设备主动上报 @@ -890,35 +920,59 @@ private var cmdReplyType: UInt8 = 0 } return } - + // 记录响应详情 var hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ") os_log("收到设备响应: %@", log: logger, type: .debug, hexString) - - // 检查数据长度 + + // 检查数据长度(与 Android 统一失败判定保持一致) if data.count != Int(length) + 4 { // 帧头 + 命令 + 长度 + 数据 + CRC os_log("设备响应数据长度不匹配: 期望=%d, 实际=%d", log: logger, type: .error, Int(length) + 4, data.count) + + // 与 Android 保持一致:构造 rawData 为 [length, 倒数第二位(payload 最后一位)] + var raw: [UInt8] = [] + raw.append(length) + if data.count >= 2 { + raw.append(data[data.count - 2]) + } + + let resultMap: [String: Any] = [ + "success": true, // 与 Android 对齐(Dart 层按 rawData 判断) + "command": Int(cmd), + "rawData": Data(raw) + ] + + // 推进命令队列(与后续正常路径一致) + isReply = true + cmdReplyType = cmd + if !commandQueue.isEmpty { + let nextCmd = commandQueue.removeFirst() + peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) + } + + // 上报到 Dart 层 + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) return } - + // 计算CRC校验 - 不包括最后一个字节(CRC) let payload = data.prefix(data.count - 1) let calculatedCrc = calculateCRC8(data: payload) let receivedCrc = data[data.count - 1] - + if calculatedCrc != receivedCrc { os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc) return } - + // 提取响应数据 let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data() - isReply = true; - cmdReplyType = cmd; - if !commandQueue.isEmpty { - let nextCmd = commandQueue.removeFirst() - peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) -} + isReply = true + cmdReplyType = cmd + if !commandQueue.isEmpty { + let nextCmd = commandQueue.removeFirst() + peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) + } // 根据命令类型处理设备响应 switch cmd { @@ -928,103 +982,98 @@ private var cmdReplyType: UInt8 = 0 "command": "versionInfo", "rawData": responseData ] - + if !responseData.isEmpty { - // 如果无法解析为字符串,以点分隔的数字表示版本 let versionNums = responseData .map { $0 } - .filter { $0 != 0xFF } // 过滤掉无效字节 + .filter { $0 != 0xFF } .map { String($0) } .joined(separator: ".") resultMap["version"] = versionNums os_log("接收到目标版本号(数字格式): %@", log: logger, type: .info, versionNums) } - + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) - + case BleConst.CMD_GET_PRODUCT_ID: var resultMap: [String: Any] = [ "success": true, "command": "productId", "rawData": responseData ] - + if !responseData.isEmpty { let productId = responseData.map { String($0 & 0xFF) }.joined(separator: ".") resultMap["productId"] = productId os_log("接收到产品ID: %d", log: logger, type: .info, productId) } - + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) - + case BleConst.CMD_GET_COLOR_ID: var resultMap: [String: Any] = [ "success": true, "command": "colorInfo", "rawData": responseData ] - + if !responseData.isEmpty { let colorId = Int(responseData[0]) resultMap["colorId"] = colorId os_log("接收到耳机颜色ID: %d", log: logger, type: .info, colorId) } - + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) - + case BleConst.CMD_GET_BATTERY_INFO: var resultMap: [String: Any] = [ "success": true, "command": "batteryInfo", "rawData": responseData ] - + if responseData.count >= 3 { - // 处理电池信息 - 左耳、右耳、充电仓 let leftEarBattery = Int(responseData[0] & 0x7F) let leftEarCharging = (responseData[0] & 0x80) != 0 - + let rightEarBattery = Int(responseData[1] & 0x7F) let rightEarCharging = (responseData[1] & 0x80) != 0 - + let caseBattery = Int(responseData[2] & 0x7F) let caseCharging = (responseData[2] & 0x80) != 0 - - // 添加到结果Map + resultMap["leftEarBattery"] = leftEarBattery resultMap["leftEarCharging"] = leftEarCharging resultMap["rightEarBattery"] = rightEarBattery resultMap["rightEarCharging"] = rightEarCharging resultMap["caseBattery"] = caseBattery resultMap["caseCharging"] = caseCharging - resultMap["batteryLevel"] = leftEarBattery // 整体电量用左耳表示 - - os_log("接收到电量信息: 左耳=%d%% (%@), 右耳=%d%% (%@), 充电仓=%d%% (%@)", + resultMap["batteryLevel"] = leftEarBattery + + os_log("接收到电量信息: 左耳=%d%% (%@), 右耳=%d%% (%@), 充电仓=%d%% (%@)", log: logger, type: .info, - leftEarBattery, leftEarCharging ? "充电中" : "未充电", + leftEarBattery, leftEarCharging ? "充电中" : "未充电", rightEarBattery, rightEarCharging ? "充电中" : "未充电", caseBattery, caseCharging ? "充电中" : "未充电") } else if !responseData.isEmpty { - // 兼容单字节电量 let batteryLevel = Int(responseData[0]) resultMap["batteryLevel"] = batteryLevel os_log("接收到电量信息(简化): %d%%", log: logger, type: .info, batteryLevel) } - + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) - + case BleConst.CMD_CONTROL_CODEC: var resultMap: [String: Any] = [ "success": true, "command": Int(cmd), "rawData": responseData ] - - if responseData.count >= 2 { + + if !responseData.isEmpty && responseData.count >= 2 { let codecStatus = Int(responseData[0]) let channelMode = Int(responseData[1]) - - // 解码状态描述 + let statusDesc: String switch codecStatus { case Int(BleConst.CODEC_CONTROL_CLOSE): @@ -1040,8 +1089,7 @@ private var cmdReplyType: UInt8 = 0 default: statusDesc = "未知状态(\(codecStatus))" } - - // 声道模式描述 + let channelDesc: String switch channelMode { case Int(BleConst.AUDIO_CHANNEL_LEFT): @@ -1053,19 +1101,29 @@ private var cmdReplyType: UInt8 = 0 default: channelDesc = "未知声道(\(channelMode))" } - + resultMap["codecStatus"] = codecStatus resultMap["channelMode"] = channelMode resultMap["statusDesc"] = statusDesc resultMap["channelDesc"] = channelDesc - + os_log("编解码控制响应: %@, %@", log: logger, type: .info, statusDesc, channelDesc) + } else if !responseData.isEmpty && responseData.count == 1 { + // 与 Android 保持一致:单字节且为 0 判定为错误 + let codecStatus = Int(responseData[0]) + if codecStatus == 0x00 { + os_log("收到设备响应: 失败, codecStatus: %d", log: logger, type: .debug, codecStatus) + resultMap = [ + "success": true, // 与 Android 对齐(Dart 层按 rawData 判断) + "command": "Error", + "rawData": responseData + ] + } } - + notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) - + default: - // 对于未知命令,返回原始数据 let resultMap: [String: Any] = [ "success": true, "command": Int(cmd), @@ -1151,11 +1209,12 @@ private var cmdReplyType: UInt8 = 0 case BleConst.CMD_WAKE_UP: os_log("📢 收到唤醒指令", log: logger, type: .info) - os_log("🔍 当前状态检查: 连接状态=%d, writeCharacteristic=%@, notifyCharacteristic=%@, audioCharacteristic=%@", + os_log("🔍 当前状态检查: 连接状态=%d, writeCharacteristic=%@, notifyCharacteristic=%@, callAudioCharacteristic=%@, audioCharacteristic=%@", log: logger, type: .debug, connectionState, writeCharacteristic != nil ? "✅" : "❌", notifyCharacteristic != nil ? "✅" : "❌", + callAudioCharacteristic != nil ? "✅" : "❌", audioCharacteristic != nil ? "✅" : "❌") @@ -1250,7 +1309,7 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { os_log("🎵 接收到编码音频数据: %d bytes", log: logger, type: .info, data.count) // 修复条件绑定语句 - guard let characteristic = callAudioCharacteristic, + guard let characteristic = callWriteCharacteristic, let peripheral = peripheralDevice, connectionState == BleConst.STATE_CONNECTED else { os_log("通话音频特征未设置或设备未连接", log: logger, type: .error) @@ -1358,7 +1417,7 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { * @param data 要发送的音频数据 */ private func sendAudioChunk(_ data: Data) { - guard let characteristic = callAudioCharacteristic, + guard let characteristic = callWriteCharacteristic, let peripheral = peripheralDevice, connectionState == BleConst.STATE_CONNECTED else { os_log("通话音频特征未设置或设备未连接,无法发送音频数据", log: logger, type: .error) @@ -1764,7 +1823,7 @@ extension BleService: CBPeripheralDelegate { }else if service.uuid == BleConst.CALL_AUDIO_SERVICE_UUID { os_log("🔍 发现通话音频服务,开始发现通话音频特征...", log: logger, type: .debug) // 通话音频服务,发现通话音频特征 - peripheral.discoverCharacteristics([BleConst.CALL_WRITE_AUDIO_CHAR_UUID], for: service) + peripheral.discoverCharacteristics([BleConst.CALL_WRITE_AUDIO_CHAR_UUID, BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID], for: service) } } } @@ -1796,15 +1855,21 @@ extension BleService: CBPeripheralDelegate { os_log("🎵 音频特征已设置,开始发现描述符...", log: logger, type: .info) // 先发现描述符,再配置通知 peripheral.discoverDescriptors(for: characteristic) - }else if characteristic.uuid == BleConst.CALL_WRITE_AUDIO_CHAR_UUID { + }else if characteristic.uuid == BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID { callAudioCharacteristic = characteristic os_log("🎵 通话音频特征已设置,开始发现描述符...", log: logger, type: .info) // 先发现描述符,再配置通知 + peripheral.discoverDescriptors(for: characteristic) + } + else if characteristic.uuid == BleConst.CALL_WRITE_AUDIO_CHAR_UUID { + callWriteCharacteristic = characteristic + os_log("🎵 通话音频特征已设置,开始发现描述符...", log: logger, type: .info) + // 先发现描述符,再配置通知 } } // 检查所有必要特征是否已准备就绪 - let isReady = writeCharacteristic != nil && notifyCharacteristic != nil && audioCharacteristic != nil + let isReady = writeCharacteristic != nil && notifyCharacteristic != nil && audioCharacteristic != nil && callAudioCharacteristic != nil if isReady { os_log("🚀 所有BLE特征已准备就绪,可以发送命令", log: logger, type: .info) } else { @@ -1812,6 +1877,7 @@ extension BleService: CBPeripheralDelegate { log: logger, type: .debug, writeCharacteristic != nil ? "✅" : "❌", notifyCharacteristic != nil ? "✅" : "❌", + callAudioCharacteristic != nil ? "✅" : "❌", audioCharacteristic != nil ? "✅" : "❌") } } @@ -1829,6 +1895,9 @@ extension BleService: CBPeripheralDelegate { } else if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { // 处理音频数据 processAudioData(data) + }else if characteristic.uuid == BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID { + // 处理音频数据 + processAudioData(data) } } @@ -1850,7 +1919,7 @@ extension BleService: CBPeripheralDelegate { characteristic.uuid.uuidString, characteristic.descriptors?.count ?? 0) // 对通知特征和音频特征配置通知 - if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID || characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { + if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID || characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID || characteristic.uuid == BleConst.CALL_RECEIVE_AUDIO_CHAR_UUID { setupNotification(for: peripheral, characteristic: characteristic) } } diff --git a/local_plugins/native_plugin/.gitignore b/local_plugins/native_plugin/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/native_plugin/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/native_plugin/.metadata b/local_plugins/native_plugin/.metadata new file mode 100644 index 000000000..a5700b901 --- /dev/null +++ b/local_plugins/native_plugin/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: android + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: ios + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/native_plugin/CHANGELOG.md b/local_plugins/native_plugin/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/native_plugin/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/native_plugin/LICENSE b/local_plugins/native_plugin/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/native_plugin/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/native_plugin/README.md b/local_plugins/native_plugin/README.md new file mode 100644 index 000000000..bc8261f01 --- /dev/null +++ b/local_plugins/native_plugin/README.md @@ -0,0 +1,15 @@ +# native_plugin + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/native_plugin/analysis_options.yaml b/local_plugins/native_plugin/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/native_plugin/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/native_plugin/android/.gitignore b/local_plugins/native_plugin/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/native_plugin/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/native_plugin/android/build.gradle b/local_plugins/native_plugin/android/build.gradle new file mode 100644 index 000000000..948c5ef6f --- /dev/null +++ b/local_plugins/native_plugin/android/build.gradle @@ -0,0 +1,52 @@ +group = "com.example.native_plugin" +version = "1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" + +android { + namespace = "com.example.native_plugin" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/local_plugins/native_plugin/android/settings.gradle b/local_plugins/native_plugin/android/settings.gradle new file mode 100644 index 000000000..9f756a98c --- /dev/null +++ b/local_plugins/native_plugin/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'native_plugin' diff --git a/local_plugins/native_plugin/android/src/main/AndroidManifest.xml b/local_plugins/native_plugin/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5153b5dc6 --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java new file mode 100644 index 000000000..265796e9d --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java @@ -0,0 +1,38 @@ +package com.example.native_plugin; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +/** NativePlugin */ +public class NativePlugin implements FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private MethodChannel channel; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin"); + channel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + if (call.method.equals("getPlatformVersion")) { + result.success("Android " + android.os.Build.VERSION.RELEASE); + } else { + result.notImplemented(); + } + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + } +} diff --git a/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java b/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java new file mode 100644 index 000000000..8034030e9 --- /dev/null +++ b/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java @@ -0,0 +1,29 @@ +package com.example.native_plugin; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import org.junit.Test; + +/** + * This demonstrates a simple unit test of the Java portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +public class NativePluginTest { + @Test + public void onMethodCall_getPlatformVersion_returnsExpectedValue() { + NativePlugin plugin = new NativePlugin(); + + final MethodCall call = new MethodCall("getPlatformVersion", null); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + plugin.onMethodCall(call, mockResult); + + verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE); + } +} diff --git a/local_plugins/native_plugin/ios/.gitignore b/local_plugins/native_plugin/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/local_plugins/native_plugin/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/local_plugins/native_plugin/ios/Assets/.gitkeep b/local_plugins/native_plugin/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.h b/local_plugins/native_plugin/ios/Classes/FLNativeView.h new file mode 100644 index 000000000..4c49e00f4 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/FLNativeView.h @@ -0,0 +1,15 @@ +#import + +@interface FLNativeViewFactory : NSObject +- (instancetype)initWithMessenger:(NSObject *)messenger; +@end + +@interface FLNativeView : NSObject + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject *)messenger; + +- (UIView *)view; +@end diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.m b/local_plugins/native_plugin/ios/Classes/FLNativeView.m new file mode 100644 index 000000000..848785a6b --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/FLNativeView.m @@ -0,0 +1,83 @@ +#import "FLNativeView.h" +#import "PlayerView.h" + +@implementation FLNativeViewFactory { + NSObject *_messenger; +} + +- (instancetype)initWithMessenger: + (NSObject *)messenger { + self = [super init]; + if (self) { + _messenger = messenger; + } + return self; +} + +- (NSObject *)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + return [[FLNativeView alloc] initWithFrame:frame + viewIdentifier:viewId + arguments:args + binaryMessenger:_messenger]; +} + +/// Implementing this method is only necessary when the `arguments` in +/// `createWithFrame` is not `nil`. +- (NSObject *)createArgsCodec { + return [FlutterStandardMessageCodec sharedInstance]; +} + +@end + +@implementation FLNativeView { + PlayerView *_view; + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject *)messenger { + if (self = [super init]) { + _view = [[PlayerView alloc] init]; + _methodChannel = [FlutterMethodChannel + methodChannelWithName: + [NSString + stringWithFormat:@"native_plugin/native_view_%lld", viewId] + binaryMessenger:messenger]; + __weak typeof(self) weakSelf = self; + [_methodChannel setMethodCallHandler:^(FlutterMethodCall *_Nonnull call, + FlutterResult _Nonnull result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf onMethodCall:call result:result]; + } + }]; + } + return self; +} + +- (UIView *)view { + return _view; +} + +- (void)onMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([@"getInternalView" isEqualToString:call.method]) { + result(@((uint64_t)(_view))); + } else if ([@"updateContent" isEqualToString:call.method]) { + NSDictionary *data = call.arguments; + NSString *lang = data[@"lang"]; + NSString *cn = data[@"cn"]; + NSString *en = data[@"en"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [_view updateWithLang:lang cn:cn en:en]; + }); + result(nil); + } else { + result(@(0)); + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.h b/local_plugins/native_plugin/ios/Classes/NativePlugin.h new file mode 100644 index 000000000..2d0b3293e --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/NativePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface NativePlugin : NSObject +@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.m b/local_plugins/native_plugin/ios/Classes/NativePlugin.m new file mode 100644 index 000000000..bee63b337 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/NativePlugin.m @@ -0,0 +1,114 @@ +#import "NativePlugin.h" +#import "FLNativeView.h" +#import "PlayerView.h" + +#import + +@interface NativePlugin () + +@property(nonatomic, strong) NSMutableArray *_Nonnull pipContentViewArray; + +@end + +@implementation NativePlugin + ++ (void)updateAudioSession { + AVAudioSession *audioSession = [AVAudioSession sharedInstance]; + NSError *categoryError = nil; + if (@available(iOS 14.5, *)) { + [audioSession + setCategory:AVAudioSessionCategoryPlayback + mode:AVAudioSessionModeMoviePlayback + options: + AVAudioSessionCategoryOptionOverrideMutedMicrophoneInterruption + error:&categoryError]; + } else { + // Fallback on earlier versions + } + if (categoryError) { + NSLog(@"Set audio session category error: %@", + categoryError.localizedDescription); + } + NSError *activeError = nil; + [audioSession setActive:YES error:&activeError]; + if (activeError) { + NSLog(@"Set audio session active error: %@", + activeError.localizedDescription); + } +} + ++ (void)registerWithRegistrar:(NSObject *)registrar { + // must call this, otherwise pip will not possible + // https://github.com/jazzychad/PiPBugDemo + [NativePlugin updateAudioSession]; + + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"native_plugin" + binaryMessenger:[registrar messenger]]; + NativePlugin *instance = [[NativePlugin alloc] init]; + + FLNativeViewFactory *factory = + [[FLNativeViewFactory alloc] initWithMessenger:registrar.messenger]; + [registrar registerViewFactory:factory withId:@"native_view"]; + + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _pipContentViewArray = [NSMutableArray array]; + } + return self; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call + result:(FlutterResult)result { + if ([@"getPlatformVersion" isEqualToString:call.method]) { + result([@"iOS " + stringByAppendingString:[[UIDevice currentDevice] systemVersion]]); + } else if ([@"createPipContentView" isEqualToString:call.method]) { + PlayerView *playerView = [[PlayerView alloc] init]; + + // should control the play state by event of pip, optimize it later + [playerView play]; + + [self.pipContentViewArray addObject:playerView]; + + result(@((uint64_t)playerView)); + } else if ([@"disposePipContentView" isEqualToString:call.method]) { + uint64_t viewId = [call.arguments unsignedLongLongValue]; + for (PlayerView *playerView in self.pipContentViewArray) { + if ((uint64_t)playerView == viewId) { + [playerView pause]; + [self.pipContentViewArray removeObject:playerView]; + break; + } + } + } else if ([@"updatePipContentView" isEqualToString:call.method]) { + NSDictionary *data = call.arguments; + NSString *lang = data[@"lang"]; + NSString *cn = data[@"cn"]; + NSString *en = data[@"en"]; + NSNumber *viewIdNum = data[@"viewId"]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (viewIdNum != nil) { + uint64_t targetId = viewIdNum.unsignedLongLongValue; + for (PlayerView *playerView in self.pipContentViewArray) { + if ((uint64_t)playerView == targetId) { + [playerView updateWithLang:lang cn:cn en:en]; + break; + } + } + } else { + PlayerView *playerView = [self.pipContentViewArray lastObject]; + [playerView updateWithLang:lang cn:cn en:en]; + } + }); + result(nil); + } else { + result(FlutterMethodNotImplemented); + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.h b/local_plugins/native_plugin/ios/Classes/PlayerView.h new file mode 100644 index 000000000..50044ae41 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlayerView : UIView + +- (void)play; + +- (void)pause; + +- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en; + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.m b/local_plugins/native_plugin/ios/Classes/PlayerView.m new file mode 100644 index 000000000..1272aef4c --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.m @@ -0,0 +1,100 @@ +#import "PlayerView.h" + +@interface PlayerView () + +@property(nonatomic, strong) UILabel *titleLabel; +@property(nonatomic, strong) UILabel *langLabel; +@property(nonatomic, strong) UILabel *cnLabel; +@property(nonatomic, strong) UILabel *enLabel; +@property(nonatomic, strong) UIStackView *stackView; + +@end + +@implementation PlayerView + +- (instancetype)init { + if (self = [super init]) { + self.backgroundColor = [UIColor whiteColor]; + + UILabel *title = [[UILabel alloc] init]; + title.text = @"实时翻译"; + title.font = [UIFont boldSystemFontOfSize:22]; + title.textColor = [UIColor blackColor]; + title.textAlignment = NSTextAlignmentCenter; + title.numberOfLines = 1; + self.titleLabel = title; + + UILabel *lang = [[UILabel alloc] init]; + lang.text = @"中文(简体) -> 英语"; + lang.font = [UIFont systemFontOfSize:14]; + lang.textColor = [UIColor darkGrayColor]; + lang.textAlignment = NSTextAlignmentLeft; + lang.numberOfLines = 1; + self.langLabel = lang; + + UILabel *cn = [[UILabel alloc] init]; + cn.font = [UIFont systemFontOfSize:18]; + cn.textColor = [UIColor blackColor]; + cn.textAlignment = NSTextAlignmentLeft; + cn.numberOfLines = 0; + self.cnLabel = cn; + + UILabel *en = [[UILabel alloc] init]; + en.font = [UIFont systemFontOfSize:18]; + en.textColor = [UIColor colorWithRed:0.24 green:0.54 blue:0.90 alpha:1.0]; + en.textAlignment = NSTextAlignmentLeft; + en.numberOfLines = 0; + self.enLabel = en; + + NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; + para.lineSpacing = 4; + + self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:@"正在识别..." attributes:@{NSParagraphStyleAttributeName: para}]; + + self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:@"翻译中..." attributes:@{NSParagraphStyleAttributeName: para}]; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[self.titleLabel, self.langLabel, self.cnLabel, self.enLabel]]; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 12; + stack.alignment = UIStackViewAlignmentFill; + stack.distribution = UIStackViewDistributionFill; + stack.translatesAutoresizingMaskIntoConstraints = NO; + self.stackView = stack; + + self.layoutMargins = UIEdgeInsetsMake(16, 16, 16, 16); + [self addSubview:self.stackView]; + + UILayoutGuide *guide = self.layoutMarginsGuide; + [NSLayoutConstraint activateConstraints:@[ + [self.stackView.topAnchor constraintEqualToAnchor:guide.topAnchor], + [self.stackView.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor], + [self.stackView.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor], + [self.stackView.bottomAnchor constraintLessThanOrEqualToAnchor:guide.bottomAnchor] + ]]; + } + return self; +} + +- (void)play { + +} + +- (void)pause { + +} + +- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en { + if (lang != nil) { + self.langLabel.text = lang; + } + NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; + para.lineSpacing = 4; + if (cn != nil) { + self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:cn attributes:@{NSParagraphStyleAttributeName: para}]; + } + if (en != nil) { + self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:en attributes:@{NSParagraphStyleAttributeName: para}]; + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy b/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/local_plugins/native_plugin/ios/native_plugin.podspec b/local_plugins/native_plugin/ios/native_plugin.podspec new file mode 100644 index 000000000..c84e9ffba --- /dev/null +++ b/local_plugins/native_plugin/ios/native_plugin.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint native_plugin.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'native_plugin' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'native_plugin_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/local_plugins/native_plugin/lib/native_plugin.dart b/local_plugins/native_plugin/lib/native_plugin.dart new file mode 100644 index 000000000..c54721f1f --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin.dart @@ -0,0 +1,19 @@ +import 'native_plugin_platform_interface.dart'; + +class NativePlugin { + Future getPlatformVersion() { + return NativePluginPlatform.instance.getPlatformVersion(); + } + + Future createPipContentView() { + return NativePluginPlatform.instance.createPipContentView(); + } + + Future updatePipContentView(Map data) { + return NativePluginPlatform.instance.updatePipContentView(data); + } + + Future disposePipContentView(int viewId) { + return NativePluginPlatform.instance.disposePipContentView(viewId); + } +} diff --git a/local_plugins/native_plugin/lib/native_plugin_method_channel.dart b/local_plugins/native_plugin/lib/native_plugin_method_channel.dart new file mode 100644 index 000000000..ec8e18ac5 --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin_method_channel.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'native_plugin_platform_interface.dart'; + +/// An implementation of [NativePluginPlatform] that uses method channels. +class MethodChannelNativePlugin extends NativePluginPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('native_plugin'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod( + 'getPlatformVersion', + ); + return version; + } + + @override + Future createPipContentView() async { + final viewId = await methodChannel.invokeMethod( + 'createPipContentView', + ); + return viewId ?? 0; + } + + @override + Future updatePipContentView(Map data) async { + await methodChannel.invokeMethod('updatePipContentView', data); + } + + @override + Future disposePipContentView(int viewId) async { + await methodChannel.invokeMethod('disposePipContentView', viewId); + } +} diff --git a/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart b/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart new file mode 100644 index 000000000..c26495c2f --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart @@ -0,0 +1,47 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'native_plugin_method_channel.dart'; + +abstract class NativePluginPlatform extends PlatformInterface { + /// Constructs a NativePluginPlatform. + NativePluginPlatform() : super(token: _token); + + static final Object _token = Object(); + + static NativePluginPlatform _instance = MethodChannelNativePlugin(); + + /// The default instance of [NativePluginPlatform] to use. + /// + /// Defaults to [MethodChannelNativePlugin]. + static NativePluginPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [NativePluginPlatform] when + /// they register themselves. + static set instance(NativePluginPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } + + Future createPipContentView() { + throw UnimplementedError( + 'createPipContentView() has not been implemented.', + ); + } + + Future updatePipContentView(Map data) { + throw UnimplementedError( + 'updatePipContentView() has not been implemented.', + ); + } + + Future disposePipContentView(int viewId) { + throw UnimplementedError( + 'disposePipContentView() has not been implemented.', + ); + } +} diff --git a/local_plugins/native_plugin/pubspec.yaml b/local_plugins/native_plugin/pubspec.yaml new file mode 100644 index 000000000..a284bfce8 --- /dev/null +++ b/local_plugins/native_plugin/pubspec.yaml @@ -0,0 +1,72 @@ +name: native_plugin +description: "A new Flutter plugin project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.example.native_plugin + pluginClass: NativePlugin + ios: + pluginClass: NativePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/pubspec.yaml b/pubspec.yaml index 670d31152..a6e8889d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.20+62 +version: 1.0.19+61 environment: sdk: ">=3.3.0 <4.0.0" @@ -80,6 +80,7 @@ dependencies: dio: ^5.8.0+1 sqflite: ^2.4.2 path: ^1.9.1 + pip: ^0.0.3 connectivity_plus: ^6.1.5 # 网络连接检测依赖 speech: path: local_plugins/speech @@ -115,8 +116,11 @@ dependencies: path: local_plugins/user_portrait qq_music: path: local_plugins/qq_music + native_plugin: + path: local_plugins/native_plugin test: path: local_plugins/test + in_app_purchase: ^3.2.3 # 应用内购买插件 dev_dependencies: flutter_test: @@ -142,8 +146,8 @@ flutter: - assets/fonts/NotoSansSC-Regular.ttf - assets/lottie/ - assets/js/ - - assets/images/headphone_new4.png - - assets/images/headphone_new3.png + - assets/images/defaultPicture.png + - assets/images/defaultPicture.jpg - assets/images/qqmusic.png - assets/images/music.png - assets/weather/