diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index a6bf4f86b..4a58d0102 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -836,6 +836,14 @@ const Map enUS = { "confirmUnbindMessage": "Are you sure you want to unbind device \"{deviceName}\"?", "confirmUnbindAction": "Confirm Unbind", + "pleaseGrantBluetoothPermission": "Please grant Bluetooth permission to scan", + "startScanFailed": "Failed to start scan", + "pleaseLoginFirst": "Please log in first", + "deviceUnauthorized": "Device not authorized, please retry", + "addDevice": "Add Device", + "boundDevices": "Bound Devices", + "currentConnection": "Current Connection", + "binding": "Binding...", // Agent模块 - 主界面 "Lily": "Lily", diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 03d97f343..289d0e75e 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -772,6 +772,14 @@ const Map zhCN = { "confirmUnbind": "确认解绑", "confirmUnbindMessage": "确定要解绑设备 \"{deviceName}\" 吗?", "confirmUnbindAction": "确认解绑", + "pleaseGrantBluetoothPermission": "请授予蓝牙权限以扫描设备", + "startScanFailed": "启动扫描失败", + "pleaseLoginFirst": "请先登录", + "deviceUnauthorized": "设备未认证,请重试", + "addDevice": "添加设备", + "boundDevices": "已绑定设备", + "currentConnection": "当前连接", + "binding": "绑定中...", // Agent模块 - 主界面 "Lily": "Lily", diff --git a/lib/data/utils/jieli_adv_parser.dart b/lib/data/utils/jieli_adv_parser.dart new file mode 100644 index 000000000..6dff23225 --- /dev/null +++ b/lib/data/utils/jieli_adv_parser.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:device_jieli/device_jieli.dart'; + +import '../../core/utils/logger.dart'; + +/// 杰理设备广播包解析结果:从扫描到的 [JieliDevice.rawAdv] 中提取出 +/// 服务端绑定接口需要的 PID / MAC / 设备授权码 (code)。 +class JieliBindInfo { + /// 产品 ID(小端字节解析) + final int pid; + + /// 设备真实 MAC(XOR 0x5A 解码后),格式 "AA:BB:CC:DD:EE:FF" + final String mac; + + /// 设备授权码 (license / SN),hex 大写 + final String code; + + /// 解析得到的 PID 是否与扫描结果上的 BLE/EDR 地址一致 + /// (仅用于日志,不强制要求一致) + final bool macMatchesAddress; + + const JieliBindInfo({ + required this.pid, + required this.mac, + required this.code, + required this.macMatchesAddress, + }); + + @override + String toString() => + 'JieliBindInfo(pid=0x${pid.toRadixString(16).toUpperCase()}, mac=$mac, code=$code, match=$macMatchesAddress)'; +} + +/// 杰理 BLE 广播解析。 +/// +/// 字节布局(与旧版一致,基于扫到的 raw scanRecord 字节): +/// Android: +/// - bytes[6..7] → PID +/// - bytes[9..14] → MAC(每字节 XOR 0x5A) +/// - bytes[15..24] → 授权码 code +/// iOS: +/// - bytes[4..5] → PID +/// - bytes[7..12] → MAC(每字节 XOR 0x5A) +/// - bytes[13..22] → 授权码 code +class JieliAdvParser { + static const String _tag = 'JieliAdvParser'; + + /// 用 [device.rawAdv] 直接解析。若 raw 数据不完整或 device 未携带广播包,返回 null。 + static JieliBindInfo? parse(JieliDevice device) { + final raw = device.rawAdv; + if (raw == null || raw.isEmpty) { + Logger.w(_tag, '设备 ${device.address} 没有 rawAdv,无法解析绑定信息'); + return null; + } + return parseHex(raw, deviceAddress: device.address); + } + + /// 直接对 hex 字符串解析,便于测试。 + static JieliBindInfo? parseHex(String hex, {String? deviceAddress}) { + final bytes = _hexToBytes(hex); + if (bytes == null) { + Logger.w(_tag, 'rawAdv 不是合法 hex: $hex'); + return null; + } + + final int pidStart; + final int pidEnd; + final int macStart; + final int macEnd; + final int codeStart; + final int codeEnd; + + if (Platform.isIOS) { + pidStart = 4; + pidEnd = 5; + macStart = 7; + macEnd = 12; + codeStart = 13; + codeEnd = 22; + } else { + // Android 默认;其他平台沿用 Android 偏移。 + pidStart = 6; + pidEnd = 7; + macStart = 9; + macEnd = 14; + codeStart = 15; + codeEnd = 24; + } + + if (bytes.length <= codeEnd) { + Logger.w(_tag, + 'rawAdv 长度不足 ${codeEnd + 1} 字节(实际 ${bytes.length}),跳过解析'); + return null; + } + + final pid = _bytesToIntBigEndian(bytes, pidStart, pidEnd); + final mac = _decodeMac(bytes, macStart, macEnd); + final code = _bytesToHexReversed(bytes, codeStart, codeEnd); + + bool match = false; + if (deviceAddress != null && deviceAddress.isNotEmpty) { + final a = deviceAddress.replaceAll(':', '').toUpperCase(); + final b = mac.replaceAll(':', '').toUpperCase(); + match = a == b; + } + + final info = JieliBindInfo( + pid: pid, + mac: mac, + code: code, + macMatchesAddress: match, + ); + Logger.i(_tag, '解析广播: $info (deviceAddress=$deviceAddress)'); + return info; + } + + static List? _hexToBytes(String hex) { + final s = hex.startsWith('0x') || hex.startsWith('0X') ? hex.substring(2) : hex; + if (s.length.isOdd) return null; + final out = []; + for (int i = 0; i < s.length; i += 2) { + final v = int.tryParse(s.substring(i, i + 2), radix: 16); + if (v == null) return null; + out.add(v); + } + return out; + } + + static int _bytesToIntBigEndian(List bytes, int start, int end) { + int result = 0; + for (int i = start; i <= end; i++) { + result = (result << 8) | (bytes[i] & 0xFF); + } + return result; + } + + static String _bytesToHexReversed(List bytes, int start, int end) { + final slice = bytes.sublist(start, end + 1); + return slice + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + + static String _decodeMac(List bytes, int start, int end) { + final out = []; + for (int i = start; i <= end; i++) { + final decoded = bytes[i] ^ 0x5A; + out.add(decoded.toRadixString(16).padLeft(2, '0').toUpperCase()); + } + return out.join(':'); + } +} diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index a106cafa0..89f82ed95 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -8,26 +8,34 @@ import 'package:permission_handler/permission_handler.dart'; import '/core/utils/logger.dart'; import '/data/models/appconfig.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; - // 扫描结果(直接绑定到 BleManager 内部 Rx 列表) RxList get scannedDevices => _bleManager.scannedDevices; RxBool get isScanning => _bleManager.isScanning; - // 当前连接地址(响应式) RxInt get connectionStateRx => _bleManager.connectionStateRx; - String? get currentDeviceAddress => _bleManager.currentDeviceAddress; - // 正在连接的目标地址(用于 tile UI 反馈) + /// 正在连接的目标地址(用于 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(); @@ -42,20 +50,18 @@ class DevicesController extends GetxController { } Future startScan() async { - print('[BLE] [SCAN] DevicesController.startScan 被调用'); + Logger.i(_tag, 'startScan 被调用'); final granted = await _ensureBluetoothPermission(); - print('[BLE] [SCAN] 权限申请结果: granted=$granted'); if (!granted) { Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); return; } - // 设备页是通用绑定入口,扫描白名单 = AppConfig 下发的所有产品 scanuuid final uuids = AppConfig.isInitialized() ? AppConfig.allproductUuids() : []; final ok = await _bleManager.startScan(uuids: uuids); if (!ok) { final err = _bleManager.lastScanError ?? 'unknown'; - print('[BLE] [SCAN] 扫描启动失败: $err'); + Logger.w(_tag, '扫描启动失败: $err'); Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); } } @@ -64,25 +70,40 @@ class DevicesController extends GetxController { await _bleManager.stopScan(); } + /// 当前用户已绑定的设备里是否包含这个 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.info('连接进行中,忽略重复点击: ${device.address}'); + Logger.i(_tag, '连接进行中,忽略重复点击: ${device.address}'); return; } - // 已连上同一台设备 → 直接进首页,避免卡在「连接中」对话框 if (_bleManager.isConnected && _bleManager.currentDeviceAddress == device.address) { - Logger.info('设备已处于连接状态,直接进入首页: ${device.address}'); + Logger.i(_tag, '设备已处于连接状态,直接进入首页: ${device.address}'); Get.offAllNamed(Routes.home); return; } - Logger.info('连接设备: ${device.name} ${device.address}'); + Logger.i(_tag, '连接设备: ${device.name} ${device.address}'); connectingAddress.value = device.address; await _bleManager.stopScan(); - // 必须 BLE 连接 OK + RCSP init 成功 才算「真正连上」。 - // 任一环节失败立即判失败,避免 dialog 关掉之后又被断开。 final completer = Completer(); bool gotConnectionOk = false; @@ -119,7 +140,7 @@ class DevicesController extends GetxController { Get.snackbar('error'.tr, 'connectionFailure'.tr); } } catch (e) { - Logger.error('连接异常: $e'); + Logger.e(_tag, '连接异常: $e'); if (Get.isDialogOpen ?? false) Get.back(); Get.snackbar('error'.tr, 'connectionFailure'.tr); } finally { @@ -128,6 +149,101 @@ class DevicesController extends GetxController { } } + /// 点击"绑定"按钮:解析广播 → 调用 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( @@ -195,15 +311,11 @@ class DevicesController extends GetxController { Future _ensureBluetoothPermission() async { try { - // Android 12+:BLUETOOTH_SCAN 已声明 neverForLocation,所以不强制要求定位。 - // 但实际不少 ROM 仍要求定位才能扫到 BLE 设备 —— 同时申请,谁拒绝就报错。 final results = await [ Permission.bluetoothScan, Permission.bluetoothConnect, Permission.locationWhenInUse, ].request(); - print('[BLE] [SCAN] 权限申请详情: $results'); - // 至少 scan + connect 必须授权;location 缺失警告但不一定阻塞 final scanOk = results[Permission.bluetoothScan]?.isGranted ?? false; final connectOk = results[Permission.bluetoothConnect]?.isGranted ?? false; return scanOk && connectOk; diff --git a/lib/modules/devices/views/devices_view.dart b/lib/modules/devices/views/devices_view.dart index 9e6b8b5e4..a870b0587 100644 --- a/lib/modules/devices/views/devices_view.dart +++ b/lib/modules/devices/views/devices_view.dart @@ -267,18 +267,25 @@ class _DevicesViewState extends State Widget _buildDeviceTile(JieliDevice device, bool isDark) { return Obx(() { - // 订阅连接状态、当前连接地址、正在连接的目标,让 tile 实时刷新 + // 订阅连接状态、当前连接地址、正在连接/绑定的目标 + 已绑定列表 tick,让 tile 实时刷新 controller.connectionStateRx.value; + controller.boundDevicesTick.value; final connected = controller.isConnected(device); final connectingAddr = controller.connectingAddress.value; + final bindingAddr = controller.bindingAddress.value; final connecting = connectingAddr == device.address; - final disabled = connectingAddr != null && !connecting; + final binding = bindingAddr == device.address; + final disabled = (connectingAddr != null && !connecting) || + (bindingAddr != null && !binding); + final bound = controller.isDeviceBound(device); return _buildDeviceTileContent( device, isDark, connected: connected, connecting: connecting, + binding: binding, disabled: disabled, + bound: bound, ); }); } @@ -288,15 +295,25 @@ class _DevicesViewState extends State bool isDark, { required bool connected, required bool connecting, + required bool binding, required bool disabled, + required bool bound, }) { const connectedColor = Color(0xFF22C55E); - final canTap = !connecting && !disabled; + final canTap = !connecting && !binding && !disabled; + void onPrimaryTap() { + if (bound) { + controller.connect(device); + } else { + controller.bindAndConnect(device); + } + } + return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(12.r), - onTap: canTap ? () => controller.connect(device) : null, + onTap: canTap ? onPrimaryTap : null, child: Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), decoration: BoxDecoration( @@ -395,7 +412,7 @@ class _DevicesViewState extends State style: TextStyle(fontSize: 11.sp, color: Colors.red), ), ) - else if (connecting) + else if (connecting || binding) SizedBox( width: 18.w, height: 18.w, @@ -407,10 +424,11 @@ class _DevicesViewState extends State ) else ElevatedButton( - onPressed: - disabled ? null : () => controller.connect(device), + onPressed: disabled ? null : onPrimaryTap, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0066CC), + backgroundColor: bound + ? const Color(0xFF0066CC) + : const Color(0xFF8B5CF6), foregroundColor: Colors.white, minimumSize: Size(0, 30.h), padding: EdgeInsets.symmetric(horizontal: 12.w), @@ -420,7 +438,7 @@ class _DevicesViewState extends State elevation: 0, ), child: Text( - 'connect'.tr, + bound ? 'connect'.tr : 'bind'.tr, style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w600, diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 866c4b868..7831100ad 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -477,7 +477,7 @@ class HomeView extends GetView { Widget _buildDeviceButton(bool isDarkMode) { return ElevatedButton( onPressed: () { - Get.toNamed(Routes.devices); // 跳转到设备管理页面 + Get.toNamed(Routes.myDevices); // 跳转到我的设备列表 }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, diff --git a/lib/modules/meeting/views/meeting_mine_view.dart b/lib/modules/meeting/views/meeting_mine_view.dart index 12e7f3605..6cf379d44 100644 --- a/lib/modules/meeting/views/meeting_mine_view.dart +++ b/lib/modules/meeting/views/meeting_mine_view.dart @@ -99,7 +99,7 @@ class _MeetingMineViewState extends State Icons.settings_remote_outlined, 'myVoitransDevice'.tr, // 对应中文:我的VOITRANS设备 onTap: () { - Get.toNamed(Routes.devices); + Get.toNamed(Routes.myDevices); }, ), ]), diff --git a/lib/modules/my_devices/bindings/my_devices_binding.dart b/lib/modules/my_devices/bindings/my_devices_binding.dart new file mode 100644 index 000000000..276864d20 --- /dev/null +++ b/lib/modules/my_devices/bindings/my_devices_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; + +import '../controllers/my_devices_controller.dart'; + +class MyDevicesBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MyDevicesController()); + } +} diff --git a/lib/modules/my_devices/controllers/my_devices_controller.dart b/lib/modules/my_devices/controllers/my_devices_controller.dart new file mode 100644 index 000000000..53fe1d12a --- /dev/null +++ b/lib/modules/my_devices/controllers/my_devices_controller.dart @@ -0,0 +1,96 @@ +import 'package:get/get.dart'; + +import '/core/utils/logger.dart'; +import '/data/models/user_Info.dart'; +import '/data/services/ble_manager.dart'; +import '/data/services/network/api.dart'; +import '/routes/app_routes.dart'; + +/// 用户已绑定设备管理页:拉取用户绑定列表、解绑。 +class MyDevicesController extends GetxController { + static const String _tag = 'MyDevicesController'; + + late final BleManager _bleManager; + + /// 按设备类型分组的已绑定设备 + final RxMap> devicesByType = + >{}.obs; + + final RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + _bleManager = Get.find(); + _refreshFromUser(); + Future.microtask(loadDevices); + } + + /// 从服务端拉取最新的设备列表,并刷新 [User.instance.devices]。 + Future loadDevices() async { + if (!User.isLoggedIn()) return; + isLoading.value = true; + try { + final response = await Api.getdevices(); + if (response != null) { + final list = response['devices'] as List?; + if (list != null) { + User.instance.devices = list + .map((e) => UserDevice.fromJson(e as Map)) + .toList(); + } else { + User.instance.devices = []; + } + Logger.i(_tag, '设备列表刷新完成: ${User.instance.devices.length} 条'); + } + } catch (e) { + Logger.e(_tag, '获取设备列表失败: $e'); + } finally { + _refreshFromUser(); + isLoading.value = false; + } + } + + /// 解绑设备:调接口成功后从本地列表移除;如果是当前连接的设备,断开并跳到扫描页。 + Future unbindDevice(UserDevice device) async { + try { + final response = await Api.unbinddevice({'id': device.id}); + Logger.i(_tag, '解绑接口返回: $response'); + if (response == null) return; + + User.instance.removeUserDevice(device.id); + _refreshFromUser(); + + if (device.devicemac.isNotEmpty && + _bleManager.currentDeviceAddress == device.devicemac) { + Logger.i(_tag, '解绑的设备就是当前连接,断开并回到扫描页'); + await _bleManager.disconnect(); + await Future.delayed(const Duration(milliseconds: 300)); + Get.offAllNamed(Routes.devices); + } + } catch (e) { + Logger.e(_tag, '解绑异常: $e'); + } + } + + void _refreshFromUser() { + final grouped = >{}; + if (User.isLoggedIn()) { + for (final d in User.instance.devices) { + grouped.putIfAbsent(d.devicetype, () => []).add(d); + } + } + devicesByType.value = grouped; + devicesByType.refresh(); + } + + bool isConnected(UserDevice device) { + if (device.devicemac.isEmpty) return false; + return _bleManager.isConnected && + _bleManager.currentDeviceAddress == device.devicemac; + } + + void goToScan() { + Get.toNamed(Routes.devices); + } +} diff --git a/lib/modules/my_devices/views/my_devices_view.dart b/lib/modules/my_devices/views/my_devices_view.dart new file mode 100644 index 000000000..7fc0ad808 --- /dev/null +++ b/lib/modules/my_devices/views/my_devices_view.dart @@ -0,0 +1,581 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '/data/models/user_Info.dart'; +import '../controllers/my_devices_controller.dart'; + +enum _MyDeviceCategory { + unknown(0), + classicHeadset(1), + bleHeadset(2), + voiceRecorder(4), + smartEye(5); + + const _MyDeviceCategory(this.value); + final int value; + + static _MyDeviceCategory fromValue(int value) => + _MyDeviceCategory.values.firstWhere( + (c) => c.value == value, + orElse: () => _MyDeviceCategory.unknown, + ); + + String get displayName { + switch (this) { + case _MyDeviceCategory.unknown: + return 'deviceTypeUnknown'.tr; + case _MyDeviceCategory.classicHeadset: + return 'deviceTypeClassicBluetoothHeadset'.tr; + case _MyDeviceCategory.bleHeadset: + return 'deviceTypeBleBluetoothHeadset'.tr; + case _MyDeviceCategory.voiceRecorder: + return 'deviceTypeSmartVoiceRecorder'.tr; + case _MyDeviceCategory.smartEye: + return 'deviceTypeSmartEye'.tr; + } + } + + IconData get icon { + switch (this) { + case _MyDeviceCategory.classicHeadset: + case _MyDeviceCategory.bleHeadset: + return Icons.headphones; + case _MyDeviceCategory.voiceRecorder: + return Icons.mic; + case _MyDeviceCategory.smartEye: + return Icons.visibility; + default: + return Icons.device_unknown; + } + } +} + +class MyDevicesView extends GetView { + const MyDevicesView({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF0A0A0F) : const Color(0xFFF5F6FA); + final topBg = isDark ? const Color(0xFF0F1018) : const Color(0xFFF0F1F6); + + return Scaffold( + backgroundColor: bg, + body: Column( + children: [ + _buildTopArea(context, isDark, bg), + Expanded(child: _buildDevicesList(isDark, topBg)), + _buildAddDeviceButton(isDark), + ], + ), + ); + } + + Widget _buildTopArea(BuildContext context, bool isDark, Color bg) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 8.h, + left: 20.w, + right: 20.w, + bottom: 20.h, + ), + decoration: BoxDecoration(color: bg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04), + ), + child: Icon( + Icons.arrow_back_ios_new, + size: 18.sp, + color: isDark ? Colors.white : const Color(0xFF222222), + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Text( + 'deviceManagement'.tr, + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w800, + color: isDark ? Colors.white : const Color(0xFF111111), + letterSpacing: -0.5, + ), + ), + ), + Obx(() => controller.isLoading.value + ? SizedBox( + width: 18.w, + height: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + onPressed: controller.loadDevices, + icon: Icon( + Icons.refresh, + size: 20.sp, + color: isDark ? Colors.white70 : Colors.black54, + ), + )), + ], + ), + SizedBox(height: 16.h), + Obx(() { + final total = controller.devicesByType.values + .fold(0, (sum, list) => sum + list.length); + final connected = controller.devicesByType.values + .expand((list) => list) + .where(controller.isConnected) + .length; + return Row( + children: [ + _buildSummaryCard( + number: '$total', + label: 'boundDevices'.tr, + isActive: true, + isDark: isDark), + SizedBox(width: 12.w), + _buildSummaryCard( + number: '$connected', + label: 'currentConnection'.tr, + isActive: false, + isDark: isDark), + ], + ); + }), + ], + ), + ); + } + + Widget _buildSummaryCard({ + required String number, + required String label, + required bool isActive, + required bool isDark, + }) { + final activeGradient = isDark + ? [ + const Color(0xFF6366F1).withValues(alpha: 0.15), + const Color(0xFF8B5CF6).withValues(alpha: 0.1), + ] + : [const Color(0xFFEEF2FF), const Color(0xFFF5F3FF)]; + + return Expanded( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.r), + gradient: isActive ? LinearGradient(colors: activeGradient) : null, + color: isActive + ? null + : (isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.white), + border: Border.all( + color: isActive + ? const Color(0xFF6366F1).withValues(alpha: 0.18) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04)), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + number, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w700, + color: isActive + ? (isDark + ? const Color(0xFFA5B4FC) + : const Color(0xFF6366F1)) + : (isDark ? Colors.white : const Color(0xFF222222)), + ), + ), + SizedBox(height: 2.h), + Text( + label, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w500, + color: isDark + ? Colors.white.withValues(alpha: 0.5) + : const Color(0xFF999999), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDevicesList(bool isDark, Color topBg) { + return Obx(() { + final groups = controller.devicesByType; + if (groups.isEmpty) { + return _buildEmptyState(isDark); + } + return ListView( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + children: groups.entries.map((entry) { + final category = _MyDeviceCategory.fromValue(entry.key); + final list = entry.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 8.w, bottom: 10.h), + child: Text( + category.displayName.toUpperCase(), + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isDark + ? Colors.white.withValues(alpha: 0.25) + : const Color(0xFFAAAAAA), + letterSpacing: 1.2, + ), + ), + ), + ...list.map((d) => _buildDeviceCard(d, category, isDark)), + SizedBox(height: 14.h), + ], + ); + }).toList(), + ); + }); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.devices_other, + size: 72.sp, + color: isDark ? Colors.white54 : Colors.grey[400], + ), + SizedBox(height: 16.h), + Text( + 'noDevicesBound'.tr, + style: TextStyle( + fontSize: 16.sp, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + SizedBox(height: 8.h), + Text( + 'pleaseBindDeviceFirst'.tr, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.sp, + color: isDark ? Colors.white54 : Colors.grey[500], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDeviceCard( + UserDevice device, _MyDeviceCategory category, bool isDark) { + final connected = controller.isConnected(device); + const connectedColor = Color(0xFF22C55E); + + return Container( + margin: EdgeInsets.only(bottom: 10.h), + padding: EdgeInsets.all(18.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.r), + color: isDark + ? (connected + ? connectedColor.withValues(alpha: 0.04) + : Colors.white.withValues(alpha: 0.03)) + : Colors.white, + border: Border.all( + color: connected + ? connectedColor.withValues(alpha: 0.25) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)), + ), + boxShadow: isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.r), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: connected + ? [connectedColor, const Color(0xFF16A34A)] + : [const Color(0xFF6366F1), const Color(0xFF8B5CF6)], + ), + ), + child: Icon( + category.icon, + color: Colors.white, + size: 22.sp, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + device.devicename.isNotEmpty + ? device.devicename + : 'deviceTypeUnknown'.tr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + color: isDark + ? Colors.white + : const Color(0xFF1A1A2E), + ), + ), + ), + if (connected) ...[ + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.r), + color: connectedColor.withValues(alpha: 0.12), + ), + child: Text( + 'connected'.tr, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w600, + color: connectedColor, + ), + ), + ), + ], + ], + ), + SizedBox(height: 3.h), + Text( + device.devicemac.isNotEmpty + ? device.devicemac + : 'noMacAddress'.tr, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w500, + color: isDark + ? Colors.white.withValues(alpha: 0.3) + : const Color(0xFF999999), + fontFamily: 'Menlo', + letterSpacing: 0.5, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => _showUnbindDialog(device, isDark), + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.r), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: Text( + 'unbind'.tr, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isDark + ? Colors.white.withValues(alpha: 0.4) + : const Color(0xFF999999), + ), + ), + ), + ), + ], + ), + if (device.license.isNotEmpty || device.cmei.isNotEmpty) ...[ + SizedBox(height: 14.h), + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.r), + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : const Color(0xFFF7F7FA), + ), + child: Column( + children: [ + if (device.license.isNotEmpty) + _buildFieldRow( + label: 'licenseCode'.tr, + value: device.license, + isDark: isDark), + if (device.license.isNotEmpty && device.cmei.isNotEmpty) + SizedBox(height: 8.h), + if (device.cmei.isNotEmpty) + _buildFieldRow( + label: 'CMEI', value: device.cmei, isDark: isDark), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildFieldRow({ + required String label, + required String value, + required bool isDark, + }) { + return Row( + children: [ + SizedBox( + width: 50.w, + child: Text( + label, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w600, + color: isDark + ? Colors.white.withValues(alpha: 0.25) + : const Color(0xFFBBBBBB), + letterSpacing: 0.5, + ), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w500, + color: isDark + ? Colors.white.withValues(alpha: 0.5) + : const Color(0xFF666666), + fontFamily: 'Menlo', + letterSpacing: 0.3, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildAddDeviceButton(bool isDark) { + return Padding( + padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 20.h), + child: SizedBox( + width: double.infinity, + height: 48.h, + child: ElevatedButton.icon( + onPressed: controller.goToScan, + icon: Icon(Icons.add, size: 18.sp), + label: Text( + 'addDevice'.tr, + style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0066CC), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.r), + ), + elevation: 0, + ), + ), + ), + ); + } + + void _showUnbindDialog(UserDevice device, bool isDark) { + Get.dialog( + AlertDialog( + backgroundColor: + isDark ? const Color(0xFF1C1C1E) : Colors.white, + title: Text( + 'confirmUnbind'.tr, + style: TextStyle(color: isDark ? Colors.white : Colors.black87), + ), + content: Text( + 'confirmUnbindMessage' + .tr + .replaceAll('{deviceName}', device.devicename), + style: + TextStyle(color: isDark ? Colors.white70 : Colors.black54), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'cancel'.tr, + style: TextStyle( + color: isDark ? Colors.white70 : Colors.grey[600]), + ), + ), + TextButton( + onPressed: () { + Get.back(); + controller.unbindDevice(device); + }, + child: Text( + 'confirmUnbindAction'.tr, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 3b04d550e..5f1107f4b 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -498,7 +498,7 @@ class SettingsView extends GetView { iconColor: isDarkMode ? Colors.red[300]! : Colors.red[600]!, titleColor: isDarkMode ? Colors.white : null, subtitleColor: isDarkMode ? Colors.white70 : null, - onTap: () => Get.toNamed(Routes.devices), + onTap: () => Get.toNamed(Routes.myDevices), isDarkMode: isDarkMode, ), Divider( diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 0684bec66..d21eb2621 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -9,6 +9,8 @@ import '../modules/ble/views/ble_view.dart'; import '../../modules/devices/bindings/devices_binding.dart'; import '../../modules/devices/views/devices_view.dart'; +import '../modules/my_devices/bindings/my_devices_binding.dart'; +import '../modules/my_devices/views/my_devices_view.dart'; import '../modules/feedback/bindings/feedback_bindings.dart'; import '../modules/feedback/views/feedback_view.dart'; import '../modules/home/views/home_view.dart'; @@ -317,6 +319,13 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.myDevices, + page: () => const MyDevicesView(), + binding: MyDevicesBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), GetPage( name: Routes.ble, page: () => const BleView(), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 3e66edb56..d2384de59 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -64,7 +64,8 @@ abstract class Routes { static const mobileElf = '/mobile_elf'; // 设备管理 - static const devices = '/devices'; + static const devices = '/devices'; // 扫描页:发现/绑定/连接新设备 + static const myDevices = '/my_devices'; // 用户已绑定设备列表 // ble设备管理 static const ble = '/ble';