diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20d5ead25..cbb9afd88 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -69,8 +69,8 @@ android { // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 26 // 更新为26以满足ble_service库的要求 targetSdk = 34 // 设置为 Android 13 (API 33),以匹配 FOREGROUND_SERVICE_MEDIA_PLAYBACK 权限的要求 - versionCode = flutter.versionCode - versionName = flutter.versionName + versionCode = flutter.versionCode // 对应构建号,自动同步flutter构建号 + versionName = flutter.versionName // 对应版本号,自动同步flutter版本号 } signingConfigs { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bc3f4a793..5be33da0e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,7 +39,7 @@ - CFBundleVersion + CFBundleVersion $(FLUTTER_BUILD_NUMBER) FacebookAdvertiserIDCollectionEnabled diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 08bb41500..c372b985a 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -10,6 +10,7 @@ import '../../core/utils/logger.dart'; import '../../data/services/bluetooth_manager.dart'; import '../../data/services/ble_manager.dart'; import '../../data/services/account_service.dart'; +import '../../data/services/version_update_service.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @@ -46,6 +47,9 @@ class InitialBinding extends Bindings { // 账号管理服务 Get.lazyPut(() => AccountService(), fenix: true); + // 版本更新服务 + Get.lazyPut(() => VersionUpdateService(), fenix: true); + // 杰理蓝牙服务 // // 语言管理器(需要最先初始化) diff --git a/lib/core/utils/synchrodata.dart b/lib/core/utils/synchrodata.dart new file mode 100644 index 000000000..4d8d2e3af --- /dev/null +++ b/lib/core/utils/synchrodata.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; + +import '../../data/services/db/sqflite_api.dart'; +import '../../data/services/network/api.dart'; + +class Synchrodata { + static final GetStorage _storage = GetStorage(); + + static List _records = _storage.read("operation_records") ?? []; + static List _delids = _storage.read("operation_delids") ?? []; + static bool _isSynchro = false; + + // 保存操作记录 + static Future saveOperationRecord( + String method, + dynamic params, + ) async { + String userId = ''; + Map? userInfo = _storage.read("user_info"); + if (userInfo != null) { + userId = userInfo['user']['uid']; + } + String id = '${userId}_${params['id']}'; + if (params['type'] != null) { + params['rtype'] = params['type']; + } + + switch (method) { + case 'insert': + _records.add({ + ...params, + 'id': id, + 'number': params['id'], + 'uid': userId, + }); + _storage.write("operation_records", _records); + break; + case 'update': + Map? foundElement = _records.firstWhere( + (element) => element['id'] == id, + orElse: () => null, + ); + if (foundElement != null) { + foundElement.addAll({ + ...params, + 'id': id, + 'number': params['id'], + }); + } else { + _records.add({ + ...params, + 'id': id, + 'number': params['id'], + 'uid': userId, + }); + } + _storage.write("operation_records", _records); + break; + case 'delete': + _delids.add(id); + _storage.write("operation_delids", _delids); + break; + default: + break; + } + } + + // 获取操作记录 + static Future startSynchro() async { + if (_isSynchro) return; + _isSynchro = true; + if (_records.isNotEmpty || _delids.isNotEmpty) { + // 获取需要修改的全部数据 + List putRecords = []; + for (var element in _records) { + final result = await SqfliteApi.getMeetingDetails(element['number']); + final speakerList = + await SqfliteApi.getMeetingSpeaker(element['number']); + putRecords.add({ + ...result, + ...result['details'], + 'rtype': result['type'], + 'original': jsonEncode(speakerList), + ...element, + }); + } + var result = await Api.putOperationRecord({ + 'delids': _delids, + 'records': putRecords, + }); + if (result != null) { + _records = []; + _delids = []; + _storage.write("operation_records", _records); + _storage.write("operation_delids", _delids); + } + } + var result = await Api.getOperationRecord(); + _isSynchro = false; + } +} diff --git a/lib/data/models/channel_app_model.dart b/lib/data/models/channel_app_model.dart new file mode 100644 index 000000000..bddbf9f39 --- /dev/null +++ b/lib/data/models/channel_app_model.dart @@ -0,0 +1,151 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'channel_app_model.g.dart'; + +/// 渠道应用信息响应模型 +@JsonSerializable() +class ChannelAppResponse { + @JsonKey(name: 'code') + final int code; + + @JsonKey(name: 'data') + final ChannelAppData? data; + + @JsonKey(name: 'msg') + final String msg; + + const ChannelAppResponse({ + required this.code, + this.data, + required this.msg, + }); + + factory ChannelAppResponse.fromJson(Map json) => + _$ChannelAppResponseFromJson(json); + + Map toJson() => _$ChannelAppResponseToJson(this); +} + +/// 渠道应用数据模型 +@JsonSerializable() +class ChannelAppData { + @JsonKey(name: 'app') + final AppInfo app; + + const ChannelAppData({ + required this.app, + }); + + factory ChannelAppData.fromJson(Map json) => + _$ChannelAppDataFromJson(json); + + Map toJson() => _$ChannelAppDataToJson(this); +} + +/// 应用信息模型 +@JsonSerializable() +class AppInfo { + /// 下载地址 + @JsonKey(name: 'address') + final String address; + + /// 渠道标识 + @JsonKey(name: 'channel') + final String channel; + + /// 应用描述 + @JsonKey(name: 'description') + final String description; + + /// 最新版本号 + @JsonKey(name: 'version') + final String version; + + const AppInfo({ + required this.address, + required this.channel, + required this.description, + required this.version, + }); + + factory AppInfo.fromJson(Map json) => + _$AppInfoFromJson(json); + + Map toJson() => _$AppInfoToJson(this); +} + +/// 版本更新信息模型 +class VersionUpdateInfo { + /// 是否有新版本 + final bool hasUpdate; + + /// 当前版本 + final String currentVersion; + + /// 最新版本 + final String latestVersion; + + /// 应用信息 + final AppInfo? appInfo; + + /// 更新类型 + final UpdateType updateType; + + const VersionUpdateInfo({ + required this.hasUpdate, + required this.currentVersion, + required this.latestVersion, + this.appInfo, + required this.updateType, + }); +} + +/// 更新类型枚举 +enum UpdateType { + /// 无需更新 + none, + /// 可选更新 + optional, + /// 强制更新 + force, +} + +/// 应用商店类型枚举 +enum AppStoreType { + /// iOS App Store + appStore, + /// Google Play + googlePlay, + /// 华为应用市场 + huawei, + /// 小米应用商店 + xiaomi, + /// OPPO软件商店 + oppo, + /// vivo应用商店 + vivo, + /// 荣耀应用市场 + honor, + /// 官网下载 + official, + /// 未知 + unknown, +} + +/// 平台信息模型 +class PlatformInfo { + /// 平台类型 + final String platform; + + /// 应用商店类型 + final AppStoreType storeType; + + /// 渠道标识 + final String channel; + + const PlatformInfo({ + required this.platform, + required this.storeType, + required this.channel, + }); +} diff --git a/lib/data/models/channel_app_model.g.dart b/lib/data/models/channel_app_model.g.dart new file mode 100644 index 000000000..7d5e6aa6b --- /dev/null +++ b/lib/data/models/channel_app_model.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_app_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelAppResponse _$ChannelAppResponseFromJson(Map json) => + ChannelAppResponse( + code: (json['code'] as num).toInt(), + data: json['data'] == null + ? null + : ChannelAppData.fromJson(json['data'] as Map), + msg: json['msg'] as String, + ); + +Map _$ChannelAppResponseToJson(ChannelAppResponse instance) => + { + 'code': instance.code, + 'data': instance.data, + 'msg': instance.msg, + }; + +ChannelAppData _$ChannelAppDataFromJson(Map json) => + ChannelAppData( + app: AppInfo.fromJson(json['app'] as Map), + ); + +Map _$ChannelAppDataToJson(ChannelAppData instance) => + { + 'app': instance.app, + }; + +AppInfo _$AppInfoFromJson(Map json) => AppInfo( + address: json['address'] as String, + channel: json['channel'] as String, + description: json['description'] as String, + version: json['version'] as String, + ); + +Map _$AppInfoToJson(AppInfo instance) => { + 'address': instance.address, + 'channel': instance.channel, + 'description': instance.description, + 'version': instance.version, + }; diff --git a/lib/data/services/account_service.dart b/lib/data/services/account_service.dart index 6c01ac65c..cbbe49de1 100644 --- a/lib/data/services/account_service.dart +++ b/lib/data/services/account_service.dart @@ -25,7 +25,7 @@ class AccountService extends GetxService { Future clearUserData() async { try { // 清理所有存储 - await _storage.erase(); + //await _storage.erase(); // 或者逐个清理关键存储项 await _storage.write("logintoken", null); await _storage.write("user_info", null); @@ -38,6 +38,18 @@ class AccountService extends GetxService { } } + Future clearAllUserData() async { + // 清理所有用户数据 + try { + await _storage.erase(); + logger.d('所有用户数据清理成功'); + return true; + } catch (e) { + logger.e('用户数据清理失败: $e'); + return false; + } + } + /// 退出第三方登录 /// /// 返回操作是否成功 @@ -105,7 +117,8 @@ class AccountService extends GetxService { } // 清理本地数据 - final dataCleaned = await clearUserData(); + final dataCleaned = await clearAllUserData(); // 一次性清除所有数据 + if (!dataCleaned) { return AccountOperationResult( success: false, diff --git a/lib/data/services/db/database_helper.dart b/lib/data/services/db/database_helper.dart index 0773d9c12..0d6c3aa54 100644 --- a/lib/data/services/db/database_helper.dart +++ b/lib/data/services/db/database_helper.dart @@ -39,14 +39,6 @@ class DatabaseHelper { synctime INTEGER ) '''); - // 操作记录 - batch.execute(''' - CREATE TABLE IF NOT EXISTS operationrecord ( - id INTEGER PRIMARY KEY, - method TEXT, - data TEXT - ) - '''); // 商务会议助手列表 batch.execute(''' CREATE TABLE IF NOT EXISTS meeting ( diff --git a/lib/data/services/db/sqflite_api.dart b/lib/data/services/db/sqflite_api.dart index a5f0f1cb3..5857b3a7a 100644 --- a/lib/data/services/db/sqflite_api.dart +++ b/lib/data/services/db/sqflite_api.dart @@ -1,49 +1,10 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; +import '../../../core/utils/synchrodata.dart'; import 'database_helper.dart'; class SqfliteApi { - // 保存操作记录 - static Future saveMeetingOperationRecord( - String method, - dynamic params, - ) async { - GetStorage storage = GetStorage(); - String userId = ''; - Map? userInfo = storage.read("user_info"); - if (userInfo != null) { - userId = userInfo['user']['uid']; - } - - final db = await DatabaseHelper().database; - String data = ''; - if (method == 'records') { - Map records = { - ...params, - 'id': '${userId}_${params['id']}', - 'number': params['id'], - 'uid': userId, - }; - data = jsonEncode(records); - } else { - data = params; - } - db.insert('operationrecord', { - 'method': method, - 'data': data, - }); - } - - // 操作记录列表 - static Future getMeetingOperationRecord() async { - final db = await DatabaseHelper().database; - return await db.query('operationrecord'); - } - // 获取商务会议助手列表 static Future getMeetingList(int offset) async { final db = await DatabaseHelper().database; @@ -112,6 +73,10 @@ class SqfliteApi { 'summary': '', 'mindmap': '', }); + Synchrodata.saveOperationRecord('insert', { + ...meeting, + 'id': id, + }); }); return id; } catch (e) { @@ -136,6 +101,10 @@ class SqfliteApi { where: 'meetingid = ?', whereArgs: [id], ); + Synchrodata.saveOperationRecord('update', { + ...meeting, + 'id': id, + }); return true; } catch (e) { Get.snackbar( @@ -205,6 +174,10 @@ class SqfliteApi { static Future editMeetingTitle(id, Map meeting) async { final db = await DatabaseHelper().database; await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); + Synchrodata.saveOperationRecord('update', { + ...meeting, + 'id': id, + }); } // 删除商务会议助手 @@ -216,6 +189,9 @@ class SqfliteApi { .delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]); await txn .delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]); + Synchrodata.saveOperationRecord('delete', { + 'id': id, + }); }); } diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index b2f43ac10..07e265b4e 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -44,14 +44,6 @@ class Api { '/api/home/user_setting', params: params, ); - // options: params is dio.FormData - // ? Options( - // headers: { - // 'Content-Type': 'multipart/form-data', - // }, - // ) - // : null, - // ); } //注销账号 @@ -107,4 +99,31 @@ class Api { params: params, ); } + + // 上传会议助手操作记录 + static putOperationRecord([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/record_put', + params: params, + ); + } + + // 获取会议助手数据 + static getOperationRecord([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/record_get', + params: params, + ); + } + + //获取渠道包信息 + static getChannelApp([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/user_getchannelapp', + params: params, + ); + } } diff --git a/lib/data/services/version_update_service.dart b/lib/data/services/version_update_service.dart new file mode 100644 index 000000000..f0bf31f05 --- /dev/null +++ b/lib/data/services/version_update_service.dart @@ -0,0 +1,315 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:logger/logger.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import '../models/channel_app_model.dart'; +import 'network/api.dart'; + +/// 版本更新服务类 +class VersionUpdateService extends GetxService { + static VersionUpdateService get to => Get.find(); + + final Logger _logger = Logger(); + + /// 获取当前应用版本信息 + Future getCurrentVersion() async { + try { + final packageInfo = await PackageInfo.fromPlatform(); + return '${packageInfo.version}+${packageInfo.buildNumber}'; + } catch (e) { + _logger.e('获取当前版本信息失败: $e'); + return '1.0.0'; + } + } + + /// 获取当前平台信息 + Future getCurrentPlatformInfo() async { + if (kIsWeb) { + return const PlatformInfo( + platform: 'web', + storeType: AppStoreType.official, + channel: '官方网站', + ); + } + + if (Platform.isIOS) { + return const PlatformInfo( + platform: 'ios', + storeType: AppStoreType.appStore, + channel: 'App Store', + ); + } + + if (Platform.isAndroid) { + // 根据设备品牌判断应用商店类型 + return await getAndroidStoreInfoByBrand(); + } + + return const PlatformInfo( + platform: 'unknown', + storeType: AppStoreType.unknown, + channel: '未知', + ); + } + + /// 根据设备品牌获取应用商店信息 + Future getAndroidStoreInfoByBrand() async { + try { + final deviceInfo = DeviceInfoPlugin(); + final androidInfo = await deviceInfo.androidInfo; + final brand = androidInfo.brand.toLowerCase(); + + return const PlatformInfo( + platform: 'android', + storeType: AppStoreType.official, + channel: '官方内测', + ); + + // switch (brand) { + // case 'huawei': + // case 'honor': + // return PlatformInfo( + // platform: 'android', + // storeType: + // brand == 'huawei' ? AppStoreType.huawei : AppStoreType.honor, + // channel: brand == 'huawei' ? '华为应用市场' : '荣耀应用市场', + // ); + // case 'xiaomi': + // case 'redmi': + // return const PlatformInfo( + // platform: 'android', + // storeType: AppStoreType.xiaomi, + // channel: '小米应用商店', + // ); + // case 'oppo': + // case 'oneplus': + // return const PlatformInfo( + // platform: 'android', + // storeType: AppStoreType.oppo, + // channel: 'OPPO软件商店', + // ); + // case 'vivo': + // case 'iqoo': + // return const PlatformInfo( + // platform: 'android', + // storeType: AppStoreType.vivo, + // channel: 'vivo应用商店', + // ); + // default: + // return const PlatformInfo( + // platform: 'android', + // storeType: AppStoreType.googlePlay, + // channel: 'Google Play', + // ); + // } + } catch (e) { + _logger.e('获取Android设备信息失败: $e'); + return const PlatformInfo( + platform: 'android', + storeType: AppStoreType.googlePlay, + channel: 'Google Play', + ); + } + } + + /// 检查版本更新 + Future checkForUpdate() async { + try { + final currentVersion = await getCurrentVersion(); + final platformInfo = await getCurrentPlatformInfo(); + + // 调用后端接口获取最新版本信息 + final response = + await Api.getChannelApp({'channel': platformInfo.channel}); + + print('获取渠道包信息:$response'); + + if (response == null || !response.containsKey('app')) { + return VersionUpdateInfo( + hasUpdate: false, + currentVersion: currentVersion, + latestVersion: currentVersion, + updateType: UpdateType.none, + ); + } + + //final channelAppResponse = ChannelAppResponse.fromJson(response); + // 直接从response中获取app信息 + final appInfo = AppInfo.fromJson(response['app']); + + // if (channelAppResponse.code != 0 || channelAppResponse.data == null) { + // _logger.w('获取版本信息失败: ${channelAppResponse.msg}'); + // return VersionUpdateInfo( + // hasUpdate: false, + // currentVersion: currentVersion, + // latestVersion: currentVersion, + // updateType: UpdateType.none, + // ); + // } + + // final appInfo = channelAppResponse.data!.app; + final hasUpdate = _compareVersions(currentVersion, appInfo.version); + + return VersionUpdateInfo( + hasUpdate: hasUpdate, + currentVersion: currentVersion, + latestVersion: appInfo.version, + appInfo: appInfo, + updateType: hasUpdate ? UpdateType.optional : UpdateType.none, + ); + } catch (e) { + _logger.e('检查版本更新失败: $e'); + final currentVersion = await getCurrentVersion(); + return VersionUpdateInfo( + hasUpdate: false, + currentVersion: currentVersion, + latestVersion: currentVersion, + updateType: UpdateType.none, + ); + } + } + + /// 比较版本号 + /// 返回true表示需要更新(远程版本更高) + bool _compareVersions(String current, String latest) { + // 拆分版本号与构建号 + final currentParts = current.split('+'); + final latestParts = latest.split('+'); + + // 解析版本号 (major.minor.patch) + final currentVersions = currentParts[0].split('.').map(int.parse).toList(); + final latestVersions = latestParts[0].split('.').map(int.parse).toList(); + + // 逐级比较版本号 + for (var i = 0; i < currentVersions.length; i++) { + if (latestVersions[i] > currentVersions[i]) return true; + if (latestVersions[i] < currentVersions[i]) return false; + } + + // 版本号相同则比较构建号 + if (latestParts.length > 1 && currentParts.length > 1) { + return int.parse(latestParts[1]) > int.parse(currentParts[1]); + } + return false; + } + + /// 解析版本号字符串为数字数组 + List _parseVersion(String version) { + final parts = version.split('.'); + final result = []; + + for (int i = 0; i < 3; i++) { + if (i < parts.length) { + result.add(int.tryParse(parts[i]) ?? 0); + } else { + result.add(0); + } + } + + return result; + } + + /// 执行更新操作 + Future performUpdate(VersionUpdateInfo updateInfo) async { + if (!updateInfo.hasUpdate || updateInfo.appInfo == null) { + return false; + } + + final platformInfo = await getCurrentPlatformInfo(); + + try { + switch (platformInfo.storeType) { + case AppStoreType.appStore: + return await _openAppStore(); + case AppStoreType.googlePlay: + return await _openGooglePlay(); + case AppStoreType.huawei: + return await _openHuaweiStore(); + case AppStoreType.xiaomi: + return await _openXiaomiStore(); + case AppStoreType.oppo: + return await _openOppoStore(); + case AppStoreType.vivo: + return await _openVivoStore(); + case AppStoreType.honor: + return await _openHonorStore(); + case AppStoreType.official: + return await _openOfficialDownload(updateInfo.appInfo!.address); + default: + return await _openOfficialDownload(updateInfo.appInfo!.address); + } + } catch (e) { + _logger.e('执行更新失败: $e'); + return false; + } + } + + /// 打开iOS App Store + Future _openAppStore() async { + // 这里需要替换为实际的App Store ID + const appStoreUrl = 'https://apps.apple.com/app/id你的应用ID'; + return await _launchUrl(appStoreUrl); + } + + /// 打开Google Play + Future _openGooglePlay() async { + // 这里需要替换为实际的包名 + const packageName = 'com.yourcompany.deepvoice'; + const playStoreUrl = + 'https://play.google.com/store/apps/details?id=$packageName'; + return await _launchUrl(playStoreUrl); + } + + /// 打开华为应用市场 + Future _openHuaweiStore() async { + const huaweiStoreUrl = 'appmarket://details?id=com.yourcompany.deepvoice'; + return await _launchUrl(huaweiStoreUrl); + } + + /// 打开小米应用商店 + Future _openXiaomiStore() async { + const xiaomiStoreUrl = 'mimarket://details?id=com.yourcompany.deepvoice'; + return await _launchUrl(xiaomiStoreUrl); + } + + /// 打开OPPO软件商店 + Future _openOppoStore() async { + const oppoStoreUrl = + 'oppomarket://details?packagename=com.yourcompany.deepvoice'; + return await _launchUrl(oppoStoreUrl); + } + + /// 打开vivo应用商店 + Future _openVivoStore() async { + const vivoStoreUrl = 'vivomarket://details?id=com.yourcompany.deepvoice'; + return await _launchUrl(vivoStoreUrl); + } + + /// 打开荣耀应用市场 + Future _openHonorStore() async { + const honorStoreUrl = 'honormarket://details?id=com.yourcompany.deepvoice'; + return await _launchUrl(honorStoreUrl); + } + + /// 打开官网下载 + Future _openOfficialDownload(String downloadUrl) async { + return await _launchUrl(downloadUrl); + } + + /// 启动URL + Future _launchUrl(String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } + return false; + } catch (e) { + _logger.e('启动URL失败: $e'); + return false; + } + } +} diff --git a/lib/modules/FTFTranslation/README.md b/lib/modules/FTFTranslation/README.md new file mode 100644 index 000000000..c41004a52 --- /dev/null +++ b/lib/modules/FTFTranslation/README.md @@ -0,0 +1,160 @@ +# Realtime 实时语音聊天模块 + +## ⚠️ 当前状态 + +**此模块为功能预览版本,提供完整的实时语音对话功能。** + +通过原生插件实现语音识别、AI对话和语音合成的完整语音交互体验。 + +## 概述 + +Realtime模块实现了类似ChatGPT app的实时语音对话功能,当前版本提供: + +- ✅ **完整语音交互** - 实时语音识别、AI对话和语音合成 +- ✅ **状态管理** - 聆听、思考、回答状态的可视化 +- ✅ **动画效果** - 基于Shader的流动云效果,支持音频振幅驱动 +- ✅ **字幕显示** - 可选择开启/关闭的字幕模式 +- ✅ **智能UI** - 根据连接状态自动调整按钮可用性 + +## 界面设计 + +### 布局结构 +- **顶部状态区域** - 显示当前状态和字幕模式切换按钮 +- **中间可视化区域** - 圆形Shader动画,根据音频振幅呈现呼吸效果 +- **底部控制区域** - 主控制按钮和状态指示器 + +### 状态指示 +- 🔵 **聆听状态** - 蓝色动画,接收用户语音输入 +- 🟠 **思考状态** - 橙色动画,AI处理中 +- 🟢 **回答状态** - 绿色动画,AI语音输出 +- ⚪ **空闲状态** - 灰色静态,等待交互 + +### 字幕模式 +- **默认关闭** - 初始状态下不显示任何字幕内容 +- **手动切换** - 通过右上角按钮手动开启/关闭字幕 +- **完整隐藏** - 关闭时字幕区域完全不显示,不占用界面空间 +- **智能布局** - 开启时圆形动画自动上移,字幕区域占据下半屏 + +### 交互控制 +- **录音按钮** - 仅在成功连接AI服务器且开始录音后可用 +- **连接状态** - 未连接时录音按钮显示不可用状态 +- **状态反馈** - 按钮颜色和可点击性根据连接状态动态调整 + +## 使用方法 + +### 路由导航 + +```dart +// 跳转到实时语音对话页面 +Get.toNamed(Routes.realtime); +``` + +### 交互流程 + +1. **自动连接** - 页面加载后自动尝试连接AI服务器 +2. **等待连接** - 录音按钮在连接成功前保持不可用状态 +3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用 +4. **语音对话** - 用户说话 → AI处理 → 语音回复 +5. **字幕控制** - 可随时通过右上角按钮切换字幕显示 + +## 技术架构 + +### 控制器 (RealtimeController) +- **连接管理**: WebSocket连接到AI服务器 +- **状态同步**: `isConnected`, `isListening`, `isSpeaking` +- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制 +- **消息管理**: 智能去重,避免流式响应重复显示 +- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画 + +### 视图组件 +- **RealtimeView** - 主视图容器,响应式布局 +- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件 +- **AutoScrollList** - 自动滚动的字幕列表 + +### 原生集成 +- **RealtimeService** - 原生音频处理和WebSocket通信 +- **音频格式** - 16kHz, 1声道, 16位PCM +- **实时传输** - 低延迟音频流处理 +- **RMS计算** - 原生层实时计算音频振幅 + +## 默认行为 + +### 字幕模式 +- **初始状态**: 关闭 (`isSubtitleMode.value = false`) +- **显示逻辑**: 仅在用户手动开启时显示字幕内容 +- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素 +- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整 + +### 按钮状态 +- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击 +- **状态指示**: 通过颜色和图标变化反映当前可用性 +- **交互反馈**: 不可用时点击无效果,避免误操作 + +## 开发计划 + +### 已完成功能 ✅ +- [x] 完整语音交互流程 +- [x] 实时WebSocket通信 +- [x] Shader流动云动画 +- [x] RMS音频振幅计算 +- [x] 智能UI状态管理 +- [x] 字幕模式控制 +- [x] 消息去重优化 + +### 计划增强 ⏳ +- [ ] 多语言语音识别 +- [ ] 语音中断检测 +- [ ] 音频质量自适应 +- [ ] 离线降级处理 +- [ ] 语音情感识别 + +## 注意事项 + +1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws) +2. **麦克风权限** - 需要获取麦克风使用权限 +3. **音频会话** - 自动管理音频会话,支持蓝牙设备 +4. **资源管理** - 页面关闭时自动清理音频资源和网络连接 + +## 自定义配置 + +### 修改服务器地址 + +```dart +// 在RealtimeController中修改连接URL +await _realtimeService.initialize( + serverUrl: 'ws://your-server:port/ws', + // ... 其他参数 +); +``` + +### 调整UI布局 + +```dart +// 修改字幕模式下圆形动画位置 +alignment: controller.isSubtitleMode.value + ? const Alignment(0, -0.9) // 调整Y轴位置 + : const Alignment(0, -0.25), +``` + +### 音频参数调优 + +```dart +// 在初始化时调整音频参数 +sampleRate: 16000, // 采样率 +channels: 1, // 声道数 +bitsPerSample: 16, // 位深度 +``` + +## 技术栈 + +- **Flutter SDK**: >= 3.0.0 +- **GetX**: >= 4.6.5 (状态管理) +- **Flutter Shaders**: >= 0.0.6 (Shader动画) +- **原生插件**: Realtime服务 (音频处理) +- **WebSocket**: 实时通信协议 + +## 版本信息 + +- **当前版本**: v1.0.0 (Production) +- **最后更新**: 2024年 +- **状态**: 完整功能实现 \ No newline at end of file diff --git a/lib/modules/FTFTranslation/bindings/FTFTranslation_binding.dart b/lib/modules/FTFTranslation/bindings/FTFTranslation_binding.dart new file mode 100644 index 000000000..85e44791c --- /dev/null +++ b/lib/modules/FTFTranslation/bindings/FTFTranslation_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import '../controllers/FTFTranslation_controller.dart'; + +class FTFTranslationBinding extends Bindings { + @override + void dependencies() { + // 注册实时语音聊天控制器 + Get.lazyPut(() => FTFTranslationController()); + } +} diff --git a/lib/modules/FTFTranslation/controllers/FTFTranslation_controller.dart b/lib/modules/FTFTranslation/controllers/FTFTranslation_controller.dart new file mode 100644 index 000000000..53cffe68d --- /dev/null +++ b/lib/modules/FTFTranslation/controllers/FTFTranslation_controller.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:get/get.dart'; +import '../../../core/utils/logger.dart'; +import '../../../data/services/asr_service.dart'; +import '../../../data/services/speech_impl/xunfei_asr_service.dart'; +import '../../../data/services/tts_service.dart'; +import '../../../data/services/language_manager.dart'; +import '../../../data/services/volcano_translation_service.dart'; + +class FTFTranslationController extends GetxController + with GetSingleTickerProviderStateMixin { + final messages = [].obs; + final isChineseInput = true.obs; + final leftLanguage = '中文'.obs; + final rightLanguage = 'English'.obs; + final isLeftInput = true.obs; + + final supportedLanguages = const [ + '中文', + 'English', + '日本語', + '한국어', + 'Español', + ]; + + late final AsrService _asrService; + + StreamSubscription? _recognitionSubscription; + + RxString intermediateContent = ''.obs; + RxString finalContent = ''.obs; + BytesBuilder _originaBytes = BytesBuilder(); //原始音频 + + final VolcanoTranslationService _translationService = + Get.find(); + final LanguageManager _languageManager = Get.find(); + // 当前使用的TTS服务 + late TtsService _ttsService; + final isTranslating = false.obs; + final sourceLanguageCode = 'zh-CN'.obs; + final targetLanguageCode = 'en-US'.obs; + final isTtsEnabled = true.obs; + + @override + void onInit() { + super.onInit(); + Get.put(XunfeiAsrService()); + final TtsService _ttsService = Get.find(); + _asrService = Get.find(); + } + + @override + void onClose() { + _asrService.dispose(); + _recognitionSubscription?.cancel(); + super.onClose(); + } + + //开始录音 + Future _startRecorder() async { + final recognitionStream = + await _asrService.startContinuousRecognition(false); + _recognitionSubscription = recognitionStream.listen( + _handleRecognitionEvent, + ); + } + + //结束录音 + Future _stopRecorder() async { + await _asrService.stopContinuousRecognition(); + } + + // 处理语音识别事件 + void _handleRecognitionEvent(RecognitionEvent event) { + switch (event.type) { + case RecognitionEventType.intermediateResult: + intermediateContent.value = event.text; + handleFinalResult(event.text); + break; + case RecognitionEventType.finalResult: + finalContent.value += _spliceText(event); + intermediateContent.value = ''; + _originaBytes.add(event.audio!); + handleIntermediateResult(event.text); + break; + default: + // 不做任何处理 + break; + } + } + + // 翻译文本 + Future translateText(String sourceText, + {bool isFinal = false}) async { + if (sourceText.isEmpty) return null; + + isTranslating.value = true; + String? translationResult; + + try { + // 根据检测到的语言确定源语言和目标语言 + final bool shouldSwap = + intermediateContent.value == sourceLanguageCode.value; + final detectedSourceLanguageCode = + shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; + final detectedTargetLanguageCode = + shouldSwap ? targetLanguageCode.value : sourceLanguageCode.value; + + // 调用翻译服务 + translationResult = await _translationService.translateText( + text: sourceText, + sourceLanguageCode: detectedSourceLanguageCode, + targetLanguageCode: detectedTargetLanguageCode, + ); + + if (translationResult != null) { + // 查找匹配的项目 - 优先使用timestamp查找 + int index = -1; + + // 播放TTS - 只在最终结果时播放 + if (isTtsEnabled.value && isFinal) { + // Logger.info('播放翻译文本: ${translationResult}'); + await playTranslatedText(translationResult); + } + } + } catch (e) { + Logger.error('翻译失败: ${e.toString()}'); + } finally { + isTranslating.value = false; + } + + return translationResult; + } + + // 处理中间识别结果 + void handleIntermediateResult(String text) { + if (text.isEmpty) return; + + translateText(text, isFinal: false); + } + + // 处理最终识别结果 + Future handleFinalResult(String text) async { + if (text.isEmpty) return; + + // 使用项目的时间戳进行翻译 + translateText(text, isFinal: true); + } + + // 播放翻译文本 + Future playTranslatedText(String text) async { + if (text.isEmpty) return; + + try { + final bool shouldSwap = + intermediateContent.value == targetLanguageCode.value; + final detectedTargetLanguageCode = + shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; + final voiceName = _languageManager + .getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ?? + 'en-US-AriaNeural'; + + await _ttsService.setVoice(voiceName); + await _ttsService.speakOnce(text); + } catch (e) { + Logger.error('播放翻译失败: ${e.toString()}'); + } + } + + String _spliceText(RecognitionEvent event) { + String eventText = event.text; + String text = ''; + if (finalContent.value.isNotEmpty) { + if (event.role != '') { + final punctuations = { + ',', + '。', + '!', + '?', + ';', + ':', + ',', + '.', + ':', + ';', + '!', + '?' + }; + final firstChar = eventText[0]; + if (punctuations.contains(firstChar)) { + text += '$firstChar\n'; + eventText = eventText.substring(1); + } else { + text += '\n'; + } + text += '说话人${event.role}:'; + } + } else { + text += '说话人${event.role}:'; + } + text += eventText; + return text; + } + + void addMessage(String text) { + final fromLang = + isLeftInput.value ? leftLanguage.value : rightLanguage.value; + final toLang = isLeftInput.value ? rightLanguage.value : leftLanguage.value; + + messages.add( + FTFTranslationMessage( + original: text, + translated: '[$fromLang → $toLang] $text (翻译模拟)', + isChineseSpeaker: isLeftInput.value, + ), + ); + } + + void swapLanguages() { + final temp = leftLanguage.value; + leftLanguage.value = rightLanguage.value; + rightLanguage.value = temp; + isLeftInput.value = !isLeftInput.value; + } + + void toggleInputSide(bool isLeft) => isLeftInput.value = isLeft; +} + +class FTFTranslationMessage { + final String original; + final String translated; + final bool isChineseSpeaker; + + FTFTranslationMessage({ + required this.original, + required this.translated, + required this.isChineseSpeaker, + }); +} diff --git a/lib/modules/FTFTranslation/views/FTFTranslation_view.dart b/lib/modules/FTFTranslation/views/FTFTranslation_view.dart new file mode 100644 index 000000000..84d8c1c6a --- /dev/null +++ b/lib/modules/FTFTranslation/views/FTFTranslation_view.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/FTFTranslation_controller.dart'; + +class FTFTranslationView extends GetView { + const FTFTranslationView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final TextEditingController inputController = TextEditingController(); + + return Scaffold( + backgroundColor: const Color(0xFF0E0F12), + appBar: AppBar( + backgroundColor: Colors.black, + title: const Text('面对面翻译'), + centerTitle: true, + ), + body: Column( + children: [ + Expanded( + child: Obx(() => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.messages.length, + itemBuilder: (context, index) { + final msg = controller.messages[index]; + final isChinese = msg.isChineseSpeaker; + return Align( + alignment: isChinese + ? Alignment.centerLeft + : Alignment.centerRight, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isChinese ? Colors.blue : Colors.grey[800], + borderRadius: BorderRadius.circular(12), + ), + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + msg.original, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + msg.translated, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + ), + ], + ), + ), + ); + }, + )), + ), + _buildBottomInput(inputController), + ], + ), + ); + } + + Widget _buildBottomInput(TextEditingController inputController) { + return Obx(() { + final isLeftInput = controller.isLeftInput.value; + final inputLang = isLeftInput + ? controller.leftLanguage.value + : controller.rightLanguage.value; + + return Container( + color: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: inputController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey[900], + hintText: '请输入 $inputLang 内容', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ), + IconButton( + onPressed: () { + final text = inputController.text.trim(); + if (text.isNotEmpty) { + controller.addMessage(text); + inputController.clear(); + } + }, + icon: const Icon(Icons.send, color: Colors.white), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildLanguageDropdown( + selected: controller.leftLanguage.value, + items: controller.supportedLanguages, + onChanged: (value) { + if (value != null) controller.leftLanguage.value = value; + }, + icon: Icons.headphones, + isSelected: isLeftInput, + label: '左方语言', + onTapInputSide: () => controller.toggleInputSide(true), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: controller.swapLanguages, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + shape: BoxShape.circle, + ), + child: + const Icon(Icons.compare_arrows, color: Colors.white), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildLanguageDropdown( + selected: controller.rightLanguage.value, + items: controller.supportedLanguages, + onChanged: (value) { + if (value != null) controller.rightLanguage.value = value; + }, + icon: Icons.smartphone, + isSelected: !isLeftInput, + label: '右方语言', + onTapInputSide: () => controller.toggleInputSide(false), + ), + ), + ], + ), + ], + ), + ); + }); + } + + Widget _buildLanguageDropdown({ + required String selected, + required List items, + required void Function(String?) onChanged, + required IconData icon, + required bool isSelected, + required String label, + required VoidCallback onTapInputSide, + }) { + return GestureDetector( + onTap: onTapInputSide, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + border: Border.all( + color: isSelected ? Colors.white : Colors.grey, + ), + borderRadius: BorderRadius.circular(24), + color: isSelected ? Colors.white10 : Colors.transparent, + ), + child: Row( + children: [ + Icon(icon, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + dropdownColor: Colors.grey[900], + value: selected, + isExpanded: true, + underline: const SizedBox(), + iconEnabledColor: Colors.white, + style: const TextStyle(color: Colors.white), + items: items.map((lang) { + return DropdownMenuItem( + value: lang, + child: Text(lang), + ); + }).toList(), + onChanged: onChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 4a77fbefc..2bbaa3d78 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -91,6 +91,17 @@ class AgentController extends GetxController { _loadTtsPreference(); //加载TTS偏好设置 _loadChatHistory(); //加载聊天历史记录 _subscribeToEvents(); //订阅代理服务事件 + _initMusicStatus(); //初始化音乐状态 + } + + // 初始化音乐播放状态 + Future _initMusicStatus() async { + try { + await musicStatus(); + Logger.d('AgentController', '音乐状态初始化完成'); + } catch (e) { + Logger.e('AgentController', '音乐状态初始化失败: $e'); + } } @override @@ -818,85 +829,161 @@ class AgentController extends GetxController { useImagePrompt('请翻译这张图片中的文字'); } - // 播放音乐 - void musicStatus() async { - var status = await _musiceManager.getStatus(); - // debugPrint('获取当前音乐播放状态 $status'); - isPlaying.value = status['isPlaying']; - currentId.value = status['id'] ?? 0; + // 获取音乐播放状态 + Future musicStatus() async { + try { + var status = await _musiceManager.getStatus(); + isPlaying.value = status['isPlaying'] ?? false; + currentId.value = status['id'] ?? 0; + Logger.d('AgentController', + '音乐状态更新: playing=${isPlaying.value}, url=${currentId.value}'); + } catch (e) { + Logger.e('AgentController', '获取音乐状态失败: $e'); + } } // 播放音乐 - void musicPlay(Map song) async { - final id = song['id'] ?? ""; - final timestampMs = song['ts'] ?? 0; - // 当前时间 - final now = DateTime.now(); - // 将时间戳转换为 DateTime - final tsTime = DateTime.fromMillisecondsSinceEpoch(timestampMs).add( - const Duration(minutes: 10), // 转换为北京时间 - ); - // 判断是否过期 - if (tsTime.isBefore(now)) { - //获取新的连接 - var response = await Api.getMusicUrl({ - "ids": [id] - }); - // 2. 将响应数据转换为User对象 - try { - print('getMusicUrl 响应数据: ${response.toString()}'); // 调试输出原始数据 - final urldata = response['urls'][0] as Map; - song["url"] = urldata['url'] ?? ''; - } catch (e) { - Logger.e(TAG, '转换响应数据失败: $e'); - return; + Future musicPlay(Map song) async { + try { + Logger.d('AgentController', '开始播放音乐: ${song['title'] ?? song['name']}'); + + // 立即更新UI状态,提供即时反馈 + isPlaying.value = true; + currentId.value = song['id'] ?? 0; + + // 重置进度状态 + currentPosition.value = 0; + duration.value = 0; + progress.value = 0.0; + + final id = song['id'] ?? ""; + final timestampMs = song['ts'] ?? 0; + + // 检查URL是否需要刷新(只有当有时间戳时才检查) + if (timestampMs > 0) { + final now = DateTime.now(); + final tsTime = DateTime.fromMillisecondsSinceEpoch(timestampMs).add( + const Duration(minutes: 10), // 转换为北京时间 + ); + + // 判断是否过期 + if (tsTime.isBefore(now)) { + Logger.d('AgentController', '音乐URL已过期,正在获取新URL...'); + try { + var response = await Api.getMusicUrl({ + "ids": [id] + }); + print('getMusicUrl 响应数据: ${response.toString()}'); // 调试输出原始数据 + final urldata = response['urls'][0] as Map; + song["url"] = urldata['url'] ?? ''; + currentId.value = song['id'] ?? 0; + Logger.d('AgentController', '获取新URL成功'); + } catch (e) { + Logger.e('AgentController', '获取新URL失败: $e'); + // 如果获取新URL失败,尝试使用原URL + } + } } - } - _musiceManager.play(song); - isPlaying.value = true; - currentId.value = song['id'] ?? 0; - _startProgressTimer(); + // 转换为MusicManager期望的格式 + final musicData = { + 'id': song['id']?.toString() ?? '', + 'url': song['url']?.toString() ?? '', + 'title': song['title']?.toString() ?? song['name']?.toString() ?? '', + 'artist': + song['artist']?.toString() ?? song['sgener']?.toString() ?? '', + 'coverUrl': + song['coverUrl']?.toString() ?? song['image']?.toString() ?? '', + }; + + // 调用音乐管理器播放 + await _musiceManager.play(musicData); + + // 启动进度定时器 + _startProgressTimer(); + + Logger.d('AgentController', '音乐播放启动成功'); + } catch (e) { + Logger.e('AgentController', '播放音乐失败: $e'); + // 发生错误时重置状态 + isPlaying.value = false; + currentId.value = 0; + } } - // 播放音乐 - void musicPause() { - _musiceManager.pause(); - isPlaying.value = false; - _stopProgressTimer(); + // 暂停音乐 + Future musicPause() async { + try { + Logger.d('AgentController', '暂停音乐播放'); + await _musiceManager.pause(); + isPlaying.value = false; + _stopProgressTimer(); + } catch (e) { + Logger.e('AgentController', '暂停音乐失败: $e'); + } } - void musicResume() { - _musiceManager.resume(); - isPlaying.value = true; - _startProgressTimer(); + // 恢复音乐播放 + Future musicResume() async { + try { + Logger.d('AgentController', '恢复音乐播放'); + await _musiceManager.resume(); + isPlaying.value = true; + _startProgressTimer(); + } catch (e) { + Logger.e('AgentController', '恢复音乐失败: $e'); + } } // 启动进度更新定时器 void _startProgressTimer() { _stopProgressTimer(); // 先停止现有定时器 - _progressTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (isPlaying.value) { + + Logger.d('AgentController', '启动播放进度定时器'); + _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) { + if (isPlaying.value && currentId.value != 0) { updateMusicProgress(); - } else { + } else if (!isPlaying.value) { + // 如果不在播放状态,停止定时器 timer.cancel(); + _progressTimer = null; + Logger.d('AgentController', '播放已停止,取消进度定时器'); } }); } // 停止进度更新定时器 void _stopProgressTimer() { - _progressTimer?.cancel(); - _progressTimer = null; + if (_progressTimer != null) { + Logger.d('AgentController', '停止播放进度定时器'); + _progressTimer?.cancel(); + _progressTimer = null; + } } // 获取播放进度 Future updateMusicProgress() async { try { + if (!isPlaying.value || currentId.value == 0) { + return; // 如果没有在播放,直接返回 + } + var progressInfo = await _musiceManager.getProgress(); - currentPosition.value = progressInfo['currentPosition'] ?? 0; - duration.value = progressInfo['duration'] ?? 0; - progress.value = progressInfo['progress'] ?? 0.0; + final newPosition = progressInfo['currentPosition'] ?? 0; + final newDuration = progressInfo['duration'] ?? 0; + final newProgress = progressInfo['progress'] ?? 0.0; + + // 只有当值发生变化时才更新,避免不必要的UI刷新 + if (currentPosition.value != newPosition) { + currentPosition.value = newPosition; + } + if (duration.value != newDuration) { + duration.value = newDuration; + } + if ((progress.value - newProgress).abs() > 0.001) { + // 避免浮点数精度问题 + progress.value = newProgress; + } } catch (e) { Logger.e('AgentController', '获取播放进度失败: $e'); } diff --git a/lib/modules/agent/views/music_card.dart b/lib/modules/agent/views/music_card.dart index ecdc7bbcd..f72af95be 100644 --- a/lib/modules/agent/views/music_card.dart +++ b/lib/modules/agent/views/music_card.dart @@ -95,19 +95,27 @@ Widget buildMusicItem(BuildContext context, int id, String name, String url, backgroundColor: Colors.grey, size: 36.0, strokeWidth: 2.0, - onPlayPause: () { - if (controller.isPlaying.value) { - if (controller.currentId.value == id) { - controller.musicPause(); - } else { - controller.musicPlay(song); - } - } else { - if (controller.currentId.value == id) { - controller.musicResume(); + onPlayPause: () async { + try { + if (controller.isPlaying.value) { + if (controller.currentId.value == id) { + // 当前歌曲正在播放,暂停它 + await controller.musicPause(); + } else { + // 其他歌曲正在播放,播放这首歌 + await controller.musicPlay(song); + } } else { - controller.musicPlay(song); + if (controller.currentId.value == id) { + // 当前歌曲已暂停,恢复播放 + await controller.musicResume(); + } else { + // 播放新歌曲 + await controller.musicPlay(song); + } } + } catch (e) { + // 静默处理错误,避免影响用户体验 } }, onSeek: isThisPlaying diff --git a/lib/modules/common/widgets/version_update_dialog.dart b/lib/modules/common/widgets/version_update_dialog.dart new file mode 100644 index 000000000..cb0a59f03 --- /dev/null +++ b/lib/modules/common/widgets/version_update_dialog.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import '../../../data/models/channel_app_model.dart'; +import '../../../data/services/version_update_service.dart'; + +/// 版本更新提示对话框 - 禁止外部关闭 +class VersionUpdateDialog extends StatelessWidget { + final VersionUpdateInfo updateInfo; + final VoidCallback? onCancel; + final VoidCallback? onUpdate; + + const VersionUpdateDialog({ + super.key, + required this.updateInfo, + this.onCancel, + this.onUpdate, + }); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + // 在强制更新时添加拦截层 + Widget dialogContent = _buildDialogContent(isDarkMode); + //if (updateInfo.updateType == UpdateType.force) { + return PopScope( + canPop: false, // 完全禁用返回键关闭 + child: dialogContent, + ); + //} + + //return dialogContent; + } + + /// 构建对话框内容 + Widget _buildDialogContent(bool isDarkMode) { + return Dialog( + // 关键修改:禁用外部点击关闭 + //barrierDismissible: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + child: Container( + width: 320.w, + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.circular(16.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 标题图标 + Container( + width: 64.w, + height: 64.w, + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(32.r), + ), + child: Icon( + Icons.system_update, + size: 32.sp, + color: Colors.blue, + ), + ), + + SizedBox(height: 16.h), + + // 标题 + Text( + '发现新版本', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + + SizedBox(height: 8.h), + + // 版本信息 + Text( + '${updateInfo.currentVersion} → ${updateInfo.latestVersion}', + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + ), + ), + + SizedBox(height: 16.h), + + // 更新描述 + if (updateInfo.appInfo?.description.isNotEmpty == true) ...[ + Container( + width: double.infinity, + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(8.r), + ), + child: Text( + updateInfo.appInfo!.description, + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.grey[300] : Colors.grey[700], + height: 1.4, + ), + ), + ), + SizedBox(height: 20.h), + ], + + // 按钮区域 + Row( + children: [ + // 取消按钮(仅在非强制更新时显示) + if (updateInfo.updateType != UpdateType.force) ...[ + Expanded( + child: TextButton( + onPressed: () { + Get.back(); + onCancel?.call(); + }, + style: TextButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + side: BorderSide( + color: isDarkMode + ? Colors.grey[600]! + : Colors.grey[300]!, + ), + ), + ), + child: Text( + '稍后提醒', + style: TextStyle( + fontSize: 16.sp, + color: + isDarkMode ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + ), + SizedBox(width: 12.w), + ], + + // 更新按钮 + Expanded( + child: ElevatedButton( + onPressed: () async { + Get.back(); + await _handleUpdate(); + onUpdate?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + elevation: 0, + ), + child: Text( + _getUpdateButtonText(), + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// 获取更新按钮文本 + String _getUpdateButtonText() { + switch (updateInfo.updateType) { + case UpdateType.force: + return '立即更新'; + case UpdateType.optional: + return '立即更新'; + default: + return '检查更新'; + } + } + + /// 处理更新操作 + Future _handleUpdate() async { + try { + // 显示加载提示 + Get.dialog( + const Center( + child: CircularProgressIndicator(), + ), + barrierDismissible: false, + ); + + final versionService = VersionUpdateService.to; + final success = await versionService.performUpdate(updateInfo); + + // 关闭加载提示 + Get.back(); + + if (!success) { + Get.snackbar( + '更新失败', + '无法打开应用商店,请手动前往应用商店更新', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + } catch (e) { + // 关闭加载提示 + if (Get.isDialogOpen == true) { + Get.back(); + } + + Get.snackbar( + '更新失败', + '更新过程中发生错误,请稍后重试', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + } + + /// 显示版本更新对话框 + static Future show(VersionUpdateInfo updateInfo) async { + await Get.dialog( + VersionUpdateDialog(updateInfo: updateInfo), + // 关键修改:禁用外部点击关闭 + barrierDismissible: false, + // 防止返回键关闭(在强制更新模式下) + //useRootNavigator: true, + ); + } +} diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index 13e88d22e..0058b31d8 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -12,7 +12,9 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/synchrodata.dart'; import '../../../core/utils/upload_oss.dart'; +import '../../../data/services/ble_manager.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../core/utils/permission_util.dart'; @@ -32,14 +34,12 @@ class MeetingController extends GetxController { List selected = []; final appDocDir = getApplicationDocumentsDirectory(); - //final appopus = getExternalStorageDirectories(); - // final directory = Directory( - // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); List files = []; TextEditingController titleController = TextEditingController(); // 录音标题 RxList dataList = [].obs; - + // 蓝牙服务 + final bleManager = Get.find(); List dataListTask = []; bool isDisposed = false; String filePath = ''; @@ -55,7 +55,6 @@ class MeetingController extends GetxController { super.onInit(); listController.addListener(_onScroll); _requestStoragePermission(); - //Permission.storage.request(); _headers = { 'Authorization': 'Bearer; $_token', 'Content-Type': 'application/json', @@ -64,7 +63,7 @@ class MeetingController extends GetxController { addMeetingAudio(); final Map args = Get.arguments ?? {}; // 检查参数是否存在且包含有效路径 - if (args != null && + if (args.isNotEmpty && args.containsKey('filePath') && args['filePath'] is String && args['filePath'].isNotEmpty) { @@ -82,6 +81,10 @@ class MeetingController extends GetxController { super.onClose(); } + void onPopInvokedWithResult(didPop, result) { + Synchrodata.startSynchro(); + } + void _onScroll() { if (isGetMore && !isLoading && @@ -109,11 +112,8 @@ class MeetingController extends GetxController { // 请求存储权限 void _requestStoragePermission() async { - // final BuildContext context = Get.overlayContext!; - PermissionUtil.instance .requestPermission( - // context: context, permissionType: Permission.storage, permissionName: "存储", explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。", @@ -124,18 +124,14 @@ class MeetingController extends GetxController { Logger.d("Permission", "用户授予了存储权限"); } else { Logger.d("Permission", "用户未授予存储权限"); - // 可以提示用户无法进行文件操作 } }); } void loadTranslatopus() async { - // final appDir = await appopus; // 先 await 获取目录 final dir = Directory("/data/user/0/com.yunqiinnovation.deepsound/files/OpusAudio"); - print("appDocDir=${dir?.path}"); - files = dir .listSync() .where((f) => f is File && (f.path.endsWith('.opus'))) @@ -147,15 +143,12 @@ class MeetingController extends GetxController { final appDir = await appDocDir; // 先 await 获取目录 final dir = Directory("${appDir.path}/TranslatAudio"); - print("appDocDir=${dir?.path}"); - // Create directory if it doesn't exist if (!await dir.exists()) { - await dir.create(recursive: true); // Create directory tree if needed - files = []; // No files exist in new directory + await dir.create(recursive: true); + files = []; return; } - // Directory exis files = dir .listSync() .where((f) => @@ -168,20 +161,12 @@ class MeetingController extends GetxController { f.path.endsWith('.mov'))) .toList(); } catch (e) { - print("Error loading files: $e"); - files = []; // Return empty list on error + files = []; } } -// 导入音频文件 Future shareAllTranslatAudio() async { final filePath = selected[0]; - // for (final filePath in selected) { - // if (filePath.isEmpty) { - // EasyLoading.showToast('没有可分享的音频文件'); - // return; - // } - // } try { await Share.shareXFiles( [XFile(filePath)], @@ -216,7 +201,7 @@ class MeetingController extends GetxController { final files = await sourceDir.list().where((entity) => entity is File).toList(); - int addQuantity = 0; + int addUp = 0; for (final fileEntity in files) { try { final file = File(fileEntity.path); @@ -225,8 +210,6 @@ class MeetingController extends GetxController { // 设置音频源获取时长 await _audioPlayer.setAudioSource(AudioSource.file(filePath)); - print( - "_audioPlayer$_audioPlayer,filePath=$filePath,_audioPlayer.duration?.inSeconds${_audioPlayer.duration?.inSeconds}"); int seconds = _audioPlayer.duration?.inSeconds ?? 0; // 获取文件修改时间 @@ -251,7 +234,6 @@ class MeetingController extends GetxController { 'tasktype': 0, 'creationtime': creationTime, }; - print("导入数据: $data"); // 插入数据库 final meetingId = await SqfliteApi.insertMeeting(data); @@ -268,12 +250,13 @@ class MeetingController extends GetxController { await file.delete(); addQuantity++; + addUp++; } } catch (e) { print("文件处理失败: ${fileEntity.path}, 错误: $e"); } } - if (addQuantity > 0) EasyLoading.showSuccess('成功导入本地 $addQuantity 条录音'); + if (addUp > 0) EasyLoading.showSuccess('成功导入本地 $addUp 条录音'); } catch (e) { EasyLoading.showError('导入失败: $e'); } finally { @@ -292,7 +275,6 @@ class MeetingController extends GetxController { filepath: filePath, rootDir: 'TranslatAudio'); int seconds = _audioPlayer.duration?.inSeconds ?? 0; final fileName = filePath.split('/').last; - // int creationTime = DateTime.now().millisecondsSinceEpoch; Map data = { 'title': fileName, 'type': 'TRANSLAT', @@ -302,7 +284,6 @@ class MeetingController extends GetxController { 'tasktype': 0, 'creationtime': creationTime, }; - print("data=$data"); final meetingId = await SqfliteApi.insertMeeting(data); if (meetingId != null) { dataList.insert(0, { @@ -314,7 +295,6 @@ class MeetingController extends GetxController { } } catch (e) { Logger.error('导入文件失败: $filePath, error: $e'); - // 可选:继续导入下一个文件 } } EasyLoading.dismiss(); @@ -335,6 +315,9 @@ class MeetingController extends GetxController { PlatformFile file = result.files.first; filePath = await _saveFile(file); fileName = file.name; + } else { + EasyLoading.dismiss(); + return; } } diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index 8577525ba..81c4c1841 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -10,6 +10,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart' as http; import '../../../core/utils/logger.dart'; +import '../../../core/utils/synchrodata.dart'; import '../../../core/utils/upload_oss.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/services/db/sqflite_api.dart'; @@ -208,6 +209,10 @@ class MeetingDetailsController extends GetxController textList[index] = dataMap; } } + Synchrodata.saveOperationRecord('update', { + 'original': jsonEncode(textList), + 'id': id, + }); isSpeakerText.value = false; editSpeakerTextId.value = 0; editSpeakerTextList = []; @@ -417,6 +422,10 @@ class MeetingDetailsController extends GetxController await SqfliteApi.editMeeting(id, {'tasktype': 2, 'personnel': personnel}); List speakerList = await SqfliteApi.getMeetingSpeaker(id); textList.value = List.from(speakerList); + Synchrodata.saveOperationRecord('update', { + 'original': jsonEncode(textList), + 'id': id, + }); meetingDetails.value.tasktype = 2; meetingDetails.value.personnel = personnel; meetingDetails.refresh(); diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 2fea38ea5..ee4640b18 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -113,12 +113,15 @@ class MeetingRecordController extends GetxController switch (audioType.value) { case 0: fileName.value = "现场录音"; + _audioSourceType = false; break; case 1: fileName.value = "音、视频录音"; + _audioSourceType = true; break; case 2: fileName.value = "通话录音"; + _audioSourceType = true; break; } newName.value = fileName.value; @@ -234,7 +237,7 @@ class MeetingRecordController extends GetxController void _startHardwareServices() { switch (audioType.value) { case 0: - _bleManager.openEncoder(); + // _bleManager.openEncoder(); break; case 1: _bleManager.openDecoder(); diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index a20cdd12a..2a1c2c7e9 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -11,27 +11,30 @@ import '../model/meeting_model.dart'; class MeetingView extends GetView { const MeetingView({super.key}); - static OverlayEntry? _recordingOverlayEntry; + @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(context, isDarkMode), - body: _buildBody(context, isDarkMode), - floatingActionButton: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton( - heroTag: "addAudio", - onPressed: () { - _showAddAudioMenu(context, isDarkMode); - }, - child: const Icon(Icons.add), - ), - ], + return PopScope( + onPopInvokedWithResult: controller.onPopInvokedWithResult, + child: Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: _buildAppBar(context, isDarkMode), + body: _buildBody(context, isDarkMode), + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: "addAudio", + onPressed: () { + _showAddAudioMenu(context, isDarkMode); + }, + child: const Icon(Icons.add), + ), + ], + ), ), ); } @@ -451,43 +454,43 @@ class MeetingView extends GetView { // TODO: 实现从文件导入逻辑 }, ), - SizedBox(height: 8.w), - _importAudioItem1( - context, - isDarkMode, - icon: Image.asset('assets/images/explore.png', - width: 32.w, height: 32.w), - title: '从翻译里导入', - onTap: () { - Navigator.of(context).pop(); - _showLocalAudioVideoPicker(context, isDarkMode); - // TODO: 实现从相册导入逻辑 - }, - ), - SizedBox(height: 8.w), - _importAudioItem1( - context, - isDarkMode, - icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - ], - ), - title: '从opus导出', - onTap: () { - Navigator.of(context).pop(); - // TODO: 实现从其他APP导入逻辑 - _showLocalOpusPicker(context, isDarkMode); - }, - ), + // SizedBox(height: 8.w), + // _importAudioItem1( + // context, + // isDarkMode, + // icon: Image.asset('assets/images/explore.png', + // width: 32.w, height: 32.w), + // title: '从翻译里导入', + // onTap: () { + // Navigator.of(context).pop(); + // _showLocalAudioVideoPicker(context, isDarkMode); + // // TODO: 实现从相册导入逻辑 + // }, + // ), + // SizedBox(height: 8.w), + // _importAudioItem1( + // context, + // isDarkMode, + // icon: Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Image.asset('assets/images/explore.png', + // width: 20.w, height: 20.w), + // SizedBox(width: 4.w), + // Image.asset('assets/images/explore.png', + // width: 20.w, height: 20.w), + // SizedBox(width: 4.w), + // Image.asset('assets/images/explore.png', + // width: 20.w, height: 20.w), + // ], + // ), + // title: '从opus导出', + // onTap: () { + // Navigator.of(context).pop(); + // // TODO: 实现从其他APP导入逻辑 + // _showLocalOpusPicker(context, isDarkMode); + // }, + // ), ], ), ), @@ -575,13 +578,12 @@ class MeetingView extends GetView { SizedBox(height: 8.w), // 音、视频录音 - 根据控制器状态禁用 _importAudioItem( - context, - isDarkMode, + context, isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '音、视频录音', - enabled: false, // 依赖控制器状态 - onTap: false + enabled: controller.bleManager.isConnected, // 依赖控制器状态 + onTap: controller.bleManager.isConnected ? () async { // 条件启用 Navigator.of(context).pop(); @@ -596,14 +598,13 @@ class MeetingView extends GetView { SizedBox(height: 8.w), // 通话录音 - 根据控制器状态禁用 _importAudioItem( - context, - isDarkMode, + context, isDarkMode, icon: Row( // ... 图标组合保持不变 ), title: '通话录音', - enabled: false, // 依赖控制器状态 - onTap: false + enabled: controller.bleManager.isConnected, // 依赖控制器状态 + onTap: controller.bleManager.isConnected ? () async { // 条件启用 Navigator.of(context).pop(); diff --git a/lib/modules/meeting/views/share_bottom_sheet.dart b/lib/modules/meeting/views/share_bottom_sheet.dart index 229d83501..318d40df7 100644 --- a/lib/modules/meeting/views/share_bottom_sheet.dart +++ b/lib/modules/meeting/views/share_bottom_sheet.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -71,7 +72,42 @@ class _ShareBottomSheetState extends State { child: Column( children: [ _box([ - _shareItem(Icons.link, '分享链接', isBorder: false), + _shareItem( + Icons.link, + '分享链接', + isBorder: false, + onTap: () async { + Get.back(); + final GetStorage storage = GetStorage(); + String token = storage.read("logintoken") ?? ''; + String userId = ''; + Map? userInfo = storage.read("user_info"); + if (userInfo != null) { + userId = userInfo['user']['uid']; + } + + String id = + '${userId}_${_controller.meetingData.value.id}'; + + final thumbnail = await getAssetThumbnailFile( + 'assets/images/headphone_dark.png', + 'share_thumb.png', + ); + + final result = await SharePlus.instance.share(ShareParams( + title: _controller.meetingData.value.title, + subject: + 'DeepSound WEB,由DeepSound.Ai提供支持-实时记录会议纪要,多语言翻译。', + uri: Uri.parse( + 'http://web.ideapsound.com/share/$id/$token', + ), + previewThumbnail: thumbnail, + )); + if (result.status == ShareResultStatus.success) { + EasyLoading.showToast('分享完成!'); + } + }, + ), ]), _box([ _shareItem(Icons.copy, '复制转写', onTap: () { @@ -213,4 +249,12 @@ class _ShareBottomSheetState extends State { } return text; } + + Future getAssetThumbnailFile(String assetPath, String fileName) async { + final byteData = await rootBundle.load(assetPath); + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(byteData.buffer.asUint8List()); + return XFile(file.path); + } } diff --git a/lib/modules/meeting/views/speaker_all_bottom_sheet.dart b/lib/modules/meeting/views/speaker_all_bottom_sheet.dart index 93b89c0f3..1d7a7c03b 100644 --- a/lib/modules/meeting/views/speaker_all_bottom_sheet.dart +++ b/lib/modules/meeting/views/speaker_all_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; @@ -6,6 +8,7 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:lottie/lottie.dart'; +import '../../../core/utils/synchrodata.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../controllers/meeting_details_controller.dart'; @@ -556,6 +559,10 @@ class _SpeakerAllBottomSheetState extends State { bool isEditSummary = false; String summaryText = _controller.meetingDetails.value.summary; _controller.textList.value = newTextList; + Synchrodata.saveOperationRecord('update', { + 'original': jsonEncode(_controller.textList), + 'id': _controller.meetingData.value.id, + }); for (var element in personnelList) { if (summaryText.contains('[${element['original']}]')) { summaryText = summaryText.replaceAll( diff --git a/lib/modules/meeting/views/speaker_bottom_sheet.dart b/lib/modules/meeting/views/speaker_bottom_sheet.dart index 0a0a40871..2e814e26f 100644 --- a/lib/modules/meeting/views/speaker_bottom_sheet.dart +++ b/lib/modules/meeting/views/speaker_bottom_sheet.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + 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:get_storage/get_storage.dart'; +import '../../../core/utils/synchrodata.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../controllers/meeting_details_controller.dart'; @@ -331,6 +334,10 @@ class _SpeakerBottomSheetState extends State { dataMap['speaker'] = _speakerController.text; _controller.textList[index] = dataMap; } + Synchrodata.saveOperationRecord('update', { + 'original': jsonEncode(_controller.textList), + 'id': _controller.meetingData.value.id, + }); _addHistorySpeaker(); Get.back(); } @@ -361,6 +368,10 @@ class _SpeakerBottomSheetState extends State { var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList); if (result != null) { _controller.textList.value = newTextList; + Synchrodata.saveOperationRecord('update', { + 'original': jsonEncode(_controller.textList), + 'id': _controller.meetingData.value.id, + }); if (_controller.meetingDetails.value.summary .contains('[${widget.speakerName}]')) { String summaryText = _controller.meetingDetails.value.summary diff --git a/lib/modules/music/views/music_playlist_view.dart b/lib/modules/music/views/music_playlist_view.dart index 65fecb2eb..37a35fbf2 100644 --- a/lib/modules/music/views/music_playlist_view.dart +++ b/lib/modules/music/views/music_playlist_view.dart @@ -175,19 +175,19 @@ class MusicPlaylistView extends StatelessWidget { backgroundColor: Colors.grey, size: 40.0, strokeWidth: 2.5, - onPlayPause: () { - if (controller.isPlaying.value) { - if (controller.currentId.value == id) { - controller.musicPause(); - } else { - controller.musicPlay(song); - } - } else { - if (controller.currentId.value == id) { - controller.musicResume(); + onPlayPause: () async { + try { + if (controller.isPlaying.value) { + if (controller.currentId.value == id) { + await controller.musicPause(); + } else { + await controller.musicPlay(song); + } } else { controller.musicPlay(song); } + } catch (e) { + // 静默处理错误 } }, onSeek: isThisPlaying diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index 36fd66fe3..285f687a0 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -16,6 +16,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'dart:async'; import 'package:app_settings/app_settings.dart'; +import '../../../data/services/version_update_service.dart'; +import '../../common/widgets/version_update_dialog.dart'; class SettingsController extends GetxController { // 语言设置 @@ -490,4 +492,50 @@ class SettingsController extends GetxController { } } } + + /// 手动检查版本更新 + Future checkForUpdate() async { + try { + // 显示加载提示 + Get.dialog( + const Center( + child: CircularProgressIndicator(), + ), + barrierDismissible: false, + ); + + final versionService = VersionUpdateService.to; + final updateInfo = await versionService.checkForUpdate(); + + // 关闭加载提示 + Get.back(); + + if (updateInfo.hasUpdate) { + // 显示更新对话框 + await VersionUpdateDialog.show(updateInfo); + } else { + // 显示已是最新版本的提示 + Get.snackbar( + '检查更新', + '当前已是最新版本 ${updateInfo.currentVersion}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + } + } catch (e) { + // 关闭加载提示 + if (Get.isDialogOpen == true) { + Get.back(); + } + + Get.snackbar( + '检查更新失败', + '网络连接异常,请稍后重试', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + } } diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index fc37c5bad..777f8c48f 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -543,6 +543,28 @@ class SettingsView extends GetView { }, isDarkMode: isDarkMode, ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + // 面对面翻译测试 + _buildSimpleNavigationSetting( + title: '面对面翻译测试', + subtitle: '测试面对面翻译', + icon: Icons.bluetooth_searching, + iconBgColor: isDarkMode + ? Colors.green[900]!.withOpacity(0.3) + : Colors.green[100]!, + iconColor: + isDarkMode ? Colors.green[300]! : Colors.green[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.ftftranslation); + }, + isDarkMode: isDarkMode, + ), Divider( height: 1, color: isDarkMode @@ -880,7 +902,8 @@ class SettingsView extends GetView { ), InkWell( onTap: () { - // 在这里添加检查更新的逻辑 + // 调用检查更新方法 + controller.checkForUpdate(); }, child: Container( constraints: BoxConstraints( diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index e53f146dc..de6a1d09e 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -8,6 +8,8 @@ import '../../../data/models/user_Info.dart'; import '../../../routes/app_routes.dart'; import '../../../core/themes/theme_service.dart'; import '../../../data/services/network/api.dart'; +import '../../../data/services/version_update_service.dart'; +import '../../../modules/common/widgets/version_update_dialog.dart'; class SplashController extends GetxController { final GetStorage _storage = GetStorage(); @@ -73,6 +75,9 @@ class SplashController extends GetxController { // await _loadImages(); // await _initDatabase(); + // 检查版本更新(在后台进行,不阻塞启动流程) + _checkVersionUpdate(); + _isResourceLoaded.value = true; _checkNavigateCondition(); } catch (e) { @@ -151,4 +156,23 @@ class SplashController extends GetxController { return false; } } + + /// 检查版本更新(后台执行,不阻塞启动流程) + void _checkVersionUpdate() { + // 延迟执行版本检查,避免影响启动速度 + Timer(const Duration(seconds: 1), () async { + try { + final versionService = VersionUpdateService.to; + final updateInfo = await versionService.checkForUpdate(); + + if (updateInfo.hasUpdate) { + // 显示更新对话框 + await VersionUpdateDialog.show(updateInfo); + } + } catch (e) { + print('版本检查失败: $e'); + // 版本检查失败不影响用户使用,静默处理 + } + }); + } } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 9a911accf..d83b04bdd 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -26,7 +26,7 @@ class TranslationController extends GetxController { final LanguageManager _languageManager = Get.find(); final GetStorage _storage = GetStorage(); // 蓝牙服务 - final _bleManager = Get.find(); + final bleManager = Get.find(); // 音频输入源 bool _audioSourceType = false; // 存储相关 @@ -308,14 +308,14 @@ class TranslationController extends GetxController { // translationHistory.add(newItem); // 开始连续语音识别 if (currentMode.value == "audioVideo") { - _bleManager.openDecoder(); + bleManager.openDecoder(); // 发送ble音乐或者通话远端声音 _audioSourceType = true; isTtsEnabled.value = false; // 禁用TTS以避免干扰 Logger.info('发送ble音乐或者通话远端声音'); } else if (currentMode.value == "call") { // 发送ble系统mic和dac(音乐或者通话远端)声音 - _bleManager.openA2DPDecoder(); + bleManager.openA2DPDecoder(); _audioSourceType = true; isTtsEnabled.value = true; Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); @@ -563,7 +563,7 @@ class TranslationController extends GetxController { Future stopAll() async { try { if (currentMode.value == "audioVideo" || currentMode.value == "call") { - _bleManager.closeCodec(); + bleManager.closeCodec(); // 发送ble音乐或者通话远端声音 Logger.info('发送ble音乐或者通话远端声音'); } diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index e8eaf3d3e..048f5f463 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -67,12 +67,12 @@ class TranslationView extends GetView { _buildPopupMenuItem( 'audioVideo', 'audioVideoTranslation'.tr, - enabled: true, // 根据连接状态启用/禁用 + enabled: controller.bleManager.isConnected, // 根据连接状态启用/禁用 ), _buildPopupMenuItem( 'call', 'callTranslation'.tr, - enabled: true, // 根据连接状态启用/禁用 + enabled: controller.bleManager.isConnected, // 根据连接状态启用/禁用 ), ], child: Row( diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 66c5f2516..8f3a14799 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -46,6 +46,8 @@ import '../modules/realtime/bindings/realtime_binding.dart'; import '../modules/realtime/views/realtime_view.dart'; import '../modules/music/views/music_playlist_view.dart'; import '../modules/music/bindings/music_playlist_binding.dart'; +import '../modules/FTFTranslation/bindings/FTFTranslation_binding.dart'; +import '../modules/FTFTranslation/views/FTFTranslation_view.dart'; abstract class AppPages { static final pages = [ @@ -191,6 +193,13 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.ftftranslation, + page: () => const FTFTranslationView(), + binding: FTFTranslationBinding(), + transition: Transition.noTransition, + transitionDuration: Duration(milliseconds: 250), + ), GetPage( name: Routes.opusTest, page: () => const OpusTestView(), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 3e2e1be89..550ab16cc 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -28,6 +28,7 @@ abstract class Routes { static const translationHistory = '/translation/history'; // 翻译历史 static const speechDemo = '/speech_demo'; // 语音演示 static const translationList = '/translation/List'; // 翻译历史 + static const ftftranslation = '/ftftranslation'; // 面对面翻译 // 测试路由(生产环境建议移除) static const ttsTest = '/tts_test'; // 文字转语音测试 static const asrTest = '/asr_test'; // 语音识别测试 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 55eb87911..2e9d93f87 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 @@ -22,6 +22,7 @@ import java.util.concurrent.LinkedBlockingQueue import android.os.Handler import java.util.concurrent.atomic.AtomicBoolean import android.util.Log + /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 * 支持: @@ -67,17 +68,20 @@ object BleService { private var bluetoothLeScanner: BluetoothLeScanner? = null // GATT连接相关 - var bluetoothGatt: BluetoothGatt? = null + var bluetoothGatt: BluetoothGatt? = null private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null + // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() private var isScanning = false -// 是否是ota模式 + + // 是否是ota模式 var isEnterOta = false + // 连接状态 val connectionState = MutableLiveData(BleConst.STATE_DISCONNECTED) @@ -136,7 +140,7 @@ object BleService { Log.e(TAG, "OpusManager初始化失败: ${e.message}", e) // 根据需要决定是否因为Opus初始化失败而返回false } -recordfile = RecordingFile(this.context) + recordfile = RecordingFile(this.context) isInitialized = true Log.d(TAG, "BLE服务初始化成功") return true @@ -198,7 +202,6 @@ recordfile = RecordingFile(this.context) // 获取一个局部引用,避免并发访问问题 val scanner = bluetoothLeScanner ?: return false if (isScanning) return false - Log.i(TAG, "开始主动扫描BLE设备...") scanResults.clear() @@ -341,13 +344,12 @@ recordfile = RecordingFile(this.context) Log.w(TAG, "未找到已配对的设备 MAC 地址") return } - - - if(!isEnterOta) - { - Log.i(TAG, "检测到已配对设备,MAC: $pairedMac,尝试连接") - // 调用现有的 connect 方法 - connect(pairedMac) + + + if (!isEnterOta) { + Log.i(TAG, "检测到已配对设备,MAC: $pairedMac,尝试连接") + // 调用现有的 connect 方法 + connect(pairedMac) } } catch (e: Exception) { @@ -518,7 +520,7 @@ recordfile = RecordingFile(this.context) when (c.uuid) { // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { - + // Log.i(TAG, "收到音频特征数据") processAudioData(data) } @@ -597,7 +599,7 @@ recordfile = RecordingFile(this.context) private fun processAudioData(data: ByteArray) { try { if (opusManager?.isDecodeStream == true) { - recordfile?.saveAudioDataToWav(data) + recordfile?.saveAudioDataToWav(data) opusManager?.writeAudioStream(data) } else { // Log.d(TAG, "Opus解码流未启动,忽略音频数据") @@ -795,17 +797,16 @@ recordfile = RecordingFile(this.context) BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" else -> "未知状态($codecStatus)" } -if (codecStatus == BleConst.CODEC_CONTROL_DECODE_ON || - codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || - codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON) { - - recordfile!!.closeFile() - recordfile!!.creatingFiles() -} -else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) -{ - recordfile!!.closeFile() -} + if (codecStatus == BleConst.CODEC_CONTROL_DECODE_ON || + codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || + codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON + ) { + + recordfile!!.closeFile() + recordfile!!.creatingFiles() + } else if (codecStatus == BleConst.CODEC_CONTROL_CLOSE) { + recordfile!!.closeFile() + } // 声道模式描述 val channelDesc = when (channelMode) { BleConst.AUDIO_CHANNEL_LEFT -> "左声道" @@ -1093,7 +1094,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // // The library might expect this in a different unit or derive it. // // Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms. // ) - startOpusStreamDecoding(false, 1, 16000, 40) + startOpusStreamDecoding(false, 1, 16000, 40) Log.i(TAG, "打开解码0xA2") return sendCommand( @@ -1110,7 +1111,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) */ fun openA2DPDecoder(): Boolean { Log.i(TAG, "打开编码 0xA1") - startOpusStreamDecoding(false, 1, 16000, 80) + startOpusStreamDecoding(false, 1, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1135,7 +1136,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) fun openEncoder(): Boolean { Log.i(TAG, "打开编码0xB1") - startOpusStreamDecoding(false, 1, 16000, 40) + startOpusStreamDecoding(false, 1, 16000, 40) // 目前仅发送命令通知设备开始编码。 return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), @@ -1385,9 +1386,10 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) } } - + } - class RecordingFile(private val context: Context) { + +class RecordingFile(private val context: Context) { private var currentAudioFile: File? = null private var fos: FileOutputStream? = null @@ -1408,8 +1410,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // 创建新的音频文件 val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) val timestamp = dateFormat.format(Date()) - - val filePath = File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath + + val filePath = + File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath currentAudioFile = File(filePath) currentAudioFile?.createNewFile() // 追加音频数据到文件 @@ -1417,42 +1420,42 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) startWriteThread() // 新增:启动写入线程 } - // 新增:启动写入线程 - private fun startWriteThread() { - if (isWriting.get()) return - isWriting.set(true) - writeThread = Thread { - try { - while (isWriting.get() || writeQueue.isNotEmpty()) { - val data = writeQueue.poll() ?: continue - fos?.write(data) - } - } catch (e: Exception) { - Log.e("", "异步写入音频数据失败: ${e.message}") + // 新增:启动写入线程 + private fun startWriteThread() { + if (isWriting.get()) return + isWriting.set(true) + writeThread = Thread { + try { + while (isWriting.get() || writeQueue.isNotEmpty()) { + val data = writeQueue.poll() ?: continue + fos?.write(data) } + } catch (e: Exception) { + Log.e("", "异步写入音频数据失败: ${e.message}") } - writeThread?.start() } + writeThread?.start() + } - /** - * 保存音频数据到 WAV 文件(异步) - */ - internal fun saveAudioDataToWav(buffer: ByteArray) { - if (fos == null || currentAudioFile == null) return - // 放入队列,由写线程写入 - // 1. 将字节数据转换为十六进制字符串 - // val hexData = buildString { - // buffer.forEachIndexed { index, byte -> - // append("%02X".format(byte)) - - // } - // } - - // 2. 写入十六进制字符串 - - writeQueue.offer(buffer) - totalBytesWritten += buffer.size - } + /** + * 保存音频数据到 WAV 文件(异步) + */ + internal fun saveAudioDataToWav(buffer: ByteArray) { + if (fos == null || currentAudioFile == null) return + // 放入队列,由写线程写入 + // 1. 将字节数据转换为十六进制字符串 + // val hexData = buildString { + // buffer.forEachIndexed { index, byte -> + // append("%02X".format(byte)) + + // } + // } + + // 2. 写入十六进制字符串 + + writeQueue.offer(buffer) + totalBytesWritten += buffer.size + } internal fun closeFile() { diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 642ce5ca1..1c5b7de75 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -10,6 +10,7 @@ import android.content.pm.PackageManager import android.location.LocationManager import android.net.Uri import android.os.Build +import android.provider.AlarmClock import android.provider.CalendarContract import android.provider.ContactsContract import android.provider.Settings @@ -558,6 +559,66 @@ private class SendTextMessageHandler(private val context: Context?) : FunctionHa * 拨打电话处理器 */ private class MakePhoneCallHandler(private val context: Context?) : FunctionHandler { + + /** + * 根据联系人名称获取电话号码 + * @param contact 联系人名称或电话号码 + * @return 电话号码 + */ + private fun getPhoneNumberForContact(contact: String): String { + // 如果输入的是电话号码(简单判断,包含数字和特殊符号) + if (contact.matches(Regex("^[+\\d\\s\\-()]*\$")) && contact.any { it.isDigit() }) { + // 移除非数字字符 + return contact.replace(Regex("[^+\\d]"), "") + } + + // 检查读取联系人权限 + if (!checkPermissionAndGuide(context!!, Manifest.permission.READ_CONTACTS, "读取联系人", "访问联系人")) { + return "" + } + + // 从联系人数据库查询 + try { + val contentResolver = context.contentResolver + val uri = Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_FILTER_URI, + Uri.encode(contact) + ) + + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val hasPhoneNumber = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + + if (hasPhoneNumber > 0) { + val contactId = cursor.getString(cursor.getColumnIndexOrThrow( + ContactsContract.Contacts._ID)) + val phoneUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI + val phoneProjection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val phoneSelection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" + val phoneSelectionArgs = arrayOf(contactId) + + contentResolver?.query(phoneUri, phoneProjection, phoneSelection, phoneSelectionArgs, null)?.use { phoneCursor -> + if (phoneCursor.moveToFirst()) { + return phoneCursor.getString(phoneCursor.getColumnIndexOrThrow( + ContactsContract.CommonDataKinds.Phone.NUMBER)) + } + } + } + } + } + } catch (e: Exception) { + Log.e("ChatApi", "查询联系人信息失败: ${e.message}", e) + } + + return "" + } + override suspend fun handle(arguments: Map): String { val contact = arguments["contact"] as? String @@ -630,6 +691,21 @@ private class MakePhoneCallHandler(private val context: Context?) : FunctionHand }) }.toString() } +// return try { +// val callIntent = Intent(Intent.ACTION_DIAL).apply { +// data = Uri.parse("tel:$contact") +// flags = Intent.FLAG_ACTIVITY_NEW_TASK +// } +// +// if (context?.packageManager?.queryIntentActivities(callIntent, 0)?.isNotEmpty() == true) { +// context.startActivity(callIntent) +// "{\"result\": \"已发起电话呼叫\"}" +// } else { +// "{\"result\": \"无法拨打电话\"}" +// } +// } catch (e: Exception) { +// "{\"result\": \"拨打电话失败:${e.message}\"}" +// } } } @@ -637,6 +713,140 @@ private class MakePhoneCallHandler(private val context: Context?) : FunctionHand * 设置提醒处理器 */ private class SetReminderHandler(private val context: Context?) : FunctionHandler { + + /** + * 获取默认日历ID + */ + private fun getDefaultCalendarId(): Long { + val projection = arrayOf(CalendarContract.Calendars._ID) + var calendarId = -1L + + try { + val uri = CalendarContract.Calendars.CONTENT_URI + val selection = "(${CalendarContract.Calendars.VISIBLE} = 1) AND (${CalendarContract.Calendars.IS_PRIMARY} = 1)" + context?.contentResolver?.query(uri, projection, selection, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + calendarId = cursor.getLong(0) + } else { + // 如果没有找到主日历,尝试找任何可见的日历 + context.contentResolver.query(uri, projection, "${CalendarContract.Calendars.VISIBLE} = 1", null, null)?.use { c -> + if (c.moveToFirst()) { + calendarId = c.getLong(0) + } + } + } + } + } catch (e: Exception) { + Log.e("CahtApi", "获取默认日历ID失败: ${e.message}", e) + } + + return calendarId + } + + /** + * 尝试创建闹钟提醒(备选方案) + */ + private fun tryCreateAlarm(title: String, content: String, timeStr: String): String { + try { + // 解析小时和分钟 + val timeParts = timeStr.split(" ")[1].split(":") + val hour = timeParts[0].toInt() + val minute = timeParts[1].toInt() + + val alarmIntent = Intent(AlarmClock.ACTION_SET_ALARM) + alarmIntent.putExtra(AlarmClock.EXTRA_MESSAGE, "$title: $content") + alarmIntent.putExtra(AlarmClock.EXTRA_HOUR, hour) + alarmIntent.putExtra(AlarmClock.EXTRA_MINUTES, minute) + alarmIntent.putExtra(AlarmClock.EXTRA_SKIP_UI, true) // 尝试跳过UI + alarmIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + if (alarmIntent.resolveActivity(context!!.packageManager) != null) { + context.startActivity(alarmIntent) + return "{\"result\": \"已创建闹钟提醒: $title, 时间: $timeStr\"}" + } + } catch (e: Exception) { + Log.e("CahtApi", "创建闹钟失败: ${e.message}", e) + } + return "{\"result\": \"未找到可用的日历或闹钟应用\"}" + } + + /** + * 设置提醒 + * @param title 提醒标题 + * @param content 提醒内容(可选) + * @param timeStr 提醒时间,格式为"yyyy-MM-dd HH:mm" + * @return 操作结果 + */ + fun setReminder(title: String, content: String, timeStr: String): String { + Log.d("CahtApi", "设置提醒: $title, 时间: $timeStr") + + try { + // 解析时间字符串 + val time:Long = try { + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) + dateFormat.parse(timeStr)?.time ?: System.currentTimeMillis() + } catch (e: Exception) { + Log.e("CahtApi", "解析时间失败: $timeStr, ${e.message}") + return "{\"result\": \"提醒时间格式无效,请使用'yyyy-MM-dd HH:mm'格式\"}" + } + + // 检查日历权限 + if (!checkPermissionAndGuide(context!!,Manifest.permission.WRITE_CALENDAR, "日历写入", "设置提醒")) { + return "{\"result\": \"需要日历权限才能创建提醒,请在权限管理中开启日历权限\"}" + } + + // 直接使用ContentProvider写入日历 + val endTime = time + 3600000 // 默认1小时后结束 + + // 获取默认日历ID + val calendarId = getDefaultCalendarId() + if (calendarId == -1L) { + Log.w("CahtApi", "未找到默认日历") + return tryCreateAlarm(title, content, timeStr) + } + + // 创建事件 + val eventValues = ContentValues().apply { + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.TITLE, title) + put(CalendarContract.Events.DESCRIPTION, content) + put(CalendarContract.Events.DTSTART, time) + put(CalendarContract.Events.DTEND, endTime) + put(CalendarContract.Events.HAS_ALARM, 1) + put(CalendarContract.Events.EVENT_TIMEZONE, java.util.TimeZone.getDefault().id) + } + + val eventUri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, eventValues) + if (eventUri == null) { + Log.e("CahtApi", "插入日历事件失败") + return tryCreateAlarm(title, content, timeStr) + } + + // 获取事件ID + val eventId = ContentUris.parseId(eventUri) + + // 添加提醒(15分钟前提醒) + val reminderValues = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + put(CalendarContract.Reminders.MINUTES, 15) // 提前15分钟提醒 + } + + val reminderUri = context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) + if (reminderUri != null) { + return "{\"result\": \"已成功创建提醒: $title, 时间: $timeStr\"}" + } else { + // 仍然创建了事件,但没有提醒 + return "{\"result\": \"已创建日历事件,但未设置提醒: $title\"}" + } + } catch (e: Exception) { + Log.e("CahtApi", "设置提醒失败: ${e.message}", e) + return "{\"result\": \"设置提醒失败: ${e.message}\"}" + } + } + + + override suspend fun handle(arguments: Map): String { val title = arguments["title"] as? String val content = arguments["content"] as? String ?: "" diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt index 0d1b90153..31b7bba05 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt @@ -1,5 +1,13 @@ package com.yunqiinnovation.chat_api +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.util.Log +import androidx.core.content.ContextCompat import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.sse.* @@ -54,4 +62,90 @@ fun createSslTrustAllClient(): HttpClient { */ fun createMcpHttpClient(): HttpClient { return createSslTrustAllClient() -} \ No newline at end of file +} + +/** + * 检查应用是否在前台运行 + * @return 应用是否在前台 + */ +fun isAppInForeground(context: Context,): Boolean { + try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = context.packageName + + for (appProcess in appProcesses) { + if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + && appProcess.processName == packageName) { + return true + } + } + } catch (e: Exception) { + Log.e("ChatApi", "检查应用状态失败: ${e.message}", e) + } + return false +} +/** + * 检查权限并根据应用状态采取不同行为 + * @param permission 需要检查的权限 + * @param permissionName 权限名称(用于显示给用户) + * @param functionName 功能名称(用于日志和提示) + * @return 是否拥有权限 + */ +fun checkPermissionAndGuide(context: Context, permission: String, permissionName: String, functionName: String): Boolean { + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + Log.w("ChatApi", "缺少${permissionName}权限,无法执行${functionName}") + + // 检查应用是否在前台 + if (isAppInForeground(context)) { + // 应用在前台,尝试直接弹出权限申请框 + try { + // 首先尝试发送广播通知DeepSound应用弹出权限申请对话框 + val intent = Intent("com.yunqiinnovation.deepsound.action.REQUEST_PERMISSION").apply { + setPackage(context.packageName) + putExtra("permission", permission) + putExtra("permissionName", permissionName) + putExtra("functionName", functionName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + context.sendBroadcast(intent) + + // 由于插件无法直接请求权限(需要Activity),上面的广播会让主应用来处理权限请求 + // 这里设置一个短暂延迟,让用户有时间响应权限请求 + Thread.sleep(500) + + // 再次检查权限是否已授予(以防用户立即授予) + if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) { + return true + } + } catch (e: Exception) { + Log.e("ChatApi", "请求权限失败: ${e.message}", e) + } + + // 如果权限仍未授予,引导用户到权限管理页面 + try { + // 尝试跳转到应用内的权限管理页面 + val intent = Intent("com.yunqiinnovation.deepsound.action.PERMISSIONS_SETTINGS").apply { + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + // 如果应用内权限页面不可用,则跳转到系统应用设置页面 + val settingsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(settingsIntent) + } + } catch (e: Exception) { + Log.e("ChatApi", "打开权限设置页面失败: ${e.message}", e) + } + } + // 应用在后台或权限请求失败,只返回文字提示 + return false + } + return true +} \ No newline at end of file diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index 5a2319917..8e25fdea2 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt @@ -1,23 +1,61 @@ package com.yunqiinnovation.location_service +import android.Manifest +import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager import android.location.Location +import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.core.content.ContextCompat import com.amap.api.location.* import io.flutter.plugin.common.MethodChannel object LocationService { private const val TAG = "LocationService" - + private var appcontext:Context? = null private var locationClient: AMapLocationClient? = null private var currentLocation: AMapLocation? = null private var initialized = false + private var permissionRetryHandler: Handler? = null + private var permissionRetryRunnable: Runnable? = null + + /** + * 检查应用是否在前台运行 + * @return 应用是否在前台 + */ + fun isAppInForeground(context: Context,): Boolean { + try { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = context.packageName + + for (appProcess in appProcesses) { + if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + && appProcess.processName == packageName) { + return true + } + } + } catch (e: Exception) { + Log.e("ChatApi", "检查应用状态失败: ${e.message}", e) + } + return false + } + fun initialize(context: Context) { if (initialized) return + appcontext = context // Log.e(TAG, "liwei--------- 初始化高德定位服务 1") - + if (!checkPermissions(context)) { + Log.w(TAG, "没有定位权限,等待用户授权中...") + startPermissionMonitor() + return + } try { + AMapLocationClient.updatePrivacyShow(context, true, true) AMapLocationClient.updatePrivacyAgree(context, true) // Log.e(TAG, "liwei--------- 初始化高德定位服务 2") @@ -47,6 +85,61 @@ object LocationService { } } + + + private fun startPermissionMonitor() { + if (permissionRetryHandler != null) return + + permissionRetryHandler = Handler(Looper.getMainLooper()) + permissionRetryRunnable = object : Runnable { + override fun run() { + val ctx = appcontext ?: return + val hasPermission = checkPermissions(ctx) + if (hasPermission && !initialized) { + Log.d(TAG, "权限已授予,重新初始化定位服务") + initialize(ctx) + stopPermissionMonitor() // 停止定时器 + } else { + permissionRetryHandler?.postDelayed(this, 5000) + } + } + } + permissionRetryHandler?.post(permissionRetryRunnable!!) + } + + private fun stopPermissionMonitor() { + permissionRetryHandler?.removeCallbacks(permissionRetryRunnable!!) + permissionRetryHandler = null + permissionRetryRunnable = null + } + + private fun checkPermissions(context: Context): Boolean { + if (isAppInForeground(context)) { + return hasForegroundLocationPermission(context) + }else{ + return hasBackgroundLocationPermission(context) + } + } + + private fun hasForegroundLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + private fun hasBackgroundLocationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } else { + true // Android 9 及以下没有这个权限 + } + } + + fun getCachedLocation(): Map? { // // 定位成功,获取经纬度 // val latitude = location.latitude @@ -60,6 +153,22 @@ object LocationService { // val streetNum = location.streetNum // 门牌号 // val adCode = location.adCode // 行政区划代码 // val address = location.address // 详细地址拼接字符串 + if (!initialized){ + if (checkPermissions(appcontext!!)) { + return mapOf( + "user_location" to "当前定位权限为获取", + "user_lat" to "当前定位权限为获取", + "user_lng" to "当前定位权限为获取", + ) + }else{ + return mapOf( + "user_location" to "后台服务下,需要后台定位权限", + "user_lat" to "后台服务下,需要后台定位权限", + "user_lng" to "后台服务下,需要后台定位权限", + ) + } + } + return currentLocation?.let { mapOf( "user_location" to it.address, diff --git a/pubspec.yaml b/pubspec.yaml index 391b0446e..200cfc6fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: lottie: ^3.3.1 app_settings: 5.2.0 fluwx: ^5.4.1 + url_launcher: ^6.3.1 firebase_core: ^3.12.1 firebase_auth: ^5.5.1 google_sign_in: ^6.2.2