Browse Source

client:底部回到「首页 · 我的」,对话助手放回首页网格;设备页一页到底

- 去掉底部中间的「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) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
934a8cd287
  1. 18
      apps/client/lib/core/utils/ai_navigation.dart
  2. 21
      apps/client/lib/data/services/agent_module_defaults.dart
  3. 25
      apps/client/lib/data/services/agent_module_service.dart
  4. 3
      apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart
  5. 55
      apps/client/lib/modules/main_tab/views/main_tab_view.dart
  6. 8
      apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart
  7. 445
      apps/client/lib/modules/main_tab/views/tabs/device_tab.dart
  8. 46
      apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart
  9. 346
      apps/client/lib/modules/my_devices/views/my_devices_view.dart
  10. 361
      apps/client/lib/modules/my_devices/views/widgets/bound_device_card.dart

18
apps/client/lib/core/utils/ai_navigation.dart

@ -1,9 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../data/models/agent_module.dart';
import '../../data/models/appconfig.dart'; import '../../data/models/appconfig.dart';
import '../../data/services/agent_module_defaults.dart';
import '../../routes/app_routes.dart'; import '../../routes/app_routes.dart';
import 'id_verify_guard.dart'; import 'id_verify_guard.dart';
@ -26,19 +24,3 @@ void navigateToAIAssistant() {
Get.toNamed(Routes.emai); 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();
}

21
apps/client/lib/data/services/agent_module_defaults.dart

@ -33,27 +33,6 @@ class AgentDefaults {
return list.isEmpty ? null : list.first; return list.isEmpty ? null : list.first;
} }
/// 底部中间「AI」按钮该打开哪个对话助手(2026-09-24)。
///
/// 按「当前连着的设备,没连就最近连过的那台」→ 品类 → 该品类默认编排里**第一个对话助手**
/// ([AgentModuleService.assistantIds] 里的模块)。车载香薰返回 EaiCar 车载助手,
/// 耳机/支架的默认编排里没有专属助手,返回 null = 走通用入口(EMAI,或后台配了移动精灵时走它)。
///
/// ⚠️ 与 [primaryModuleForVendor] 不同:那个取「默认编排第一个模块」,给设备按键唤醒用;
/// 支架的第一个是语音纪要,拿它开对话页是不对的,所以这里只在助手里挑。
static String? assistantModuleForCurrentDevice() {
if (!Get.isRegistered<DeviceHub>()) 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 Worker? _worker;
static void bind(DeviceHub hub) { static void bind(DeviceHub hub) {

25
apps/client/lib/data/services/agent_module_service.dart

@ -185,31 +185,6 @@ class AgentModuleService extends GetxService {
.whereType<AgentModule>() .whereType<AgentModule>()
.toList(growable: false); .toList(growable: false);
/// 对话助手类模块(2026-09-24):统一由底部中间的「AI」按钮进入,**首页网格不再展示**。
/// 以后新增设备专属助手(像 EaiCar 这种)加到这里,AI 按钮会按设备自动接上。
///
/// ⚠️ 只是不在首页**显示**,编排里的 id 原样保留:设备默认编排、持久化、广场都不受影响,
/// 设备按键唤醒找 agent(AgentDefaults.primaryModuleForVendor)也照旧能找到它们。
static const Set<String> assistantIds = {
AgentModules.emaiAssistant,
AgentModules.eaicarChat,
};
/// 首页网格展示的模块:[enabledModules] 去掉对话助手。
List<AgentModule> 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<AgentModule> get allModules => AgentModules.all; List<AgentModule> get allModules => AgentModules.all;

3
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 openDevice() => Get.toNamed(Routes.device);
void openRecall() => Get.toNamed(Routes.recall); void openRecall() => Get.toNamed(Routes.recall);
/// 从没有过设备时,左上角是加号,点了直接进「选择设备」
void openAddDevice() => Get.toNamed(Routes.deviceCategory);
/// 扫描页连上设备后调:回到主框架的 AI 页,再把设备页推上来让用户看到连接结果。 /// 扫描页连上设备后调:回到主框架的 AI 页,再把设备页推上来让用户看到连接结果。
/// ///
/// 调用方先 `Get.offAllNamed(Routes.home)` 清掉扫描页栈,再在下一帧调这里—— /// 调用方先 `Get.offAllNamed(Routes.home)` 清掉扫描页栈,再在下一帧调这里——

55
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:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../core/utils/ai_navigation.dart';
import '../controllers/main_tab_controller.dart'; import '../controllers/main_tab_controller.dart';
import 'tabs/ai_tab.dart'; import 'tabs/ai_tab.dart';
import 'tabs/mine_tab.dart'; import 'tabs/mine_tab.dart';
import '../../../core/theme/app_palette.dart'; import '../../../core/theme/app_palette.dart';
/// App 主框架:底部「首页 · AI · 我的」(2026-09-24)。 /// App 主框架:底部「首页 · 我的」两个 tab(PageView 两页,可左右滑)。
/// ///
/// - 首页、我的是两个真正的 tab(PageView 两页,可左右滑); /// - 对话助手(EMAI / EaiCar 车载助手)是首页网格里的卡片(2026-09-26 从底部中间的
/// - 中间的「AI」是个**按钮不是页**:点了按当前设备打开对应的对话助手 /// 「AI」按钮挪回首页);
/// (EaiCar 香薰 → EaiCar 车载助手,其余 → EMAI),见 [openAiAssistant]。
/// 不做成常驻 tab 是刻意的:对话页一打开就连百炼、占麦克风,常驻会让这条连接一直挂着。
/// - 设备、拾忆是首页顶栏的入口(左上设备图标、右上日历图标),广场在「我的」里。 /// - 设备、拾忆是首页顶栏的入口(左上设备图标、右上日历图标),广场在「我的」里。
/// ///
/// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。 /// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。
@ -110,7 +107,6 @@ class MainTabView extends GetView<MainTabController> {
return Row( return Row(
children: [ children: [
tab(MainTabController.tabAi), tab(MainTabController.tabAi),
Expanded(child: _buildAiButton(isDarkMode)),
tab(MainTabController.tabMine), tab(MainTabController.tabMine),
], ],
); );
@ -121,51 +117,6 @@ class MainTabView extends GetView<MainTabController> {
} }
} }
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 的常驻行为。 /// 让 PageView 里的页面滑走后不被销毁,等价于原来 IndexedStack 的常驻行为。
class _KeepAlivePage extends StatefulWidget { class _KeepAlivePage extends StatefulWidget {
const _KeepAlivePage({required this.child}); const _KeepAlivePage({required this.child});

8
apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart

@ -69,6 +69,7 @@ class AiTab extends StatelessWidget {
leading: DeviceStatusChip( leading: DeviceStatusChip(
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
onTap: tabCtrl.openDevice, onTap: tabCtrl.openDevice,
onAdd: tabCtrl.openAddDevice,
), ),
actions: [ actions: [
SizedBox(width: 6.w), SizedBox(width: 6.w),
@ -85,8 +86,7 @@ class AiTab extends StatelessWidget {
), ),
], ],
child: Obx(() { child: Obx(() {
// 对话助手(EMAI / EaiCar 等)已挪到底部中间的「AI」按钮,首页网格不再展示 final modules = service.enabledModules;
final modules = service.homeModules;
final bool editing = tabCtrl.isEditingAi.value; final bool editing = tabCtrl.isEditingAi.value;
// 三条链路任意一条连上都算已连接:之前只看了杰理那条, // 三条链路任意一条连上都算已连接:之前只看了杰理那条,
// 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。 // 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。
@ -369,7 +369,7 @@ class AiTab extends StatelessWidget {
return DragTarget<int>( return DragTarget<int>(
onWillAcceptWithDetails: (d) => d.data != index, onWillAcceptWithDetails: (d) => d.data != index,
onAcceptWithDetails: (d) => service.moveOnHome(d.data, index), onAcceptWithDetails: (d) => service.move(d.data, index),
builder: (context, candidate, rejected) { builder: (context, candidate, rejected) {
final bool hovering = candidate.isNotEmpty; final bool hovering = candidate.isNotEmpty;
return LongPressDraggable<int>( return LongPressDraggable<int>(
@ -408,7 +408,7 @@ class AiTab extends StatelessWidget {
) { ) {
return DragTarget<int>( return DragTarget<int>(
onWillAcceptWithDetails: (d) => true, onWillAcceptWithDetails: (d) => true,
onAcceptWithDetails: (d) => service.moveOnHome(d.data, index), onAcceptWithDetails: (d) => service.move(d.data, index),
builder: (context, candidate, rejected) { builder: (context, candidate, rejected) {
final bool hovering = candidate.isNotEmpty; final bool hovering = candidate.isNotEmpty;
return Container( return Container(

445
apps/client/lib/modules/main_tab/views/tabs/device_tab.dart

@ -8,113 +8,396 @@ import '../../../../core/utils/device_connection.dart';
import '../../../../data/models/user_Info.dart'; import '../../../../data/models/user_Info.dart';
import '../../../../devices/device_hub.dart'; import '../../../../devices/device_hub.dart';
import '../../../../routes/app_routes.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/battery_indicator.dart';
import '../widgets/tab_scaffold.dart'; import '../widgets/tab_scaffold.dart';
/// 设备页 /// 设备页(从首页左上角设备图标进入的二级页面)
/// ///
/// 顶部标题栏左侧是「添加设备」的圆形加号;下面是设备图片展示区,图片四角 /// 一页到底,不再往下嵌一层(2026-09-26):
/// 叠信息:右上=连接状态,左下=授权码,右下=MAC + 电量(单/双电池自适应)。 /// 1. 设备主图,四角叠信息:右上=连接状态,左下=授权码,右下=MAC + 电量;
/// 2. 设备管理:已绑定设备的卡片直接铺在主图下面(原「设备管理」页的内容,[BoundDeviceCard]);
/// 3. 固件升级:当前/最新版本一行,设备版本低于服务端时右边直接是升级按钮,进度也在这一行里走;
/// 4. 「添加设备」按钮固定在页面最下面。
/// ///
/// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), /// **不认厂商**:设备信息来自 [DeviceHub] 的主设备快照([DeviceInfo]),
/// 电量按 [DeviceBattery] 里有什么显示什么。 /// 升级走 [DeviceOtaController](厂商差异在各自的 DeviceOtaPort 里)。
class DeviceTab extends StatelessWidget { class DeviceTab extends StatefulWidget {
const DeviceTab({Key? key, this.standalone = false}) : super(key: key); const DeviceTab({Key? key, this.standalone = false}) : super(key: key);
/// 作为独立页面打开(从 AI 页左上角设备图标进入)时为 true,标题栏带返回箭头。 /// 作为独立页面打开(从首页左上角设备图标进入)时为 true,标题栏带返回箭头。
/// 2026-09-24 起设备不再是底部 tab,目前只有这一种用法。 /// 2026-09-24 起设备不再是底部 tab,目前只有这一种用法。
final bool standalone; final bool standalone;
@override
State<DeviceTab> createState() => _DeviceTabState();
}
class _DeviceTabState extends State<DeviceTab> {
/// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动 /// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动
static const double _imageAreaHeight = 300; static const double _imageAreaHeight = 300;
/// 底部信息(左下授权码 / 右下电量)的专属高度,免得压在耳机主图上 /// 底部信息(左下授权码 / 右下电量)的专属高度,免得压在耳机主图上
static const double _bottomInfoInset = 28; 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<MyDevicesController>();
_devices = _ownDevices
? Get.put(MyDevicesController())
: Get.find<MyDevicesController>();
_ownOta = !Get.isRegistered<DeviceOtaController>();
_ota = _ownOta
? Get.put(DeviceOtaController())
: Get.find<DeviceOtaController>();
}
@override
void dispose() {
// 升级中退出已在 _handleBack 里确认并 cancel 过;onClose 负责把设备交还给正常会话
if (_ownOta && Get.isRegistered<DeviceOtaController>()) {
Get.delete<DeviceOtaController>();
}
if (_ownDevices && Get.isRegistered<MyDevicesController>()) {
Get.delete<MyDevicesController>();
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
final hub = DeviceHub.to; final hub = DeviceHub.to;
return TabScaffold( return PopScope(
// 固件推送中不能直接走:先确认,确认了再取消升级退出
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _handleBack();
},
child: TabScaffold(
slogan: 'sloganDevice'.tr, slogan: 'sloganDevice'.tr,
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
leading: _buildAddButton(isDarkMode),
titleOnRight: true, titleOnRight: true,
onBack: standalone ? Get.back : null, onBack: widget.standalone ? _handleBack : null,
child: Column(
children: [
Expanded(
child: Obx(() { child: Obx(() {
// revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它
hub.revision.value; hub.revision.value;
final bool connected = hub.anyConnected.value; final bool connected = hub.anyConnected.value;
final DeviceInfo? info = hub.primaryInfo.value; final DeviceInfo? info = hub.primaryInfo.value;
return ListView( return RefreshIndicator(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), onRefresh: _devices.loadDevices,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding:
EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
children: [ children: [
_buildImageArea(isDarkMode, connected, info), _buildImageArea(isDarkMode, connected, info),
SizedBox(height: 20.h), SizedBox(height: 20.h),
_buildEntry( _buildSectionTitle('deviceManagement'.tr, isDarkMode),
isDarkMode: isDarkMode, SizedBox(height: 10.h),
icon: Icons.device_hub, _buildBoundDevices(isDarkMode),
iconColor: AppPalette.error, SizedBox(height: 14.h),
title: 'deviceManagement'.tr, // 设备管理 _buildSectionTitle('firmwareUpgrade'.tr, isDarkMode),
subtitle: 'manageConnectedDevices'.tr, // 管理已绑定的蓝牙设备 SizedBox(height: 10.h),
onTap: () => Get.toNamed(Routes.myDevices), _buildFirmwareCard(isDarkMode),
SizedBox(height: 16.h),
],
), ),
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),
), ),
_buildAddButton(context),
], ],
),
),
); );
}), }
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) {
Widget _buildAddButton(bool isDarkMode) { return Padding(
return GestureDetector( padding: EdgeInsets.only(left: 4.w),
behavior: HitTestBehavior.opaque, child: Text(
onTap: () => Get.toNamed(Routes.deviceCategory), text,
child: Row( style: TextStyle(
mainAxisSize: MainAxisSize.min, 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: [ children: [
Container( for (final d in list)
width: 30.w, BoundDeviceCard(
height: 30.w, 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( decoration: BoxDecoration(
color: AppPalette.accentLight, color: cardBg,
shape: BoxShape.circle, borderRadius: BorderRadius.circular(16.r),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppPalette.accent.withOpacity(0.18), color: Colors.black.withOpacity(isDarkMode ? 0.06 : 0.05),
blurRadius: 8, blurRadius: 10,
offset: const Offset(0, 2), offset: const Offset(0, 4),
), ),
], ],
), ),
child: Icon(Icons.add, color: AppPalette.accent, size: 20.sp), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
SizedBox(width: 7.w), 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( Text(
'addDevice'.tr, // 添加设备 '${'otaCurrentVersion'.tr} ${cur.isEmpty ? '--' : cur}',
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : AppPalette.brand800, 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),
),
), ),
), ),
], ],
), ),
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),
),
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),
],
),
),
);
}
} }

46
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/theme/app_palette.dart';
import '../../../../core/utils/device_connection.dart'; import '../../../../core/utils/device_connection.dart';
import '../../../../data/models/user_Info.dart';
import '../../../../devices/device_hub.dart'; import '../../../../devices/device_hub.dart';
import 'battery_indicator.dart'; import 'battery_indicator.dart';
/// AI 页左上角的设备入口:产品小图标 + 电量,点击进设备页。 /// AI 页左上角的设备入口,三种形态:
///
/// - 从没连过、也没绑过任何设备:一个加号小图标,点了走 [onAdd](选择要连接的设备);
/// - 已连接:产品图标外圈绿色,右下角一个绿点,旁边显示电量,点了走 [onTap](设备页);
/// - 未连接:产品图标置灰,不显示电量,点了同样进设备页。
/// ///
/// - 已连接:图标外圈绿色,右下角一个绿点,旁边显示电量; /// - 已连接:图标外圈绿色,右下角一个绿点,旁边显示电量;
/// - 未连接:图标置灰,不显示电量。 /// - 未连接:图标置灰,不显示电量。
@ -21,11 +26,23 @@ class DeviceStatusChip extends StatelessWidget {
Key? key, Key? key,
required this.isDarkMode, required this.isDarkMode,
required this.onTap, required this.onTap,
required this.onAdd,
}) : super(key: key); }) : super(key: key);
final bool isDarkMode; final bool isDarkMode;
final VoidCallback onTap; 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 亮度系数),给未连接时的产品图标用 /// 置灰矩阵(ITU-R BT.601 亮度系数),给未连接时的产品图标用
static const ColorFilter _grayscale = ColorFilter.matrix(<double>[ static const ColorFilter _grayscale = ColorFilter.matrix(<double>[
0.299, 0.587, 0.114, 0, 0, // 0.299, 0.587, 0.114, 0, 0, //
@ -40,6 +57,8 @@ class DeviceStatusChip extends StatelessWidget {
return Obx(() { return Obx(() {
// revision 随任一会话的 info(含电量)变化 +1,读一下让 Obx 收集到它 // revision 随任一会话的 info(含电量)变化 +1,读一下让 Obx 收集到它
hub.revision.value; hub.revision.value;
// 绑定列表刷新(登录、拉设备列表)时跟着重画
User.refreshTick.value;
final bool connected = hub.anyConnected.value; final bool connected = hub.anyConnected.value;
final DeviceInfo? info = hub.primaryInfo.value; final DeviceInfo? info = hub.primaryInfo.value;
final DeviceBattery? battery = connected ? info?.battery : null; final DeviceBattery? battery = connected ? info?.battery : null;
@ -49,6 +68,8 @@ class DeviceStatusChip extends StatelessWidget {
battery.chargingLeft || battery.chargingLeft ||
battery.chargingRight); battery.chargingRight);
if (!connected && !_hasAnyDevice(hub)) return _buildAdd();
final Color ring = connected final Color ring = connected
? AppPalette.success ? AppPalette.success
: (isDarkMode ? Colors.white24 : Colors.black12); : (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),
),
);
}
} }

346
apps/client/lib/modules/my_devices/views/my_devices_view.dart

@ -1,58 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../data/models/device_images.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '/data/models/user_Info.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../controllers/my_devices_controller.dart'; import '../controllers/my_devices_controller.dart';
import 'widgets/device_settings_sheet.dart'; import 'widgets/bound_device_card.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> { class MyDevicesView extends GetView<MyDevicesController> {
const MyDevicesView({super.key}); const MyDevicesView({super.key});
@ -285,9 +237,11 @@ class MyDevicesView extends GetView<MyDevicesController> {
// 卡片自带 10 的下边距,这里不再额外加间隔,否则两者叠加显得松散 // 卡片自带 10 的下边距,这里不再额外加间隔,否则两者叠加显得松散
separatorBuilder: (_, __) => const SizedBox.shrink(), separatorBuilder: (_, __) => const SizedBox.shrink(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final d = devices[index]; return BoundDeviceCard(
final category = _MyDeviceCategory.fromValue(d.devicetype); device: devices[index],
return _buildDeviceCard(d, category, isDark); controller: controller,
isDark: isDark,
);
}, },
), ),
); );
@ -328,292 +282,4 @@ class MyDevicesView extends GetView<MyDevicesController> {
), ),
); );
} }
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),
),
),
],
),
);
}
} }

361
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),
),
),
],
),
);
}
}
Loading…
Cancel
Save