Browse Source

上传绑定流程调整

weicu
liwei1dao 5 months ago
parent
commit
46e51284b6
  1. 8
      lib/core/translations/language/en_us.dart
  2. 8
      lib/core/translations/language/zh_cn.dart
  3. 154
      lib/data/utils/jieli_adv_parser.dart
  4. 154
      lib/modules/devices/controllers/devices_controller.dart
  5. 36
      lib/modules/devices/views/devices_view.dart
  6. 2
      lib/modules/home/views/home_view.dart
  7. 2
      lib/modules/meeting/views/meeting_mine_view.dart
  8. 10
      lib/modules/my_devices/bindings/my_devices_binding.dart
  9. 96
      lib/modules/my_devices/controllers/my_devices_controller.dart
  10. 581
      lib/modules/my_devices/views/my_devices_view.dart
  11. 2
      lib/modules/settings/views/settings_view.dart
  12. 9
      lib/routes/app_pages.dart
  13. 3
      lib/routes/app_routes.dart

8
lib/core/translations/language/en_us.dart

@ -836,6 +836,14 @@ const Map<String, String> 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",

8
lib/core/translations/language/zh_cn.dart

@ -772,6 +772,14 @@ const Map<String, String> zhCN = {
"confirmUnbind": "确认解绑",
"confirmUnbindMessage": "确定要解绑设备 \"{deviceName}\" 吗?",
"confirmUnbindAction": "确认解绑",
"pleaseGrantBluetoothPermission": "请授予蓝牙权限以扫描设备",
"startScanFailed": "启动扫描失败",
"pleaseLoginFirst": "请先登录",
"deviceUnauthorized": "设备未认证,请重试",
"addDevice": "添加设备",
"boundDevices": "已绑定设备",
"currentConnection": "当前连接",
"binding": "绑定中...",
// Agent模块 - 主界面
"Lily": "Lily",

154
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<int>? _hexToBytes(String hex) {
final s = hex.startsWith('0x') || hex.startsWith('0X') ? hex.substring(2) : hex;
if (s.length.isOdd) return null;
final out = <int>[];
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<int> 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<int> 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<int> bytes, int start, int end) {
final out = <String>[];
for (int i = start; i <= end; i++) {
final decoded = bytes[i] ^ 0x5A;
out.add(decoded.toRadixString(16).padLeft(2, '0').toUpperCase());
}
return out.join(':');
}
}

154
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<JieliDevice> 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<void> 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() : <String>[];
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<void> 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>();
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<void> 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<String, dynamic>);
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<String, dynamic> 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<bool> _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;

36
lib/modules/devices/views/devices_view.dart

@ -267,18 +267,25 @@ class _DevicesViewState extends State<DevicesView>
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<DevicesView>
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<DevicesView>
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<DevicesView>
)
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<DevicesView>
elevation: 0,
),
child: Text(
'connect'.tr,
bound ? 'connect'.tr : 'bind'.tr,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w600,

2
lib/modules/home/views/home_view.dart

@ -477,7 +477,7 @@ class HomeView extends GetView<HomeController> {
Widget _buildDeviceButton(bool isDarkMode) {
return ElevatedButton(
onPressed: () {
Get.toNamed(Routes.devices); // 跳转到设备管理页面
Get.toNamed(Routes.myDevices); // 跳转到我的设备列表
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,

2
lib/modules/meeting/views/meeting_mine_view.dart

@ -99,7 +99,7 @@ class _MeetingMineViewState extends State<MeetingMineView>
Icons.settings_remote_outlined,
'myVoitransDevice'.tr, // 对应中文:我的VOITRANS设备
onTap: () {
Get.toNamed(Routes.devices);
Get.toNamed(Routes.myDevices);
},
),
]),

10
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>(() => MyDevicesController());
}
}

96
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<int, List<UserDevice>> devicesByType =
<int, List<UserDevice>>{}.obs;
final RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
_bleManager = Get.find<BleManager>();
_refreshFromUser();
Future.microtask(loadDevices);
}
/// 从服务端拉取最新的设备列表,并刷新 [User.instance.devices]。
Future<void> loadDevices() async {
if (!User.isLoggedIn()) return;
isLoading.value = true;
try {
final response = await Api.getdevices();
if (response != null) {
final list = response['devices'] as List<dynamic>?;
if (list != null) {
User.instance.devices = list
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
.toList();
} else {
User.instance.devices = [];
}
Logger.i(_tag, '设备列表刷新完成: ${User.instance.devices.length} 条');
}
} catch (e) {
Logger.e(_tag, '获取设备列表失败: $e');
} finally {
_refreshFromUser();
isLoading.value = false;
}
}
/// 解绑设备:调接口成功后从本地列表移除;如果是当前连接的设备,断开并跳到扫描页。
Future<void> 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 = <int, List<UserDevice>>{};
if (User.isLoggedIn()) {
for (final d in User.instance.devices) {
grouped.putIfAbsent(d.devicetype, () => <UserDevice>[]).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);
}
}

581
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<MyDevicesController> {
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<int>(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),
),
),
],
),
);
}
}

2
lib/modules/settings/views/settings_view.dart

@ -498,7 +498,7 @@ class SettingsView extends GetView<SettingsController> {
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(

9
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(),

3
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';

Loading…
Cancel
Save