Compare commits
2 Commits
2a913260e6
...
934a8cd287
| Author | SHA1 | Date |
|---|---|---|
|
|
934a8cd287 | 4 weeks ago |
|
|
2dacba0e59 | 4 weeks ago |
14 changed files with 1056 additions and 540 deletions
@ -0,0 +1,189 @@ |
|||||
|
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../../../../core/theme/app_palette.dart'; |
||||
|
import '../../../../core/utils/device_connection.dart'; |
||||
|
import '../../../../data/models/user_Info.dart'; |
||||
|
import '../../../../devices/device_hub.dart'; |
||||
|
import 'battery_indicator.dart'; |
||||
|
|
||||
|
/// AI 页左上角的设备入口,三种形态: |
||||
|
/// |
||||
|
/// - 从没连过、也没绑过任何设备:一个加号小图标,点了走 [onAdd](选择要连接的设备); |
||||
|
/// - 已连接:产品图标外圈绿色,右下角一个绿点,旁边显示电量,点了走 [onTap](设备页); |
||||
|
/// - 未连接:产品图标置灰,不显示电量,点了同样进设备页。 |
||||
|
/// |
||||
|
/// - 已连接:图标外圈绿色,右下角一个绿点,旁边显示电量; |
||||
|
/// - 未连接:图标置灰,不显示电量。 |
||||
|
/// |
||||
|
/// 图标取「当前连着的、没连就最近连过的」那台设备([DeviceConnection.currentIconImage]), |
||||
|
/// 从没连过时是通用图。**不认厂商**:电量按 [DeviceBattery.summary] 有什么显示什么, |
||||
|
/// TWS 耳机给的是左右较低的一侧,支架这类拿不到电量的设备就只显示图标。 |
||||
|
class DeviceStatusChip extends StatelessWidget { |
||||
|
const DeviceStatusChip({ |
||||
|
Key? key, |
||||
|
required this.isDarkMode, |
||||
|
required this.onTap, |
||||
|
required this.onAdd, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
final bool isDarkMode; |
||||
|
final VoidCallback onTap; |
||||
|
|
||||
|
/// 「从没有过设备」时点加号走这里 |
||||
|
final VoidCallback onAdd; |
||||
|
|
||||
|
/// 有没有过设备:本机连过([DeviceHub.hasKnownDevice],落盘跨冷启动)或账号下绑过。 |
||||
|
/// 后者照顾换手机重装的用户——本机没连过,但他不是「首次」。 |
||||
|
static bool _hasAnyDevice(DeviceHub hub) { |
||||
|
if (hub.hasKnownDevice) return true; |
||||
|
if (!User.isLoggedIn()) return false; |
||||
|
return User.instance.devices.isNotEmpty; |
||||
|
} |
||||
|
|
||||
|
/// 置灰矩阵(ITU-R BT.601 亮度系数),给未连接时的产品图标用 |
||||
|
static const ColorFilter _grayscale = ColorFilter.matrix(<double>[ |
||||
|
0.299, 0.587, 0.114, 0, 0, // |
||||
|
0.299, 0.587, 0.114, 0, 0, // |
||||
|
0.299, 0.587, 0.114, 0, 0, // |
||||
|
0, 0, 0, 1, 0, |
||||
|
]); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final hub = DeviceHub.to; |
||||
|
return Obx(() { |
||||
|
// revision 随任一会话的 info(含电量)变化 +1,读一下让 Obx 收集到它 |
||||
|
hub.revision.value; |
||||
|
// 绑定列表刷新(登录、拉设备列表)时跟着重画 |
||||
|
User.refreshTick.value; |
||||
|
final bool connected = hub.anyConnected.value; |
||||
|
final DeviceInfo? info = hub.primaryInfo.value; |
||||
|
final DeviceBattery? battery = connected ? info?.battery : null; |
||||
|
final int? level = battery?.summary; |
||||
|
final bool charging = battery != null && |
||||
|
(battery.chargingSingle || |
||||
|
battery.chargingLeft || |
||||
|
battery.chargingRight); |
||||
|
|
||||
|
if (!connected && !_hasAnyDevice(hub)) return _buildAdd(); |
||||
|
|
||||
|
final Color ring = connected |
||||
|
? AppPalette.success |
||||
|
: (isDarkMode ? Colors.white24 : Colors.black12); |
||||
|
|
||||
|
Widget icon = Image.asset( |
||||
|
DeviceConnection.currentIconImage(), |
||||
|
width: 26.w, |
||||
|
height: 26.w, |
||||
|
fit: BoxFit.contain, |
||||
|
errorBuilder: (_, __, ___) => Icon( |
||||
|
Icons.devices_other, |
||||
|
size: 20.sp, |
||||
|
color: isDarkMode ? Colors.white54 : AppPalette.brand400, |
||||
|
), |
||||
|
); |
||||
|
if (!connected) { |
||||
|
icon = Opacity( |
||||
|
opacity: 0.55, |
||||
|
child: ColorFiltered(colorFilter: _grayscale, child: icon), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return GestureDetector( |
||||
|
behavior: HitTestBehavior.opaque, |
||||
|
onTap: onTap, |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 36.w, |
||||
|
height: 36.w, |
||||
|
child: Stack( |
||||
|
clipBehavior: Clip.none, |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 36.w, |
||||
|
height: 36.w, |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode |
||||
|
? Colors.white.withOpacity(0.06) |
||||
|
: Colors.white, |
||||
|
shape: BoxShape.circle, |
||||
|
border: Border.all( |
||||
|
color: ring, |
||||
|
width: connected ? 1.6 : 1, |
||||
|
), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black |
||||
|
.withOpacity(isDarkMode ? 0.2 : 0.06), |
||||
|
blurRadius: 6, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: icon, |
||||
|
), |
||||
|
if (connected) |
||||
|
Positioned( |
||||
|
right: 0, |
||||
|
bottom: 0, |
||||
|
child: Container( |
||||
|
width: 9.w, |
||||
|
height: 9.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: AppPalette.success, |
||||
|
shape: BoxShape.circle, |
||||
|
border: Border.all( |
||||
|
color: isDarkMode |
||||
|
? const Color(0xFF0A0F1A) |
||||
|
: Colors.white, |
||||
|
width: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (level != null) ...[ |
||||
|
SizedBox(width: 6.w), |
||||
|
BatteryIndicator( |
||||
|
level: level, |
||||
|
isDarkMode: isDarkMode, |
||||
|
charging: charging, |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildAdd() { |
||||
|
return GestureDetector( |
||||
|
behavior: HitTestBehavior.opaque, |
||||
|
onTap: onAdd, |
||||
|
child: Container( |
||||
|
width: 36.w, |
||||
|
height: 36.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: AppPalette.accentLight, |
||||
|
shape: BoxShape.circle, |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: AppPalette.accent.withOpacity(0.18), |
||||
|
blurRadius: 8, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Icon(Icons.add, color: AppPalette.accent, size: 22.sp), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,361 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '/data/models/device_images.dart'; |
||||
|
import '/data/models/user_Info.dart'; |
||||
|
import '../../controllers/my_devices_controller.dart'; |
||||
|
import 'device_settings_sheet.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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 已绑定设备的一行卡片:产品小图标 + 名称/MAC + 连接状态 + 解绑, |
||||
|
/// 有设置面板的设备整行可点。 |
||||
|
/// |
||||
|
/// 「设备管理」页与设备页(2026-09-26 起设备管理直接铺在设备主图下面)共用这一份。 |
||||
|
/// 依赖 [MyDevicesController]:连接状态、能否打开面板、解绑都走它。 |
||||
|
class BoundDeviceCard extends StatelessWidget { |
||||
|
const BoundDeviceCard({ |
||||
|
super.key, |
||||
|
required this.device, |
||||
|
required this.controller, |
||||
|
required this.isDark, |
||||
|
}); |
||||
|
|
||||
|
final UserDevice device; |
||||
|
final MyDevicesController controller; |
||||
|
final bool isDark; |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final category = _MyDeviceCategory.fromValue(device.devicetype); |
||||
|
final connected = controller.isConnected(device); |
||||
|
const connectedColor = Color(0xFF22C55E); |
||||
|
// 2026-09-22:有设置面板的设备(目前只有 EaiCar)整行可点,点开底部面板。 |
||||
|
// 解绑按钮自己的 GestureDetector 在子树里先吃掉事件,不会冒泡到这里; |
||||
|
// 其它设备 onTap 为 null,行为与以前一字不差。 |
||||
|
final canOpen = controller.canOpenSettings(device); |
||||
|
|
||||
|
return GestureDetector( |
||||
|
behavior: HitTestBehavior.opaque, |
||||
|
onTap: canOpen ? () => DeviceSettingsSheet.show(device) : null, |
||||
|
child: 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: [ |
||||
|
// 产品小图标(<设备名>_icon.png,取图规则见 DeviceImages)。 |
||||
|
// ⚠️ 这里用的是**服务端下发的** device.devicename,不是蓝牙广播名—— |
||||
|
// 这一页列的是「绑定关系」,可能包含此刻没连上、甚至不在身边的设备, |
||||
|
// 拿当前连接的名字去取图会让所有行显示成同一台。 |
||||
|
Container( |
||||
|
width: 44.w, |
||||
|
height: 44.w, |
||||
|
padding: EdgeInsets.all(4.w), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
// 底色仍然区分连接状态:换成实拍图之后,原来靠渐变色 |
||||
|
// 传达的「这台连着」就没了,得留个视觉线索。 |
||||
|
color: connected |
||||
|
? connectedColor.withValues(alpha: 0.12) |
||||
|
: (isDark |
||||
|
? Colors.white.withValues(alpha: 0.06) |
||||
|
: const Color(0xFF6366F1).withValues(alpha: 0.08)), |
||||
|
), |
||||
|
child: Image.asset( |
||||
|
DeviceImages.iconFor(device.devicename), |
||||
|
fit: BoxFit.contain, |
||||
|
// 图缺失时退回品类图标,不让整行塌成红色报错块 |
||||
|
errorBuilder: (_, __, ___) => Icon( |
||||
|
category.icon, |
||||
|
color: connected |
||||
|
? connectedColor |
||||
|
: const Color(0xFF6366F1), |
||||
|
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, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (canOpen) ...[ |
||||
|
Icon( |
||||
|
Icons.tune, |
||||
|
size: 18.sp, |
||||
|
color: connected |
||||
|
? connectedColor |
||||
|
: (isDark |
||||
|
? Colors.white.withValues(alpha: 0.3) |
||||
|
: const Color(0xFFBBBBBB)), |
||||
|
), |
||||
|
SizedBox(width: 10.w), |
||||
|
], |
||||
|
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.hasRealLicense || 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.hasRealLicense) |
||||
|
_buildFieldRow( |
||||
|
label: 'licenseCode'.tr, |
||||
|
value: device.license, |
||||
|
isDark: isDark), |
||||
|
if (device.hasRealLicense && 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, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
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), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue