import 'dart:async'; import '/core/utils/id_verify_guard.dart'; import '/core/utils/device_connection.dart'; import '/core/utils/toast_throttle.dart'; import 'package:device_jieli/device_jieli.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:agent_service/agent_service.dart'; import '../../../data/services/novice_guide_service.dart'; import '../../../routes/app_routes.dart'; import '../../../core/utils/logger.dart'; import 'package:flutter/material.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/ble_manager.dart'; import '../../../data/services/ota_check_service.dart'; import '../../../data/services/id_verify_service.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; /// 连接状态枚举 enum ConnectionStatus { disconnected, // 未连接 connecting, // 连接中 connected, // 已连接 failed // 连接失败 } /// 主页控制器,管理设备连接状态和信息展示 class HomeController extends GetxController { static const String _tag = 'HomeCtrl'; static const String _lastDeviceNameKey = 'last_connected_device_name'; // 存储键 static const String _lastDeviceImageKey = 'last_connected_device_image'; // 存储上一次设备图片的键 static HomeController get to => Get.find(); final GetStorage _storage = GetStorage(); String? _lastConnectedDeviceImage; // 新增:存储上一次连接设备的图片链接 final _isFullyConnected = false.obs; String? _lastConnectedDeviceName; // 播报打断开关状态(与 AgentController 对齐) final isTtsInterruptEnabled = false.obs; // 是否启用播报打断 // 移除了电池信息定时刷新相关变量 // OTA检查服务 late final OtaCheckService _otaCheckService; bool get isFullyConnected => _isFullyConnected.value; RxBool get isFullyConnectedRx => _isFullyConnected; BluetoothDevice? get currentDevice => _bluetoothManager.currentDevice; Rx get deviceInfo => _bleManager.deviceInfo; static final RxInt leftEarBattery = 0.obs; static final RxInt rightEarBattery = 0.obs; final caseBattery = 0.obs; final leftEarCharging = false.obs; final rightEarCharging = false.obs; final caseCharging = false.obs; final firmwareVersion = ''.obs; void updateDeviceInfo(DeviceInfo? newDeviceInfo) { leftEarBattery.value = newDeviceInfo?.leftEarBattery ?? 0; rightEarBattery.value = newDeviceInfo?.rightEarBattery ?? 0; caseBattery.value = newDeviceInfo?.caseBattery ?? 0; leftEarCharging.value = newDeviceInfo?.leftEarCharging ?? false; rightEarCharging.value = newDeviceInfo?.rightEarCharging ?? false; caseCharging.value = newDeviceInfo?.caseCharging ?? false; firmwareVersion.value = newDeviceInfo?.firmwareVersion ?? ''; } Worker? _deviceInfoWorker; Worker? _classicBluetoothWorker; Worker? _bleConnectionWorker; /// 全局 5s 刷新定时器。声明为 static 是为了让它跨 HomeController 实例存活: /// `Get.offAllNamed(Routes.home)` 把旧 controller 干掉、新 controller 起来时, /// 这个定时器始终是同一个,永远在跑,直接调当前注册的 HomeController 的 /// `refreshDeviceConnection`(=点刷新按钮的逻辑)。 static Timer? _staticPollTimer; static const Duration _pollInterval = Duration(seconds: 5); final _bluetoothManager = Get.find(); final _bleManager = Get.find(); final noviceGuideService = Get.find(); /// 按设备名安全地查产品(AppConfig.getproduct 会抛,见调用处注释)。 DBProduct? _findProductByName(String deviceName) { if (!AppConfig.isInitialized()) return null; final n = deviceName.trim(); if (n.isEmpty) return null; for (final p in AppConfig.config.products) { if (p.devicename.contains(n)) return p; } return null; } @override void onInit() { super.onInit(); _otaCheckService = Get.find(); // 实名状态:InitialBinding 里那次拉取发生在登录之前、必然拿不到, // 登录后所有流程都会落到 home,在这里补一次(已拉到过就不会重复请求)。 if (Get.isRegistered()) { IdVerifyService.to.ensureLoaded(); } _loadLastConnectedDeviceName(); // 加载持久化的设备名称 _initWithCachedInfo(); _loadTtsInterrupt(); // 加载播报打断开关初始状态 // 监听存储中的“agent_tts_interrupe”键变化,实现与 Agent 页同步 // 中文注释:当 Agent 页或 Home 页任意一处切换时,另一处通过此监听自动刷新开关状态 _storage.listenKey('agent_tts_interrupt', (value) { try { final bool newValue = value is bool ? value : (value == 'true'); if (isTtsInterruptEnabled.value != newValue) { isTtsInterruptEnabled.value = newValue; } } catch (e) { // 中文注释:忽略异常,保持现有状态,避免因类型转换失败导致崩溃 } }); // 移除了电池信息定时刷新的启动 } @override void onClose() { _deviceInfoWorker?.dispose(); _classicBluetoothWorker?.dispose(); _bleConnectionWorker?.dispose(); super.onClose(); } void _initWithCachedInfo() { _setupStateListeners(); _checkConnectionStatus(); _ensureStaticPollTimer(); } /// 启动静态 5s 刷新定时器。已经在跑就什么都不做。 /// 定时器一旦起来就永远不取消(HomeController 只是 lazyPut,控制器实例可能反复 /// 销毁/重建,但定时器跨实例存活),每次 fire 都去当前注册的 HomeController /// 调 refreshDeviceConnection——和用户点刷新按钮完全等价。 static void _ensureStaticPollTimer() { if (_staticPollTimer != null && _staticPollTimer!.isActive) return; _staticPollTimer = Timer.periodic(_pollInterval, (_) { try { if (Get.isRegistered()) { Get.find().refreshDeviceConnection(); } } catch (_) { // 忽略:home 还没注册或者刚销毁中间窗口期 } }); } void _setupStateListeners() { _deviceInfoWorker = ever(_bleManager.deviceInfo, (DeviceInfo? info) { if (info != null) updateDeviceInfo(info); }); _classicBluetoothWorker = ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { if (device != null) { _saveLastConnectedDeviceName(device.name); // 使用新的保存方法 } _checkConnectionStatus(); }); _bleConnectionWorker = ever(_bleManager.connectionStateRx, (int state) { _checkConnectionStatus(); }); } /// 加载“播报打断”开关状态 /// 逻辑与 AgentController 保持一致: /// 1)若本地不存在存储,则根据设备产品的 iscontinuouschat 取反作为默认值; /// 2)若存在存储则直接读取; /// 3)同步到原生服务 AgentService.isInterrupt。 Future _loadTtsInterrupt() async { try { if (!_storage.hasData('agent_tts_interrupt')) { final deviceName = getDeviceName(); // 这里用本地的 _findProductByName 而不是 AppConfig.getproduct(): // 后者只做「产品名包含设备名」单向匹配,本地这份是双向包含 + 兜底, // 设备名与产品名不完全一致时才匹配得上。 // (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。) final DBProduct? product = _findProductByName(deviceName); if (deviceName.isNotEmpty && product != null) { isTtsInterruptEnabled.value = !(product.iscontinuouschat ?? true); } else { isTtsInterruptEnabled.value = false; } } else { isTtsInterruptEnabled.value = _storage.read('agent_tts_interrupt') ?? false; } await AgentService.isInterrupt(isTtsInterruptEnabled.value); } catch (e) { Logger.e(_tag, '加载播报打断开关失败: $e'); isTtsInterruptEnabled.value = false; } } /// 切换“播报打断”开关 /// 同步保存到本地,并通知原生服务。 Future toggleTtsInterrupt() async { try { isTtsInterruptEnabled.toggle(); await _storage.write('agent_tts_interrupt', isTtsInterruptEnabled.value); await AgentService.isInterrupt(isTtsInterruptEnabled.value); } catch (e) { Logger.e(_tag, '保存播报打断偏好失败: $e'); } } /// 从本地存储加载最后连接的设备名称 void _loadLastConnectedDeviceName() { _lastConnectedDeviceName = _storage.read(_lastDeviceNameKey); Logger.d(_tag, '加载最后连接的设备名称: $_lastConnectedDeviceName'); } /// 保存最后连接的设备名称到本地存储 void _saveLastConnectedDeviceName(String deviceName) { _lastConnectedDeviceName = deviceName; _storage.write(_lastDeviceNameKey, deviceName); Logger.d(_tag, '保存最后连接的设备名称: $deviceName'); } /// 保存上一次连接设备的图片链接到本地存储 void _saveLastConnectedDeviceImage(String? imageUrl) { if (imageUrl != null && imageUrl.isNotEmpty) { _lastConnectedDeviceImage = imageUrl; _storage.write(_lastDeviceImageKey, imageUrl); Logger.d(_tag, '保存上一次连接设备的图片: $imageUrl'); } } Future refreshDeviceConnection() async { await _checkConnectionStatus(); // 如果设备已连接,主动刷新设备信息 if (_isFullyConnected.value) { try { Logger.d(_tag, '刷新设备信息'); await _bleManager.fetchDeviceInfo(); } catch (e) { Logger.e(_tag, '刷新设备信息失败: $e'); } } } void rescanDevices() { Get.toNamed(Routes.devices); } void openTranslation() { AgentService.interruptCurrentResponse(); Get.toNamed(Routes.translationMiddleware); } /// 翻译跳转:按 translationId 进入对应翻译模式 /// 逻辑参考 TranslationMiddlewareController.navigateToTranslationFeature /// translationId 取值:simultaneous / faceToFace / call / audioVideo /// 其中 call、audioVideo 依赖设备连接,未连接则提示并返回 void openTranslationFeature(String translationId) { if (IdVerifyGuard.blocked()) return; // 只有通话翻译必须依赖耳机链路;多媒体翻译走手机摄像头/麦克风,无需连接设备 final bool needBle = translationId == 'call'; // 三条链路任意一条连上都算已连接(杰理 / 蓝讯支架 / 恒玄耳机)。 // 之前只看 _isFullyConnected(杰理专用),恒玄耳机连上了照样被拦。 // 通话翻译还要求正处于通话中——耳机得先进通话模式(0xD1)才有双路音频 // ⚠️ 下面三条一律走 ToastThrottle 而不是裸 Get.snackbar: // 它们拦的都是「这个功能现在就是用不了」,而卡片只是灰着、点得动, // 用户会连点几下试——每点一下补一条一模一样的提示就把屏幕糊满了, // 而且 Get 的 snackbar 会排队,要等它们逐条超时才散得干净。 // 信息量只在第一条,后面全是噪音。 if (needBle && DeviceConnection.any && !DeviceConnection.inCall) { ToastThrottle.show(ToastThrottle.keyCallRequired, title: 'callRequiredTitle'.tr, message: 'callRequiredDesc'.tr, window: ToastThrottle.gateWindow); return; } if (needBle && !DeviceConnection.any) { ToastThrottle.show(ToastThrottle.keyConnectDevice, title: 'connectDevice'.tr, // 连接设备 message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 window: ToastThrottle.gateWindow); return; } if (DeviceConnection.isBlockedByCall(translationId)) { ToastThrottle.show(ToastThrottle.keyInCallBusy, title: 'inCallBusyTitle'.tr, message: 'inCallBusyDesc'.tr, window: ToastThrottle.gateWindow); return; } AgentService.interruptCurrentResponse(); Get.toNamed(Routes.translation, arguments: {'translationId': translationId}); } /// 打开通话录音:与首页卡片置灰条件统一,未连接设备则提示并返回 void openCallRecording() { if (IdVerifyGuard.blocked()) return; if (DeviceConnection.any && !DeviceConnection.inCall) { ToastThrottle.show(ToastThrottle.keyCallRequired, title: 'callRequiredTitle'.tr, message: 'callRequiredDesc'.tr, window: ToastThrottle.gateWindow); return; } if (!DeviceConnection.any) { ToastThrottle.show(ToastThrottle.keyConnectDevice, title: 'connectDevice'.tr, // 连接设备 message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 window: ToastThrottle.gateWindow); return; } Get.toNamed(Routes.recording, arguments: {'title': 'agentCallRecording', 'folder': 'call_recordings'}); } /// 打开现场录音。 /// /// 不再要求先连设备:连上了走设备录音通路,没连就用手机麦克风 /// (具体分支见 CallRecordingController._startRecording)。 void openFieldRecording() { if (DeviceConnection.isBlockedByCall('fieldRecording')) { ToastThrottle.show(ToastThrottle.keyInCallBusy, title: 'inCallBusyTitle'.tr, message: 'inCallBusyDesc'.tr, window: ToastThrottle.gateWindow); return; } if (IdVerifyGuard.blocked()) return; Get.toNamed(Routes.fieldRecording, arguments: { 'title': 'agentFieldRecording', 'folder': 'field_recordings', // 录完归档进语音记录助手,由它统一管理并上云 'archiveToMeeting': true, }); } /// 打开会议模块 /// 参数:无 /// 返回值:`Future` 无返回数据 /// 异常:不显式抛出;内部仅记录日志 /// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用) void openMeet() { if (IdVerifyGuard.blocked()) return; AgentService.interruptCurrentResponse(); Get.toNamed(Routes.meeting); } Future _checkConnectionStatus() async { try { // 直接问杰理 SDK 当前已连接设备 JieliDevice? sdkDevice; try { sdkDevice = await Jielihome.instance.connectedDevice(); } catch (e) { Logger.w(_tag, '查询杰理已连接设备失败: $e'); sdkDevice = null; } final hasBleConnection = sdkDevice != null && sdkDevice.address.isNotEmpty; bool wasConnected = _isFullyConnected.value; bool isNowConnected = hasBleConnection; if (_isFullyConnected.value != isNowConnected) { _isFullyConnected.value = isNowConnected; _isFullyConnected.refresh(); //update(); if (!wasConnected && isNowConnected) { // 连接成功后等待1秒再获取设备信息 Future.delayed(const Duration(seconds: 1), () async { await _bleManager.fetchDeviceInfo(); }); // 移除了连接成功后的电池信息刷新 // 连接成功后检查OTA升级 if (noviceGuideService.isHomeGuide) { Future.delayed(const Duration(seconds: 1), () { _checkOtaUpgrade(); }); } } // 移除了根据连接状态控制定时器的逻辑 } //update(); } catch (e) { Logger.e(_tag, '${'checkConnectionStatusFailed'.tr}: $e'); if (_isFullyConnected.value) { _isFullyConnected.value = false; _isFullyConnected.refresh(); // 移除了发生错误时停止定时器的逻辑 // 注意:不再重置OTA检查状态,因为现在由全局服务管理 //update(); } } } Color getConnectionStatusColor() { return _isFullyConnected.value ? Colors.green : Colors.grey; } String getConnectionStatusText() { return _isFullyConnected.value ? 'connected'.tr : 'unconnected'.tr; } bool get shouldShowDeviceInfo => currentDevice != null; String getDeviceName() { if (_isFullyConnected.value && currentDevice == null) { _checkConnectionStatus(); } final deviceName = currentDevice?.name; //final deviceName = '102'; if (deviceName != null && deviceName.isNotEmpty) { return deviceName; } final bleDeviceName = deviceInfo.value?.name; if (bleDeviceName != null && bleDeviceName.isNotEmpty) { return bleDeviceName; } if (!_isFullyConnected.value && _lastConnectedDeviceName != null && _lastConnectedDeviceName!.isNotEmpty) { return _lastConnectedDeviceName!; } return 'unknownEquipment'.tr; } String getSignalStrength() { if (!_isFullyConnected.value) { return 'zeroSignal'.tr; } final random = DateTime.now().millisecondsSinceEpoch % 3; switch (random) { case 0: return 'good'.tr; case 1: return 'fine'.tr; default: return 'excellent'.tr; } } String getDeviceId() { return deviceInfo.value?.address ?? currentDevice?.address ?? 'unknown'.tr; } String getBatteryText() { return deviceInfo.value?.batteryLevel != null && deviceInfo.value!.batteryLevel > 0 ? '${deviceInfo.value?.batteryLevel}%' : 'unknown'.tr; } String getEarbudsText() { if (deviceInfo.value == null || (deviceInfo.value!.leftEarBattery <= 0 && deviceInfo.value!.rightEarBattery <= 0 || deviceInfo.value!.caseBattery > 100)) { return 'unknown'.tr; } String leftStatus = deviceInfo.value!.leftEarBattery > 0 ? '${deviceInfo.value!.leftEarBattery}%' : 'N/A'; String rightStatus = deviceInfo.value!.rightEarBattery > 0 ? '${deviceInfo.value!.rightEarBattery}%' : 'N/A'; leftStatus = deviceInfo.value!.leftEarCharging ? '$leftStatus ⚡' : leftStatus; rightStatus = deviceInfo.value!.rightEarCharging ? '$rightStatus ⚡' : rightStatus; return '${'leftEar'.tr}: $leftStatus / ${'rightEar'.tr}: $rightStatus'; // 左耳: xxx / 右耳: xxx } String getCaseText() { if (deviceInfo.value == null || deviceInfo.value!.caseBattery <= 0 || deviceInfo.value!.caseBattery > 100) { return 'unknown'.tr; } String caseStatus = '${deviceInfo.value!.caseBattery}%'; if (deviceInfo.value!.caseCharging) { caseStatus = '$caseStatus ⚡'; } return '${'chargingCase'.tr}: $caseStatus'; // 充电仓: xxx } String getChargingText() { if (deviceInfo.value == null) { return 'unknown'.tr; } bool isCharging = deviceInfo.value!.leftEarCharging || deviceInfo.value!.rightEarCharging || deviceInfo.value!.caseCharging; return isCharging ? 'charging'.tr : 'notCharging'.tr; } /// 获取当前设备类型 /// 返回设备类型,如果无法获取则返回未知类型 DeviceType getCurrentDeviceType() { try { final deviceName = getDeviceName(); if (deviceName == 'unknownEquipment'.tr) { return DeviceType.unknown; } // 通过AppConfig获取产品信息 final product = AppConfig.getproduct(deviceName); if (product != null) { // 根据产品的devicetype字段返回对应的DeviceType switch (product.devicetype) { case 1: return DeviceType.classicBluetoothHeadset; case 2: return DeviceType.bleBluetoothDoubleHeadset; case 3: return DeviceType.bleBluetoothSingleHeadset; case 4: return DeviceType.smartVoiceRecorder; case 5: return DeviceType.smartEye; default: return DeviceType.unknown; } } return DeviceType.unknown; } catch (e) { Logger.e(_tag, '获取设备类型失败: $e'); return DeviceType.unknown; } } /// 判断当前设备是否为单端耳机 bool isSingleHeadset() { return getCurrentDeviceType() == DeviceType.bleBluetoothSingleHeadset || getCurrentDeviceType() == DeviceType.smartVoiceRecorder || getCurrentDeviceType() == DeviceType.smartEye; } String getVersionText() { return deviceInfo.value?.firmwareVersion.isNotEmpty == true ? deviceInfo.value!.firmwareVersion : 'unknown'.tr; } // 移除了电池信息定时刷新相关方法 /// 检查OTA固件升级 /// 在BLE连接成功后调用,检查是否需要升级 Future _checkOtaUpgrade() async { Logger.d(_tag, '触发OTA升级检查...'); // 打印当前OTA检查状态(用于调试) final status = _otaCheckService.getCheckStatus(); Logger.d(_tag, 'OTA检查状态: $status'); // 使用全局OTA检查服务执行检查 final success = await _otaCheckService.performOtaCheck(_isFullyConnected.value); if (success) { Logger.d(_tag, 'OTA检查已成功执行'); } else { Logger.d(_tag, 'OTA检查被跳过或执行失败'); } } /// 调试方法:获取OTA检查状态 /// 可以在开发阶段用于调试 Map getOtaCheckStatus() { return _otaCheckService.getCheckStatus(); } /// 获取当前设备的产品图片路径 /// 如果没有配置产品图片或获取失败,返回null使用默认图片 String? getCurrentDeviceImage() { try { final deviceName = getDeviceName(); if (deviceName == 'unknownEquipment'.tr) { Logger.d(_tag, '设备未知,使用默认图片'); return null; } // 通过AppConfig获取产品信息 if (!AppConfig.isInitialized()) { Logger.w(_tag, 'AppConfig未初始化,使用默认图片'); return null; } final product = AppConfig.getproduct(deviceName); if (product != null && product.productimage != null && product.productimage!.isNotEmpty) { Logger.d(_tag, '获取到设备产品图片: ${product.productimage}'); _saveLastConnectedDeviceImage(product.productimage); return product.productimage; } else { Logger.d(_tag, '设备 "$deviceName" 未配置产品图片,使用默认图片'); return null; } } catch (e) { Logger.e(_tag, '获取设备产品图片失败: $e'); return null; } } /// 获取设备图片的完整资源路径 /// 处理网络图片URL和本地资源路径 String getDeviceImagePath(bool isDarkMode) { final productImage = getCurrentDeviceImage(); if (productImage != null) { // 如果是网络图片URL if (productImage.startsWith('http://') || productImage.startsWith('https://')) { return productImage; } // 如果是本地资源路径,确保以assets/开头 else if (productImage.startsWith('assets/')) { return productImage; } // 如果只是文件名,添加assets/images/前缀 else { return 'assets/images/$productImage'; } } // 使用默认耳机图片 return 'assets/images/defaultPicture.png'; } /// 判断是否为网络图片 bool isNetworkImage(String imagePath) { return imagePath.startsWith('http://') || imagePath.startsWith('https://'); } }