From 934a8cd287bdbd9fcd8f4fbf71a707c07f3dd3e7 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 26 Sep 2026 08:27:24 +0800 Subject: [PATCH] =?UTF-8?q?client=EF=BC=9A=E5=BA=95=E9=83=A8=E5=9B=9E?= =?UTF-8?q?=E5=88=B0=E3=80=8C=E9=A6=96=E9=A1=B5=20=C2=B7=20=E6=88=91?= =?UTF-8?q?=E7=9A=84=E3=80=8D=EF=BC=8C=E5=AF=B9=E8=AF=9D=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E6=94=BE=E5=9B=9E=E9=A6=96=E9=A1=B5=E7=BD=91=E6=A0=BC=EF=BC=9B?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E9=A1=B5=E4=B8=80=E9=A1=B5=E5=88=B0=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉底部中间的「AI」按钮(连同按设备挑助手的 openAiAssistant),EMAI / EaiCar 车载助手 回到首页网格当普通卡片,拖拽排序改回 service.move - 首页左上角设备入口:从没连过也没绑过任何设备时显示加号,点了直接进「选择设备」; 有过设备时显示最后连接那台的小图标,连着亮、没连置灰 - 设备页:主图下面直接铺已绑定设备卡片(原「设备管理」页内容,抽成 BoundDeviceCard 两处共用) 和固件升级一行(当前/最新版本,设备版本低于服务端时右边直接「开始升级」,进度在行内走, 升级中返回先确认);「添加设备」挪到页面最下面 - 原「设备管理」「固件升级」页保留(语音纪要「我的」、固件更新提醒弹窗仍在用) 验证:dart analyze 无新增 error/warning;flutter test 224 全过;release 包装到 ALN-AL80 看过首页与设备页(未连接态)。连接态电量/升级按钮、首次加号形态未在真机上看。 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/client/lib/core/utils/ai_navigation.dart | 18 - .../data/services/agent_module_defaults.dart | 21 - .../data/services/agent_module_service.dart | 25 - .../controllers/main_tab_controller.dart | 3 + .../modules/main_tab/views/main_tab_view.dart | 55 +- .../modules/main_tab/views/tabs/ai_tab.dart | 8 +- .../main_tab/views/tabs/device_tab.dart | 483 +++++++++++++----- .../views/widgets/device_status_chip.dart | 46 +- .../my_devices/views/my_devices_view.dart | 346 +------------ .../views/widgets/bound_device_card.dart | 361 +++++++++++++ 10 files changed, 777 insertions(+), 589 deletions(-) create mode 100644 apps/client/lib/modules/my_devices/views/widgets/bound_device_card.dart diff --git a/apps/client/lib/core/utils/ai_navigation.dart b/apps/client/lib/core/utils/ai_navigation.dart index 153c6d18..bed49a4e 100644 --- a/apps/client/lib/core/utils/ai_navigation.dart +++ b/apps/client/lib/core/utils/ai_navigation.dart @@ -1,9 +1,7 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import '../../data/models/agent_module.dart'; import '../../data/models/appconfig.dart'; -import '../../data/services/agent_module_defaults.dart'; import '../../routes/app_routes.dart'; import 'id_verify_guard.dart'; @@ -26,19 +24,3 @@ void navigateToAIAssistant() { Get.toNamed(Routes.emai); } } - -/// 底部中间「AI」按钮(2026-09-24):按当前设备接对应的对话助手。 -/// -/// 连着(或最近连过)EaiCar 车载香薰 → EaiCar 车载助手;其余 → [navigateToAIAssistant] -/// (EMAI,或后台配了移动精灵时走移动精灵)。选哪个助手的规则在 -/// [AgentDefaults.assistantModuleForCurrentDevice],新设备要接专属助手只需把它的模块放进 -/// 该品类的默认编排、并登记到 AgentModuleService.assistantIds。 -void openAiAssistant() { - final id = AgentDefaults.assistantModuleForCurrentDevice(); - if (id == AgentModules.eaicarChat) { - if (IdVerifyGuard.blocked()) return; - Get.toNamed(Routes.eaicarChat); - return; - } - navigateToAIAssistant(); -} diff --git a/apps/client/lib/data/services/agent_module_defaults.dart b/apps/client/lib/data/services/agent_module_defaults.dart index 86d83485..8f46ad1a 100644 --- a/apps/client/lib/data/services/agent_module_defaults.dart +++ b/apps/client/lib/data/services/agent_module_defaults.dart @@ -33,27 +33,6 @@ class AgentDefaults { return list.isEmpty ? null : list.first; } - /// 底部中间「AI」按钮该打开哪个对话助手(2026-09-24)。 - /// - /// 按「当前连着的设备,没连就最近连过的那台」→ 品类 → 该品类默认编排里**第一个对话助手** - /// ([AgentModuleService.assistantIds] 里的模块)。车载香薰返回 EaiCar 车载助手, - /// 耳机/支架的默认编排里没有专属助手,返回 null = 走通用入口(EMAI,或后台配了移动精灵时走它)。 - /// - /// ⚠️ 与 [primaryModuleForVendor] 不同:那个取「默认编排第一个模块」,给设备按键唤醒用; - /// 支架的第一个是语音纪要,拿它开对话页是不对的,所以这里只在助手里挑。 - static String? assistantModuleForCurrentDevice() { - if (!Get.isRegistered()) return null; - final hub = DeviceHub.to; - final String vendor = - hub.sessions.isNotEmpty ? hub.sessions.first.vendor : hub.lastKnownVendor; - final cat = _vendorCategory[vendor]; - if (cat == null) return null; - for (final id in AgentModules.defaultsForCategory(cat)) { - if (AgentModuleService.assistantIds.contains(id)) return id; - } - return null; - } - static Worker? _worker; static void bind(DeviceHub hub) { diff --git a/apps/client/lib/data/services/agent_module_service.dart b/apps/client/lib/data/services/agent_module_service.dart index a6b7e04e..bab1885a 100644 --- a/apps/client/lib/data/services/agent_module_service.dart +++ b/apps/client/lib/data/services/agent_module_service.dart @@ -185,31 +185,6 @@ class AgentModuleService extends GetxService { .whereType() .toList(growable: false); - /// 对话助手类模块(2026-09-24):统一由底部中间的「AI」按钮进入,**首页网格不再展示**。 - /// 以后新增设备专属助手(像 EaiCar 这种)加到这里,AI 按钮会按设备自动接上。 - /// - /// ⚠️ 只是不在首页**显示**,编排里的 id 原样保留:设备默认编排、持久化、广场都不受影响, - /// 设备按键唤醒找 agent(AgentDefaults.primaryModuleForVendor)也照旧能找到它们。 - static const Set assistantIds = { - AgentModules.emaiAssistant, - AgentModules.eaicarChat, - }; - - /// 首页网格展示的模块:[enabledModules] 去掉对话助手。 - List get homeModules => enabledModules - .where((m) => !assistantIds.contains(m.id)) - .toList(growable: false); - - /// 首页拖拽排序。[from]/[to] 是 [homeModules] 里的下标,换算成 [enabledIds] 的下标再挪, - /// 这样被隐藏的助手在编排里的相对位置不变。越界的语义与 [move] 一致:直接忽略。 - void moveOnHome(int from, int to) { - final ids = homeModules.map((m) => m.id).toList(growable: false); - if (from == to) return; - if (from < 0 || from >= ids.length) return; - if (to < 0 || to >= ids.length) return; - move(enabledIds.indexOf(ids[from]), enabledIds.indexOf(ids[to])); - } - /// 广场页展示的全集 List get allModules => AgentModules.all; diff --git a/apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart b/apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart index f6af4f1f..3bd6aea9 100644 --- a/apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart +++ b/apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart @@ -64,6 +64,9 @@ class MainTabController extends GetxController { void openDevice() => Get.toNamed(Routes.device); void openRecall() => Get.toNamed(Routes.recall); + /// 从没有过设备时,左上角是加号,点了直接进「选择设备」 + void openAddDevice() => Get.toNamed(Routes.deviceCategory); + /// 扫描页连上设备后调:回到主框架的 AI 页,再把设备页推上来让用户看到连接结果。 /// /// 调用方先 `Get.offAllNamed(Routes.home)` 清掉扫描页栈,再在下一帧调这里—— diff --git a/apps/client/lib/modules/main_tab/views/main_tab_view.dart b/apps/client/lib/modules/main_tab/views/main_tab_view.dart index b4390ad7..a0b474c2 100644 --- a/apps/client/lib/modules/main_tab/views/main_tab_view.dart +++ b/apps/client/lib/modules/main_tab/views/main_tab_view.dart @@ -2,18 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import '../../../core/utils/ai_navigation.dart'; import '../controllers/main_tab_controller.dart'; import 'tabs/ai_tab.dart'; import 'tabs/mine_tab.dart'; import '../../../core/theme/app_palette.dart'; -/// App 主框架:底部「首页 · AI · 我的」(2026-09-24)。 +/// App 主框架:底部「首页 · 我的」两个 tab(PageView 两页,可左右滑)。 /// -/// - 首页、我的是两个真正的 tab(PageView 两页,可左右滑); -/// - 中间的「AI」是个**按钮不是页**:点了按当前设备打开对应的对话助手 -/// (EaiCar 香薰 → EaiCar 车载助手,其余 → EMAI),见 [openAiAssistant]。 -/// 不做成常驻 tab 是刻意的:对话页一打开就连百炼、占麦克风,常驻会让这条连接一直挂着。 +/// - 对话助手(EMAI / EaiCar 车载助手)是首页网格里的卡片(2026-09-26 从底部中间的 +/// 「AI」按钮挪回首页); /// - 设备、拾忆是首页顶栏的入口(左上设备图标、右上日历图标),广场在「我的」里。 /// /// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。 @@ -110,7 +107,6 @@ class MainTabView extends GetView { return Row( children: [ tab(MainTabController.tabAi), - Expanded(child: _buildAiButton(isDarkMode)), tab(MainTabController.tabMine), ], ); @@ -121,51 +117,6 @@ class MainTabView extends GetView { } } -extension _AiButton on MainTabView { - /// 底部中间的「AI」按钮:圆形渐变底 + 星芒图标,下面一行「AI」。 - Widget _buildAiButton(bool isDarkMode) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: openAiAssistant, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 38.w, - height: 38.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF5B8DEF), Color(0xFF7E57C2)], - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF6C6FE0) - .withOpacity(isDarkMode ? 0.45 : 0.35), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Icon(Icons.auto_awesome, size: 20.sp, color: Colors.white), - ), - SizedBox(height: 2.h), - Text( - 'AI', - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w700, - color: isDarkMode ? Colors.white70 : const Color(0xFF6C6FE0), - ), - ), - ], - ), - ); - } -} - /// 让 PageView 里的页面滑走后不被销毁,等价于原来 IndexedStack 的常驻行为。 class _KeepAlivePage extends StatefulWidget { const _KeepAlivePage({required this.child}); diff --git a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart index 568acec0..2c6c7630 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart @@ -69,6 +69,7 @@ class AiTab extends StatelessWidget { leading: DeviceStatusChip( isDarkMode: isDarkMode, onTap: tabCtrl.openDevice, + onAdd: tabCtrl.openAddDevice, ), actions: [ SizedBox(width: 6.w), @@ -85,8 +86,7 @@ class AiTab extends StatelessWidget { ), ], child: Obx(() { - // 对话助手(EMAI / EaiCar 等)已挪到底部中间的「AI」按钮,首页网格不再展示 - final modules = service.homeModules; + final modules = service.enabledModules; final bool editing = tabCtrl.isEditingAi.value; // 三条链路任意一条连上都算已连接:之前只看了杰理那条, // 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。 @@ -369,7 +369,7 @@ class AiTab extends StatelessWidget { return DragTarget( onWillAcceptWithDetails: (d) => d.data != index, - onAcceptWithDetails: (d) => service.moveOnHome(d.data, index), + onAcceptWithDetails: (d) => service.move(d.data, index), builder: (context, candidate, rejected) { final bool hovering = candidate.isNotEmpty; return LongPressDraggable( @@ -408,7 +408,7 @@ class AiTab extends StatelessWidget { ) { return DragTarget( onWillAcceptWithDetails: (d) => true, - onAcceptWithDetails: (d) => service.moveOnHome(d.data, index), + onAcceptWithDetails: (d) => service.move(d.data, index), builder: (context, candidate, rejected) { final bool hovering = candidate.isNotEmpty; return Container( diff --git a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart index 15accd36..04c09bbb 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart @@ -8,112 +8,395 @@ import '../../../../core/utils/device_connection.dart'; import '../../../../data/models/user_Info.dart'; import '../../../../devices/device_hub.dart'; import '../../../../routes/app_routes.dart'; +import '../../../my_devices/controllers/my_devices_controller.dart'; +import '../../../my_devices/views/widgets/bound_device_card.dart'; +import '../../../ota_upgrade/controllers/device_ota_controller.dart'; import '../widgets/battery_indicator.dart'; import '../widgets/tab_scaffold.dart'; -/// 设备页 +/// 设备页(从首页左上角设备图标进入的二级页面) /// -/// 顶部标题栏左侧是「添加设备」的圆形加号;下面是设备图片展示区,图片四角 -/// 叠信息:右上=连接状态,左下=授权码,右下=MAC + 电量(单/双电池自适应)。 +/// 一页到底,不再往下嵌一层(2026-09-26): +/// 1. 设备主图,四角叠信息:右上=连接状态,左下=授权码,右下=MAC + 电量; +/// 2. 设备管理:已绑定设备的卡片直接铺在主图下面(原「设备管理」页的内容,[BoundDeviceCard]); +/// 3. 固件升级:当前/最新版本一行,设备版本低于服务端时右边直接是升级按钮,进度也在这一行里走; +/// 4. 「添加设备」按钮固定在页面最下面。 /// -/// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), -/// 电量按 [DeviceBattery] 里有什么显示什么。 -class DeviceTab extends StatelessWidget { +/// **不认厂商**:设备信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), +/// 升级走 [DeviceOtaController](厂商差异在各自的 DeviceOtaPort 里)。 +class DeviceTab extends StatefulWidget { const DeviceTab({Key? key, this.standalone = false}) : super(key: key); - /// 作为独立页面打开(从 AI 页左上角设备图标进入)时为 true,标题栏带返回箭头。 + /// 作为独立页面打开(从首页左上角设备图标进入)时为 true,标题栏带返回箭头。 /// 2026-09-24 起设备不再是底部 tab,目前只有这一种用法。 final bool standalone; + @override + State createState() => _DeviceTabState(); +} + +class _DeviceTabState extends State { /// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动 static const double _imageAreaHeight = 300; /// 底部信息(左下授权码 / 右下电量)的专属高度,免得压在耳机主图上 static const double _bottomInfoInset = 28; + static const Color _otaAccent = Color(0xFF009688); + + late final MyDevicesController _devices; + late final DeviceOtaController _ota; + + /// 控制器是不是本页建的。「设备管理」「固件升级」两个老页面还在 + /// (语音纪要「我的」、固件更新提醒弹窗仍会打开它们),它们若已经挂着同类型 + /// 控制器,就借用、不销毁——本页退出时把别人正在用的删掉会让那边整页失效。 + bool _ownDevices = false; + bool _ownOta = false; + + @override + void initState() { + super.initState(); + _ownDevices = !Get.isRegistered(); + _devices = _ownDevices + ? Get.put(MyDevicesController()) + : Get.find(); + _ownOta = !Get.isRegistered(); + _ota = _ownOta + ? Get.put(DeviceOtaController()) + : Get.find(); + } + + @override + void dispose() { + // 升级中退出已在 _handleBack 里确认并 cancel 过;onClose 负责把设备交还给正常会话 + if (_ownOta && Get.isRegistered()) { + Get.delete(); + } + if (_ownDevices && Get.isRegistered()) { + Get.delete(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; final hub = DeviceHub.to; - return TabScaffold( - slogan: 'sloganDevice'.tr, - isDarkMode: isDarkMode, - leading: _buildAddButton(isDarkMode), - titleOnRight: true, - onBack: standalone ? Get.back : null, - child: Obx(() { - // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 - hub.revision.value; - final bool connected = hub.anyConnected.value; - final DeviceInfo? info = hub.primaryInfo.value; - return ListView( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + return PopScope( + // 固件推送中不能直接走:先确认,确认了再取消升级退出 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _handleBack(); + }, + child: TabScaffold( + slogan: 'sloganDevice'.tr, + isDarkMode: isDarkMode, + titleOnRight: true, + onBack: widget.standalone ? _handleBack : null, + child: Column( children: [ - _buildImageArea(isDarkMode, connected, info), - SizedBox(height: 20.h), - _buildEntry( - isDarkMode: isDarkMode, - icon: Icons.device_hub, - iconColor: AppPalette.error, - title: 'deviceManagement'.tr, // 设备管理 - subtitle: 'manageConnectedDevices'.tr, // 管理已绑定的蓝牙设备 - onTap: () => Get.toNamed(Routes.myDevices), - ), - SizedBox(height: 12.h), - // 固件升级入口**始终显示**:进去后由 DeviceOtaController 按 - // 「有没有在线设备 / 它有没有 otaPort」给出具体提示。 - // ⚠️ 别再按 `hub.has(DeviceCapability.ota)` 来显隐——那样写有个洞: - // 设备连着、但它不支持 OTA 时,「有能力」和「没连设备」两个分支 - // 都不满足,入口会**凭空消失**(2026-09-10 支架就这么丢过入口)。 - _buildEntry( - isDarkMode: isDarkMode, - icon: Icons.system_update_alt, - iconColor: AppPalette.warning, - title: 'firmwareUpgrade'.tr, // 固件升级 - subtitle: 'bleHeadsetFirmwareUpgrade'.tr, - onTap: () => Get.toNamed(Routes.otaUpgrade), + Expanded( + child: Obx(() { + // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 + hub.revision.value; + final bool connected = hub.anyConnected.value; + final DeviceInfo? info = hub.primaryInfo.value; + return RefreshIndicator( + onRefresh: _devices.loadDevices, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: + EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + children: [ + _buildImageArea(isDarkMode, connected, info), + SizedBox(height: 20.h), + _buildSectionTitle('deviceManagement'.tr, isDarkMode), + SizedBox(height: 10.h), + _buildBoundDevices(isDarkMode), + SizedBox(height: 14.h), + _buildSectionTitle('firmwareUpgrade'.tr, isDarkMode), + SizedBox(height: 10.h), + _buildFirmwareCard(isDarkMode), + SizedBox(height: 16.h), + ], + ), + ); + }), ), + _buildAddButton(context), ], - ); - }), + ), + ), ); } - /// 标题栏左侧:圆形加号 + 「添加设备」文字,进「选择设备」页 - Widget _buildAddButton(bool isDarkMode) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => Get.toNamed(Routes.deviceCategory), - child: Row( - mainAxisSize: MainAxisSize.min, + void _handleBack() { + if (!_ota.isBusy) { + Get.back(); + return; + } + Get.dialog( + AlertDialog( + title: Text('otaExitTitle'.tr), + content: Text('otaExitConfirm'.tr), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: () { + _ota.cancel(); + Get.back(); // 关弹窗 + Get.back(); // 退页面 + }, + child: Text('confirm'.tr), + ), + ], + ), + ); + } + + Widget _buildSectionTitle(String text, bool isDarkMode) { + return Padding( + padding: EdgeInsets.only(left: 4.w), + child: Text( + text, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: isDarkMode ? Colors.white : AppPalette.brand900, + ), + ), + ); + } + + /// 已绑定设备列表(原「设备管理」页的内容)。一台都没有时给一行提示,不占大块空状态。 + Widget _buildBoundDevices(bool isDarkMode) { + return Obx(() { + final list = _devices.allDevices; + if (list.isEmpty) { + if (_devices.isLoading.value) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 16.h), + child: const Center(child: CircularProgressIndicator()), + ); + } + return Padding( + padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 4.w), + child: Text( + 'noDevicesBound'.tr, + style: TextStyle( + fontSize: 13.sp, + color: isDarkMode ? Colors.white54 : AppPalette.brand400, + ), + ), + ); + } + return Column( children: [ - Container( - width: 30.w, - height: 30.w, - decoration: BoxDecoration( - color: AppPalette.accentLight, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: AppPalette.accent.withOpacity(0.18), - blurRadius: 8, - offset: const Offset(0, 2), + for (final d in list) + BoundDeviceCard( + device: d, + controller: _devices, + isDark: isDarkMode, + ), + ], + ); + }); + } + + /// 固件升级:一行版本信息,右边按状态放「升级」按钮 / 进度 / 结论文案。 + /// + /// 始终显示(不按 `hub.has(DeviceCapability.ota)` 显隐):设备连着但不支持 OTA、 + /// 或者没连设备时,这一行照样在,只是右边给出原因——入口「凭空消失」 + /// 比告诉用户为什么不能升更让人困惑(2026-09-10 支架就这么丢过入口)。 + Widget _buildFirmwareCard(bool isDarkMode) { + final Color cardBg = isDarkMode ? AppPalette.brand800 : Colors.white; + final Color titleColor = isDarkMode ? Colors.white : AppPalette.brand900; + final Color subColor = isDarkMode ? Colors.white60 : AppPalette.brand400; + return Obx(() { + final int phase = _ota.phase.value; + final bool busy = + phase == OtaPhase.downloading || phase == OtaPhase.upgrading; + final String cur = _ota.currentVersion.value.trim(); + final String latest = _ota.latestVersion.value.trim(); + final bool connected = _ota.isConnected.value; + final bool canUpgrade = _ota.isUpdateAvailable.value && + connected && + (phase == OtaPhase.idle || phase == OtaPhase.failed); + + final double progress = phase == OtaPhase.downloading + ? _ota.downloadProgress.value + : _ota.otaProgress.value; + + return Container( + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(isDarkMode ? 0.06 : 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.system_update_alt, + size: 24.sp, color: AppPalette.warning), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${'otaCurrentVersion'.tr} ${cur.isEmpty ? '--' : cur}', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + SizedBox(height: 3.h), + Text( + '${'otaLatestVersion'.tr} ${latest.isEmpty ? '--' : latest}', + style: TextStyle(fontSize: 12.sp, color: subColor), + ), + ], + ), ), + SizedBox(width: 8.w), + if (busy) + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: _otaAccent, + ), + ) + else if (canUpgrade) + SizedBox( + height: 32.h, + child: ElevatedButton( + onPressed: _ota.startUpdate, + style: ElevatedButton.styleFrom( + backgroundColor: _otaAccent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: 14.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + ), + child: Text( + 'otaStartUpgrade'.tr, + style: TextStyle( + fontSize: 13.sp, fontWeight: FontWeight.w600), + ), + ), + ) + else + // 不用 Flexible:它和左边的 Expanded 平分剩余宽度,短文案会被挤到中间 + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 150.w), + child: Text( + _firmwareStatusText(phase, connected), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12.sp, + color: phase == OtaPhase.failed + ? AppPalette.error + : (phase == OtaPhase.success + ? _otaAccent + : subColor), + ), + ), + ), ], ), - child: Icon(Icons.add, color: AppPalette.accent, size: 20.sp), + if (busy) ...[ + SizedBox(height: 12.h), + ClipRRect( + borderRadius: BorderRadius.circular(4.r), + child: LinearProgressIndicator( + value: progress == 0 ? null : progress, + minHeight: 6.h, + backgroundColor: + isDarkMode ? Colors.white12 : Colors.grey[200], + valueColor: const AlwaysStoppedAnimation(_otaAccent), + ), + ), + SizedBox(height: 8.h), + Text( + _ota.detailedStatus.value.isEmpty + ? (phase == OtaPhase.downloading + ? 'otaDownloading'.tr + : 'otaUpgradingShort'.tr) + : _ota.detailedStatus.value, + style: TextStyle(fontSize: 12.sp, color: subColor), + ), + SizedBox(height: 4.h), + Text( + 'otaKeepConnectedWarning'.tr, + style: TextStyle(fontSize: 11.sp, color: AppPalette.warning), + ), + ], + ], + ), + ); + }); + } + + /// 右侧不给按钮时的结论文案。 + /// + /// ⚠️ 「服务端没给版本」「认不出是哪个产品」必须与「已是最新」分开说: + /// 固件压根没上传时报「已是最新」是假话(2026-09-14 车载香薰踩过)。 + String _firmwareStatusText(int phase, bool connected) { + if (phase == OtaPhase.success) return 'otaSuccessReboot'.tr; + if (phase == OtaPhase.failed) return 'otaFailed'.tr; + if (!connected) return 'unconnected'.tr; + if (_ota.productMissing.value) return 'otaProductInfoUnavailable'.tr; + if (_ota.latestVersion.value.trim().isEmpty) return 'otaNoServerVersion'.tr; + return 'otaAlreadyLatest'.tr; + } + + /// 页面最下面的「添加设备」,进「选择设备」页 + Widget _buildAddButton(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + 20.w, + 8.h, + 20.w, + // 手势条/虚拟键区域会盖住按钮,底部安全区要单独加进来 + MediaQuery.of(context).padding.bottom + 12.h, + ), + child: SizedBox( + width: double.infinity, + height: 48.h, + child: ElevatedButton.icon( + onPressed: () => Get.toNamed(Routes.deviceCategory), + icon: Icon(Icons.add, size: 20.sp), + label: Text( + 'addDevice'.tr, + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), ), - SizedBox(width: 7.w), - Text( - 'addDevice'.tr, // 添加设备 - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : AppPalette.brand800, + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.accent, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.r), ), ), - ], + ), ), ); } @@ -304,60 +587,4 @@ class DeviceTab extends StatelessWidget { ], ); } - - Widget _buildEntry({ - required bool isDarkMode, - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onTap, - Color iconColor = AppPalette.accent, - }) { - final Color cardBg = isDarkMode ? AppPalette.brand800 : Colors.white; - final Color titleColor = isDarkMode ? Colors.white : AppPalette.brand900; - final Color subColor = isDarkMode ? Colors.white60 : AppPalette.brand400; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: cardBg, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.06 : 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - children: [ - Icon(icon, size: 24.sp, color: iconColor), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.bold, - color: titleColor, - ), - ), - SizedBox(height: 2.h), - Text(subtitle, - style: TextStyle(fontSize: 11.sp, color: subColor)), - ], - ), - ), - Icon(Icons.chevron_right, size: 20.sp, color: subColor), - ], - ), - ), - ); - } } diff --git a/apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart b/apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart index 04cb25c2..74a0453b 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart @@ -5,10 +5,15 @@ 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 页左上角的设备入口:产品小图标 + 电量,点击进设备页。 +/// AI 页左上角的设备入口,三种形态: +/// +/// - 从没连过、也没绑过任何设备:一个加号小图标,点了走 [onAdd](选择要连接的设备); +/// - 已连接:产品图标外圈绿色,右下角一个绿点,旁边显示电量,点了走 [onTap](设备页); +/// - 未连接:产品图标置灰,不显示电量,点了同样进设备页。 /// /// - 已连接:图标外圈绿色,右下角一个绿点,旁边显示电量; /// - 未连接:图标置灰,不显示电量。 @@ -21,11 +26,23 @@ class DeviceStatusChip extends StatelessWidget { 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([ 0.299, 0.587, 0.114, 0, 0, // @@ -40,6 +57,8 @@ class DeviceStatusChip extends StatelessWidget { 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; @@ -49,6 +68,8 @@ class DeviceStatusChip extends StatelessWidget { battery.chargingLeft || battery.chargingRight); + if (!connected && !_hasAnyDevice(hub)) return _buildAdd(); + final Color ring = connected ? AppPalette.success : (isDarkMode ? Colors.white24 : Colors.black12); @@ -142,4 +163,27 @@ class DeviceStatusChip extends StatelessWidget { ); }); } + + 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), + ), + ); + } } diff --git a/apps/client/lib/modules/my_devices/views/my_devices_view.dart b/apps/client/lib/modules/my_devices/views/my_devices_view.dart index 739f4699..1aaab682 100644 --- a/apps/client/lib/modules/my_devices/views/my_devices_view.dart +++ b/apps/client/lib/modules/my_devices/views/my_devices_view.dart @@ -1,58 +1,10 @@ import 'package:flutter/material.dart'; -import '../../../data/models/device_images.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import '/data/models/user_Info.dart'; import '../../../routes/app_routes.dart'; import '../controllers/my_devices_controller.dart'; -import 'widgets/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; - } - } -} +import 'widgets/bound_device_card.dart'; class MyDevicesView extends GetView { const MyDevicesView({super.key}); @@ -285,9 +237,11 @@ class MyDevicesView extends GetView { // 卡片自带 10 的下边距,这里不再额外加间隔,否则两者叠加显得松散 separatorBuilder: (_, __) => const SizedBox.shrink(), itemBuilder: (context, index) { - final d = devices[index]; - final category = _MyDeviceCategory.fromValue(d.devicetype); - return _buildDeviceCard(d, category, isDark); + return BoundDeviceCard( + device: devices[index], + controller: controller, + isDark: isDark, + ); }, ), ); @@ -328,292 +282,4 @@ class MyDevicesView extends GetView { ), ); } - - Widget _buildDeviceCard( - UserDevice device, _MyDeviceCategory category, bool isDark) { - 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), - ), - ), - ], - ), - ); - } } diff --git a/apps/client/lib/modules/my_devices/views/widgets/bound_device_card.dart b/apps/client/lib/modules/my_devices/views/widgets/bound_device_card.dart new file mode 100644 index 00000000..09864b46 --- /dev/null +++ b/apps/client/lib/modules/my_devices/views/widgets/bound_device_card.dart @@ -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), + ), + ), + ], + ), + ); + } +}