import 'dart:async'; import 'dart:io'; import 'package:device_jieli/device_jieli.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; import '/core/utils/logger.dart'; import '/core/utils/permission_util.dart'; import '/data/models/device_category.dart'; import '/data/services/agent_module_defaults.dart'; import '/data/models/user_Info.dart'; import '/data/services/ble_manager.dart'; import '/data/services/network/api.dart'; import '/data/utils/jieli_adv_parser.dart'; import '/routes/app_routes.dart'; /// 设备页控制器:扫描杰理设备、绑定(首次)、连接、断开。 class DevicesController extends GetxController { static const String _tag = 'DevicesController'; late final BleManager _bleManager; RxList get scannedDevices => _bleManager.scannedDevices; RxBool get isScanning => _bleManager.isScanning; RxInt get connectionStateRx => _bleManager.connectionStateRx; String? get currentDeviceAddress => _bleManager.currentDeviceAddress; /// 正在连接的目标地址(用于 tile UI 反馈) final RxnString connectingAddress = RxnString(); /// 正在调用绑定接口的目标地址(用于 tile UI 反馈) final RxnString bindingAddress = RxnString(); /// 当 [User.instance.devices] 变化时触发 UI 重建。 /// 直接读 user 对象不响应式,所以加一个 tick。 final RxInt boundDevicesTick = 0.obs; @override void onInit() { super.onInit(); _bleManager = Get.find(); Future.microtask(startScan); } @override void onClose() { _bleManager.stopScan(); super.onClose(); } Future startScan() async { Logger.i(_tag, 'startScan 被调用'); final granted = await _ensureBluetoothPermission(); if (!granted) { Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); return; } // 从「选择设备」页带过来的品类:只扫该品类产品的 scanuuid; // 没带品类(或品类圈不出产品)时退回扫描全部 UUID final String? categoryId = Get.arguments is Map ? (Get.arguments as Map)['categoryId'] as String? : null; _categoryId = categoryId; final uuids = DeviceCategories.scanUuidsFor(categoryId); Logger.i(_tag, '扫描品类=${categoryId ?? "全部"}, uuid 数=${uuids.length}'); final ok = await _bleManager.startScan(uuids: uuids); if (!ok) { final err = _bleManager.lastScanError ?? 'unknown'; Logger.w(_tag, '扫描启动失败: $err'); Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); } } Future stopScan() async { await _bleManager.stopScan(); } /// 从「选择设备」页带过来的品类 id,连接成功后按它铺 AI 页默认功能 String? _categoryId; /// 当前用户已绑定的设备里是否包含这个 BLE 设备(按 MAC 匹配)。 bool isDeviceBound(JieliDevice device) { if (!User.isLoggedIn()) return false; return _findBoundDevice(device) != null; } UserDevice? _findBoundDevice(JieliDevice device) { final addr = device.address.replaceAll(':', '').toUpperCase(); final edr = (device.edrAddr ?? '').replaceAll(':', '').toUpperCase(); for (final d in User.instance.devices) { final dm = d.devicemac.replaceAll(':', '').toUpperCase(); if (dm.isEmpty) continue; if (dm == addr || (edr.isNotEmpty && dm == edr)) return d; } return null; } /// 点击"连接"或"绑定后自动连接"时调用。 Future connect(JieliDevice device) async { if (connectingAddress.value != null) { Logger.i(_tag, '连接进行中,忽略重复点击: ${device.address}'); return; } if (_bleManager.isConnected && _bleManager.currentDeviceAddress == device.address) { Logger.i(_tag, '设备已处于连接状态,直接进入首页: ${device.address}'); Get.offAllNamed(Routes.home); return; } Logger.i(_tag, '连接设备: ${device.name} ${device.address}'); connectingAddress.value = device.address; await _bleManager.stopScan(); final completer = Completer(); bool gotConnectionOk = false; final evtSub = Jielihome.instance.events.listen((evt) { if (evt is ConnectionStateEvent) { if (evt.address != device.address) return; if (evt.state == ConnectionStateEvent.connectionOk) { gotConnectionOk = true; } else if (evt.state == ConnectionStateEvent.connectionDisconnect) { if (!completer.isCompleted) completer.complete(false); } } else if (evt is RcspInitEvent) { if (evt.address != device.address) return; if (evt.success && gotConnectionOk) { if (!completer.isCompleted) completer.complete(true); } else if (!evt.success) { if (!completer.isCompleted) completer.complete(false); } } }); _showConnectingDialog(device); try { await _bleManager.connectDevice(device); final ok = await completer.future .timeout(const Duration(seconds: 20), onTimeout: () => false); if (Get.isDialogOpen ?? false) Get.back(); if (ok) { // 首次连上该品类设备 → AI 页铺开这个品类的默认功能编排 AgentDefaults.onJieliConnected(_categoryId); Get.offAllNamed(Routes.home); } else { Get.snackbar('error'.tr, 'connectionFailure'.tr); } } catch (e) { Logger.e(_tag, '连接异常: $e'); if (Get.isDialogOpen ?? false) Get.back(); Get.snackbar('error'.tr, 'connectionFailure'.tr); } finally { await evtSub.cancel(); connectingAddress.value = null; } } /// 点击"绑定"按钮:解析广播 → 调用 binddevice → 成功后自动 connect。 Future bindAndConnect(JieliDevice device) async { if (bindingAddress.value != null || connectingAddress.value != null) { Logger.i(_tag, '已有绑定/连接进行中,忽略重复点击: ${device.address}'); return; } if (!User.isLoggedIn()) { Get.snackbar('tip'.tr, 'pleaseLoginFirst'.tr); return; } final info = JieliAdvParser.parse(device); if (info == null || info.code.isEmpty) { Logger.w(_tag, '无法从广播包解析绑定信息: ${device.address}'); Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); return; } bindingAddress.value = device.address; try { final response = await Api.binddevice({ 'pid': info.pid, 'code': info.code, 'devicename': device.name, 'devicemac': device.address, }); Logger.i(_tag, '绑定接口返回: $response'); if (response == null) { Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); Get.offAllNamed(Routes.devices); return; } final deviceJson = response['device']; if (deviceJson == null || deviceJson == '') { Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); Get.offAllNamed(Routes.devices); return; } final newUserDevice = UserDevice.fromJson(deviceJson as Map); User.instance.addUserDevice(newUserDevice); boundDevicesTick.value++; // 首次绑定奖励 if (response['isgiveaway'] == true) { _applyGiveaway(response); } // 绑定成功后自动连接进入主界面 await connect(device); } catch (e) { Logger.e(_tag, '绑定异常: $e'); Get.snackbar('error'.tr, 'deviceUnauthorized'.tr); } finally { bindingAddress.value = null; } } void _applyGiveaway(Map response) { User.instance.viplv = (response['viplv'] as num?)?.toInt() ?? User.instance.viplv; User.instance.vipexptime = (response['vipexptime'] as num?)?.toInt() ?? User.instance.vipexptime; User.instance.aichatintegral = (response['aichatintegral'] as num?)?.toInt() ?? User.instance.aichatintegral; User.instance.aichattotalintegral = (response['aichattotalintegral'] as num?)?.toInt() ?? User.instance.aichattotalintegral; User.instance.tradeintegral = (response['tradeintegral'] as num?)?.toInt() ?? User.instance.tradeintegral; User.instance.tradetotalintegral = (response['tradetotalintegral'] as num?)?.toInt() ?? User.instance.tradetotalintegral; User.instance.meetintegral = (response['meetintegral'] as num?)?.toInt() ?? User.instance.meetintegral; User.instance.meettotalintegral = (response['meettotalintegral'] as num?)?.toInt() ?? User.instance.meettotalintegral; Get.dialog( AlertDialog( title: Text('bindRewardTitle'.tr), content: Text('bindRewardSimpleDesc'.tr), actions: [ TextButton( onPressed: () => Get.back(), child: Text('ok'.tr), ), ], ), barrierDismissible: true, ); } void _showConnectingDialog(JieliDevice device) { Get.dialog( PopScope( canPop: false, child: Dialog( backgroundColor: Colors.transparent, elevation: 0, insetPadding: EdgeInsets.symmetric(horizontal: 40.w), child: Container( padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h), decoration: BoxDecoration( color: Get.isDarkMode ? const Color(0xFF1C1C1E) : Colors.white, borderRadius: BorderRadius.circular(16.r), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 44.w, height: 44.w, child: const CircularProgressIndicator( strokeWidth: 3, valueColor: AlwaysStoppedAnimation(Color(0xFF0066CC)), ), ), SizedBox(height: 18.h), Text( 'connectingDevice'.tr, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: Get.isDarkMode ? Colors.white : Colors.black87, ), ), SizedBox(height: 6.h), Text( device.name.isEmpty ? device.address : device.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, color: Get.isDarkMode ? Colors.white54 : Colors.black54, ), ), ], ), ), ), ), barrierDismissible: false, ); } Future disconnect() async { await _bleManager.disconnect(); } bool isConnected(JieliDevice device) { return _bleManager.isConnected && _bleManager.currentDeviceAddress == device.address; } /// 扫描设备需要的权限(Android) static const List _scanPermissions = [ Permission.bluetoothScan, Permission.bluetoothConnect, Permission.locationWhenInUse, ]; /// 权限被拒且仍在 48h 冷却期 —— 视图据此把"扫描设备"入口置灰 bool get isBluetoothPermissionCoolingDown { if (Platform.isIOS) return false; return _scanPermissions .any((p) => PermissionUtil.instance.isDenialCoolingDown(p)); } Future _ensureBluetoothPermission() async { // iOS: CoreBluetooth 首次扫描时由系统自动弹出蓝牙权限对话框 // (依赖 Info.plist 的 NSBluetoothAlwaysUsageDescription), // permission_handler 的 bluetoothScan/bluetoothConnect 是 Android 专属, // 在 iOS 上调用永远返回 denied,会误拦截扫描。 if (Platform.isIOS) return true; try { // 合规(场景 1/2):走统一网关,用户拒绝一次后 48 小时内不再申请, // 返回 false 时调用方置灰扫描入口,不得再弹窗或跳设置页。 return await PermissionUtil.instance.requestPermissions( permissions: _scanPermissions, permissionName: 'bluetooth'.tr, explanationText: 'bluetoothScanPermissionDesc'.tr, ); } catch (e) { Logger.error('请求蓝牙权限失败: $e'); return false; } } }