From 2dacba0e599a98396db154fd00e0d29af0670807 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Thu, 24 Sep 2026 16:10:32 +0800 Subject: [PATCH] =?UTF-8?q?client=EF=BC=9A=E5=BA=95=E9=83=A8=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E9=A6=96=E9=A1=B5=20=C2=B7=20AI=20=C2=B7=20?= =?UTF-8?q?=E6=88=91=E7=9A=84=E3=80=8D=EF=BC=8C=E8=AE=BE=E5=A4=87/?= =?UTF-8?q?=E6=8B=BE=E5=BF=86=E6=94=B6=E8=BF=9B=E9=A6=96=E9=A1=B5=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=EF=BC=8CAI=20=E6=8C=89=E9=92=AE=E6=8C=89=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8E=A5=E5=AF=B9=E5=BA=94=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 底部:原「AI」tab 改名「首页」;中间新增圆形「AI」按钮(不是常驻页,点了才打开对话页, 避免百炼连接和麦克风常驻);右边「我的」。设备、拾忆不再是 tab - 首页顶栏:左上角产品小图标(DeviceStatusChip,已连接绿圈+绿点+电量,未连接置灰), 点进设备页 /device;右上角「编辑」紧挨「日历」,日历点进拾忆页 /recall。 两个独立页复用原 DeviceTab / TodoTab(standalone 模式带返回箭头,TabScaffold 新增 onBack) - AI 按钮:openAiAssistant() 按「当前连着、没连就最近连过的设备」→ 品类默认编排里第一个对话助手: EaiCar 香薰 → EaiCar 车载助手,其余 → navigateToAIAssistant(EMAI / 移动精灵) - 首页网格不再显示对话助手(AgentModuleService.assistantIds:EMAI、EaiCar),只是不显示, 编排与设备按键唤醒找 agent 不受影响;拖拽排序改走 moveOnHome 换算下标 - 三个扫描页连上后改为回到首页再打开设备页(showDeviceAfterConnect) 验证:dart analyze 无 error;flutter test 221 全过;release 包装到 ALN-AL80 真机点过首页、 日历、设备、AI 按钮(打开 EMAI)。连着 EaiCar 时打开 EaiCar 助手这条未实测。 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 +++ .../modules/devices/views/bes_scan_view.dart | 2 +- .../devices/views/eaicar_scan_view.dart | 2 +- .../devices/views/holder_scan_view.dart | 2 +- .../controllers/main_tab_controller.dart | 28 +++- .../modules/main_tab/views/main_tab_view.dart | 139 +++++++++++------ .../modules/main_tab/views/tabs/ai_tab.dart | 55 ++++--- .../main_tab/views/tabs/device_tab.dart | 7 +- .../modules/main_tab/views/tabs/todo_tab.dart | 7 +- .../views/widgets/device_status_chip.dart | 145 ++++++++++++++++++ .../main_tab/views/widgets/tab_scaffold.dart | 23 +++ apps/client/lib/routes/app_pages.dart | 6 + apps/client/lib/routes/app_routes.dart | 2 + 15 files changed, 405 insertions(+), 77 deletions(-) create mode 100644 apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart diff --git a/apps/client/lib/core/utils/ai_navigation.dart b/apps/client/lib/core/utils/ai_navigation.dart index bed49a4e..153c6d18 100644 --- a/apps/client/lib/core/utils/ai_navigation.dart +++ b/apps/client/lib/core/utils/ai_navigation.dart @@ -1,7 +1,9 @@ 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'; @@ -24,3 +26,19 @@ 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 8f46ad1a..86d83485 100644 --- a/apps/client/lib/data/services/agent_module_defaults.dart +++ b/apps/client/lib/data/services/agent_module_defaults.dart @@ -33,6 +33,27 @@ 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 bab1885a..a6b7e04e 100644 --- a/apps/client/lib/data/services/agent_module_service.dart +++ b/apps/client/lib/data/services/agent_module_service.dart @@ -185,6 +185,31 @@ 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/devices/views/bes_scan_view.dart b/apps/client/lib/modules/devices/views/bes_scan_view.dart index 23e7d3ac..72e119d4 100644 --- a/apps/client/lib/modules/devices/views/bes_scan_view.dart +++ b/apps/client/lib/modules/devices/views/bes_scan_view.dart @@ -79,7 +79,7 @@ class _BesScanViewState extends State { Get.offAllNamed(Routes.home); WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { - MainTabController.to.changeTab(MainTabController.tabDevice); + MainTabController.to.showDeviceAfterConnect(); } }); } diff --git a/apps/client/lib/modules/devices/views/eaicar_scan_view.dart b/apps/client/lib/modules/devices/views/eaicar_scan_view.dart index da4cb5da..ce04fbd8 100644 --- a/apps/client/lib/modules/devices/views/eaicar_scan_view.dart +++ b/apps/client/lib/modules/devices/views/eaicar_scan_view.dart @@ -133,7 +133,7 @@ class _EaiCarScanViewState extends State { Get.offAllNamed(Routes.home); WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { - MainTabController.to.changeTab(MainTabController.tabDevice); + MainTabController.to.showDeviceAfterConnect(); } }); } else { diff --git a/apps/client/lib/modules/devices/views/holder_scan_view.dart b/apps/client/lib/modules/devices/views/holder_scan_view.dart index b9ece800..f39afd6c 100644 --- a/apps/client/lib/modules/devices/views/holder_scan_view.dart +++ b/apps/client/lib/modules/devices/views/holder_scan_view.dart @@ -106,7 +106,7 @@ class _HolderScanViewState extends State { Get.offAllNamed(Routes.home); WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { - MainTabController.to.changeTab(MainTabController.tabDevice); + MainTabController.to.showDeviceAfterConnect(); } }); } else { 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 fc8b672b..f6af4f1f 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 @@ -3,17 +3,17 @@ import 'package:get/get.dart'; import '../../../routes/app_routes.dart'; -/// 底部四个 tab 的索引管理。 +/// 底部 tab 的索引管理。 /// -/// 广场原来是第 5 个 tab,现在收进「我的」里当一个入口([Routes.plaza]), -/// 底部只剩 设备 / 代办 / AI / 我的 四个。 +/// 底部只剩 AI / 我的 两个(2026-09-24): +/// - 广场早先收进「我的」里当入口([Routes.plaza]); +/// - 设备改成 AI 页左上角的设备图标,点进 [Routes.device]; +/// - 拾忆改成 AI 页右上角的日历图标,点进 [Routes.recall]。 class MainTabController extends GetxController { static MainTabController get to => Get.find(); - static const int tabDevice = 0; - static const int tabTodo = 1; - static const int tabAi = 2; - static const int tabMine = 3; + static const int tabAi = 0; + static const int tabMine = 1; final currentIndex = tabAi.obs; @@ -60,6 +60,20 @@ class MainTabController extends GetxController { void toggleAiEditing() => isEditingAi.value = !isEditingAi.value; + /// 打开设备页 / 拾忆页(AI 页顶栏的两个入口) + void openDevice() => Get.toNamed(Routes.device); + void openRecall() => Get.toNamed(Routes.recall); + + /// 扫描页连上设备后调:回到主框架的 AI 页,再把设备页推上来让用户看到连接结果。 + /// + /// 调用方先 `Get.offAllNamed(Routes.home)` 清掉扫描页栈,再在下一帧调这里—— + /// 那时主框架刚重建,PageView 还没挂上 controller,所以只改状态、不驱动动画。 + void showDeviceAfterConnect() { + currentIndex.value = tabAi; + if (pageController.hasClients) pageController.jumpToPage(tabAi); + openDevice(); + } + /// 广场添加模块后跳回 AI 页查看(新模块在最下面)。 /// 广场现在是压在主框架之上的独立路由,先关掉它再切 tab。 void goToAi() { 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 1a004aac..b4390ad7 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,14 +2,19 @@ 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/device_tab.dart'; import 'tabs/mine_tab.dart'; -import 'tabs/todo_tab.dart'; import '../../../core/theme/app_palette.dart'; -/// App 主框架:底部五个 tab(设备 / 代办 / AI / 广场 / 我的) +/// App 主框架:底部「首页 · AI · 我的」(2026-09-24)。 +/// +/// - 首页、我的是两个真正的 tab(PageView 两页,可左右滑); +/// - 中间的「AI」是个**按钮不是页**:点了按当前设备打开对应的对话助手 +/// (EaiCar 香薰 → EaiCar 车载助手,其余 → EMAI),见 [openAiAssistant]。 +/// 不做成常驻 tab 是刻意的:对话页一打开就连百炼、占麦克风,常驻会让这条连接一直挂着。 +/// - 设备、拾忆是首页顶栏的入口(左上设备图标、右上日历图标),广场在「我的」里。 /// /// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。 /// 原先用的是 IndexedStack,为的是让各 tab 的状态(日历展开程度、AI 页 @@ -21,19 +26,12 @@ class MainTabView extends GetView { /// tab 文案要跟随语言切换,所以不能是 const 列表(.tr 是扩展方法, /// 在常量表达式里用不了),改成每次 build 时取。 + /// 下标与 PageView 页、[MainTabController.tabAi]/[MainTabController.tabMine] 一一对应。 static List<_TabItem> get _items => [ _TabItem( - icon: Icons.headset_outlined, - activeIcon: Icons.headset, - label: 'tabDevice'.tr), - _TabItem( - icon: Icons.event_note_outlined, - activeIcon: Icons.event_note, - label: 'tabTodo'.tr), - _TabItem( - icon: Icons.auto_awesome_outlined, - activeIcon: Icons.auto_awesome, - label: 'AI'), + icon: Icons.home_outlined, + activeIcon: Icons.home_rounded, + label: 'home'.tr), _TabItem( icon: Icons.person_outline, activeIcon: Icons.person, @@ -50,11 +48,7 @@ class MainTabView extends GetView { body: PageView( controller: controller.pageController, onPageChanged: controller.onPageSettled, - // 代办页的日历自带月/周两层横向 PageView,手指落在日历上时由内层 - // 先抢到手势(翻月/翻周),落在别处才走外层切 tab。 children: const [ - _KeepAlivePage(child: DeviceTab()), - _KeepAlivePage(child: TodoTab()), _KeepAlivePage(child: AiTab()), _KeepAlivePage(child: MineTab()), ], @@ -82,37 +76,43 @@ class MainTabView extends GetView { height: 58.h, child: Obx(() { final int current = controller.currentIndex.value; - return Row( - children: List.generate(_items.length, (i) { - final item = _items[i]; - final bool active = i == current; - final Color color = active - ? AppPalette.accent - : (isDarkMode ? Colors.white54 : Colors.grey[500]!); - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => controller.changeTab(i), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(active ? item.activeIcon : item.icon, - size: 22.sp, color: color), - SizedBox(height: 3.h), - Text( - item.label, - style: TextStyle( - fontSize: 10.sp, - color: color, - fontWeight: - active ? FontWeight.w600 : FontWeight.normal, - ), + Widget tab(int i) { + final item = _items[i]; + final bool active = i == current; + final Color color = active + ? AppPalette.accent + : (isDarkMode ? Colors.white54 : Colors.grey[500]!); + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => controller.changeTab(i), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(active ? item.activeIcon : item.icon, + size: 22.sp, color: color), + SizedBox(height: 3.h), + Text( + item.label, + style: TextStyle( + fontSize: 10.sp, + color: color, + fontWeight: + active ? FontWeight.w600 : FontWeight.normal, ), - ], - ), + ), + ], ), - ); - }), + ), + ); + } + + return Row( + children: [ + tab(MainTabController.tabAi), + Expanded(child: _buildAiButton(isDarkMode)), + tab(MainTabController.tabMine), + ], ); }), ), @@ -121,6 +121,51 @@ 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 3b87bd74..568acec0 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 @@ -9,6 +9,7 @@ import '../../../../data/models/agent_module.dart'; import '../../../../data/services/agent_module_service.dart'; import '../../../home/controllers/home_controller.dart'; import '../../controllers/main_tab_controller.dart'; +import '../widgets/device_status_chip.dart'; import '../widgets/module_card.dart'; import '../widgets/tab_scaffold.dart'; import '../../../../core/theme/app_palette.dart'; @@ -63,23 +64,29 @@ class AiTab extends StatelessWidget { slogan: 'sloganAi'.tr, isDarkMode: isDarkMode, titleOnRight: true, - leading: Obx(() { - final bool editing = tabCtrl.isEditingAi.value; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: tabCtrl.toggleAiEditing, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h), - child: Icon( - editing ? Icons.check_rounded : Icons.edit_outlined, - size: 20.sp, - color: AppPalette.accent, - ), - ), - ); - }), + // 顶栏(2026-09-24):左上角设备图标(点进设备页),右上角「编辑」紧挨「日历」(点进拾忆页)。 + // 设备、拾忆原本是底部 tab,现在收成这两个入口。 + leading: DeviceStatusChip( + isDarkMode: isDarkMode, + onTap: tabCtrl.openDevice, + ), + actions: [ + SizedBox(width: 6.w), + Obx(() { + final bool editing = tabCtrl.isEditingAi.value; + return _topIcon( + icon: editing ? Icons.check_rounded : Icons.edit_outlined, + onTap: tabCtrl.toggleAiEditing, + ); + }), + _topIcon( + icon: Icons.calendar_month_outlined, + onTap: tabCtrl.openRecall, + ), + ], child: Obx(() { - final modules = service.enabledModules; + // 对话助手(EMAI / EaiCar 等)已挪到底部中间的「AI」按钮,首页网格不再展示 + final modules = service.homeModules; final bool editing = tabCtrl.isEditingAi.value; // 三条链路任意一条连上都算已连接:之前只看了杰理那条, // 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。 @@ -200,6 +207,18 @@ class AiTab extends StatelessWidget { ); } + /// 顶栏右侧的小图标按钮(编辑、日历) + Widget _topIcon({required IconData icon, required VoidCallback onTap}) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h), + child: Icon(icon, size: 20.sp, color: AppPalette.accent), + ), + ); + } + /// 网格区:左大方块 + 右侧上下两个矮方块 Widget _buildGrid({ required List modules, @@ -350,7 +369,7 @@ class AiTab extends StatelessWidget { return DragTarget( onWillAcceptWithDetails: (d) => d.data != index, - onAcceptWithDetails: (d) => service.move(d.data, index), + onAcceptWithDetails: (d) => service.moveOnHome(d.data, index), builder: (context, candidate, rejected) { final bool hovering = candidate.isNotEmpty; return LongPressDraggable( @@ -389,7 +408,7 @@ class AiTab extends StatelessWidget { ) { return DragTarget( onWillAcceptWithDetails: (d) => true, - onAcceptWithDetails: (d) => service.move(d.data, index), + onAcceptWithDetails: (d) => service.moveOnHome(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 68b054b7..15accd36 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 @@ -19,7 +19,11 @@ import '../widgets/tab_scaffold.dart'; /// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), /// 电量按 [DeviceBattery] 里有什么显示什么。 class DeviceTab extends StatelessWidget { - const DeviceTab({Key? key}) : super(key: key); + const DeviceTab({Key? key, this.standalone = false}) : super(key: key); + + /// 作为独立页面打开(从 AI 页左上角设备图标进入)时为 true,标题栏带返回箭头。 + /// 2026-09-24 起设备不再是底部 tab,目前只有这一种用法。 + final bool standalone; /// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动 static const double _imageAreaHeight = 300; @@ -37,6 +41,7 @@ class DeviceTab extends StatelessWidget { isDarkMode: isDarkMode, leading: _buildAddButton(isDarkMode), titleOnRight: true, + onBack: standalone ? Get.back : null, child: Obx(() { // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 hub.revision.value; diff --git a/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart index c464643a..2bc2d7bd 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart @@ -16,7 +16,11 @@ import '../widgets/todo_scope_list.dart'; /// 都在这一层与 [TodoScopeList] 里。改成 StatefulWidget 是为了在 initState 里 /// 触发首次拉取——build 里发请求会在每次重建时重复触发。 class TodoTab extends StatefulWidget { - const TodoTab({Key? key}) : super(key: key); + const TodoTab({Key? key, this.standalone = false}) : super(key: key); + + /// 作为独立页面打开(从 AI 页右上角日历图标进入)时为 true,标题栏带返回箭头。 + /// 2026-09-24 起拾忆不再是底部 tab,目前只有这一种用法。 + final bool standalone; @override State createState() => _TodoTabState(); @@ -42,6 +46,7 @@ class _TodoTabState extends State { slogan: 'sloganHome'.tr, titleOnRight: true, isDarkMode: isDarkMode, + onBack: widget.standalone ? Get.back : null, // ⚠️ 这里原来有个右下角的 + 号(手动新增事项)。2026-09-06 按产品要求摘掉: // 拾忆的内容应当由助手/会议纪要自动落进来,没人有时间手动录。 // 卡片点开编辑仍然保留(TodoScopeList 里),那是改助手记错的时间/内容用的, 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 new file mode 100644 index 00000000..04cb25c2 --- /dev/null +++ b/apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart @@ -0,0 +1,145 @@ +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 '../../../../devices/device_hub.dart'; +import 'battery_indicator.dart'; + +/// AI 页左上角的设备入口:产品小图标 + 电量,点击进设备页。 +/// +/// - 已连接:图标外圈绿色,右下角一个绿点,旁边显示电量; +/// - 未连接:图标置灰,不显示电量。 +/// +/// 图标取「当前连着的、没连就最近连过的」那台设备([DeviceConnection.currentIconImage]), +/// 从没连过时是通用图。**不认厂商**:电量按 [DeviceBattery.summary] 有什么显示什么, +/// TWS 耳机给的是左右较低的一侧,支架这类拿不到电量的设备就只显示图标。 +class DeviceStatusChip extends StatelessWidget { + const DeviceStatusChip({ + Key? key, + required this.isDarkMode, + required this.onTap, + }) : super(key: key); + + final bool isDarkMode; + final VoidCallback onTap; + + /// 置灰矩阵(ITU-R BT.601 亮度系数),给未连接时的产品图标用 + static const ColorFilter _grayscale = ColorFilter.matrix([ + 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; + 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); + + 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, + ), + ], + ], + ), + ); + }); + } +} diff --git a/apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart b/apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart index 43862fd3..0f5e7ab7 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart @@ -16,8 +16,15 @@ class TabScaffold extends StatelessWidget { this.leading, this.titleOnRight = false, this.actions = const [], + this.onBack, }) : super(key: key); + /// 非空时在标题栏最左侧画一个返回箭头。 + /// + /// 设备页、拾忆页原来是底部 tab,2026-09-24 改成从 AI 页点进去的独立页面, + /// 复用同一个组件;作为独立页面时要有返回入口,作为 tab 时不传。 + final VoidCallback? onBack; + /// 标题左侧的小控件(设备页放「添加设备」的圆形加号 + 文字) final Widget? leading; @@ -87,6 +94,22 @@ class TabScaffold extends StatelessWidget { padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 4.h), child: Row( children: [ + if (onBack != null) ...[ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBack, + child: Padding( + padding: EdgeInsets.only(right: 6.w), + child: Icon( + Icons.arrow_back_ios_new_rounded, + size: 20.sp, + color: isDarkMode + ? Colors.white70 + : AppPalette.brand800, + ), + ), + ), + ], if (leading != null) ...[ leading!, SizedBox(width: 10.w), diff --git a/apps/client/lib/routes/app_pages.dart b/apps/client/lib/routes/app_pages.dart index 33c1506b..f049ce78 100644 --- a/apps/client/lib/routes/app_pages.dart +++ b/apps/client/lib/routes/app_pages.dart @@ -18,7 +18,9 @@ import '../modules/login/bindings/login_binding.dart'; import '../modules/login/views/login_view.dart'; import '../modules/main_tab/bindings/main_tab_binding.dart'; import '../modules/main_tab/views/main_tab_view.dart'; +import '../modules/main_tab/views/tabs/device_tab.dart'; import '../modules/main_tab/views/tabs/plaza_tab.dart'; +import '../modules/main_tab/views/tabs/todo_tab.dart'; import '../modules/mobile_elf/bindings/mobile_elf_binding.dart'; import '../modules/mobile_elf/views/mobile_elf_view.dart'; import '../modules/my_devices/bindings/my_devices_binding.dart'; @@ -96,6 +98,10 @@ abstract class AppPages { _page(Routes.eaicarScan, () => const EaiCarScanView()), _page(Routes.besScan, () => const BesScanView()), _page(Routes.plaza, () => const PlazaTab()), + // 设备、拾忆原是底部 tab,2026-09-24 改为从 AI 页顶栏进入的独立页面。 + // 依赖的 HomeController / CalendarController 由 MainTabBinding 注册,主框架始终在栈底,这里不用再绑。 + _page(Routes.device, () => const DeviceTab(standalone: true)), + _page(Routes.recall, () => const TodoTab(standalone: true)), _page(Routes.myDevices, () => const MyDevicesView(), binding: MyDevicesBinding()), _page(Routes.payment, () => const PaymentView(), diff --git a/apps/client/lib/routes/app_routes.dart b/apps/client/lib/routes/app_routes.dart index 25b73ed0..ab0503e5 100644 --- a/apps/client/lib/routes/app_routes.dart +++ b/apps/client/lib/routes/app_routes.dart @@ -15,6 +15,8 @@ abstract class Routes { // 设备 static const otaUpgrade = '/ota_upgrade'; // 固件升级 + static const device = '/device'; // 设备页(原底部「设备」tab,2026-09-24 改为从 AI 页左上角设备图标进入) + static const recall = '/recall'; // 拾忆页(原底部「拾忆」tab,2026-09-24 改为从 AI 页右上角日历图标进入) static const deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类 static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接 static const eaicarScan = '/eaicar_scan'; // EaiCar 车载香薰扫描/连接