Browse Source

client:底部改为「首页 · AI · 我的」,设备/拾忆收进首页顶栏,AI 按钮按设备接对应助手

- 底部:原「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) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
2dacba0e59
  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. 2
      apps/client/lib/modules/devices/views/bes_scan_view.dart
  5. 2
      apps/client/lib/modules/devices/views/eaicar_scan_view.dart
  6. 2
      apps/client/lib/modules/devices/views/holder_scan_view.dart
  7. 28
      apps/client/lib/modules/main_tab/controllers/main_tab_controller.dart
  8. 87
      apps/client/lib/modules/main_tab/views/main_tab_view.dart
  9. 47
      apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart
  10. 7
      apps/client/lib/modules/main_tab/views/tabs/device_tab.dart
  11. 7
      apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart
  12. 145
      apps/client/lib/modules/main_tab/views/widgets/device_status_chip.dart
  13. 23
      apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart
  14. 6
      apps/client/lib/routes/app_pages.dart
  15. 2
      apps/client/lib/routes/app_routes.dart

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

@ -1,7 +1,9 @@
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';
@ -24,3 +26,19 @@ 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,6 +33,27 @@ 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,6 +185,31 @@ 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;

2
apps/client/lib/modules/devices/views/bes_scan_view.dart

@ -79,7 +79,7 @@ class _BesScanViewState extends State<BesScanView> {
Get.offAllNamed(Routes.home); Get.offAllNamed(Routes.home);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.isRegistered<MainTabController>()) { if (Get.isRegistered<MainTabController>()) {
MainTabController.to.changeTab(MainTabController.tabDevice); MainTabController.to.showDeviceAfterConnect();
} }
}); });
} }

2
apps/client/lib/modules/devices/views/eaicar_scan_view.dart

@ -133,7 +133,7 @@ class _EaiCarScanViewState extends State<EaiCarScanView> {
Get.offAllNamed(Routes.home); Get.offAllNamed(Routes.home);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.isRegistered<MainTabController>()) { if (Get.isRegistered<MainTabController>()) {
MainTabController.to.changeTab(MainTabController.tabDevice); MainTabController.to.showDeviceAfterConnect();
} }
}); });
} else { } else {

2
apps/client/lib/modules/devices/views/holder_scan_view.dart

@ -106,7 +106,7 @@ class _HolderScanViewState extends State<HolderScanView> {
Get.offAllNamed(Routes.home); Get.offAllNamed(Routes.home);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.isRegistered<MainTabController>()) { if (Get.isRegistered<MainTabController>()) {
MainTabController.to.changeTab(MainTabController.tabDevice); MainTabController.to.showDeviceAfterConnect();
} }
}); });
} else { } else {

28
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'; import '../../../routes/app_routes.dart';
/// 底部四个 tab 的索引管理。 /// 底部 tab 的索引管理。
/// ///
/// 广场原来是第 5 个 tab,现在收进「我的」里当一个入口([Routes.plaza]), /// 底部只剩 AI / 我的 两个(2026-09-24):
/// 底部只剩 设备 / 代办 / AI / 我的 四个。 /// - 广场早先收进「我的」里当入口([Routes.plaza]);
/// - 设备改成 AI 页左上角的设备图标,点进 [Routes.device];
/// - 拾忆改成 AI 页右上角的日历图标,点进 [Routes.recall]。
class MainTabController extends GetxController { class MainTabController extends GetxController {
static MainTabController get to => Get.find(); static MainTabController get to => Get.find();
static const int tabDevice = 0; static const int tabAi = 0;
static const int tabTodo = 1; static const int tabMine = 1;
static const int tabAi = 2;
static const int tabMine = 3;
final currentIndex = tabAi.obs; final currentIndex = tabAi.obs;
@ -60,6 +60,20 @@ class MainTabController extends GetxController {
void toggleAiEditing() => isEditingAi.value = !isEditingAi.value; 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 页查看(新模块在最下面)。 /// 广场添加模块后跳回 AI 页查看(新模块在最下面)。
/// 广场现在是压在主框架之上的独立路由,先关掉它再切 tab。 /// 广场现在是压在主框架之上的独立路由,先关掉它再切 tab。
void goToAi() { void goToAi() {

87
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: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/device_tab.dart';
import 'tabs/mine_tab.dart'; import 'tabs/mine_tab.dart';
import 'tabs/todo_tab.dart';
import '../../../core/theme/app_palette.dart'; import '../../../core/theme/app_palette.dart';
/// App 主框架:底部五个 tab(设备 / 代办 / AI / 广场 / 我的) /// App 主框架:底部「首页 · AI · 我的」(2026-09-24)。
///
/// - 首页、我的是两个真正的 tab(PageView 两页,可左右滑);
/// - 中间的「AI」是个**按钮不是页**:点了按当前设备打开对应的对话助手
/// (EaiCar 香薰 → EaiCar 车载助手,其余 → EMAI),见 [openAiAssistant]。
/// 不做成常驻 tab 是刻意的:对话页一打开就连百炼、占麦克风,常驻会让这条连接一直挂着。
/// - 设备、拾忆是首页顶栏的入口(左上设备图标、右上日历图标),广场在「我的」里。
/// ///
/// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。 /// 用 PageView 承载,底部按钮点击和手指左右滑动都能切页。
/// 原先用的是 IndexedStack,为的是让各 tab 的状态(日历展开程度、AI 页 /// 原先用的是 IndexedStack,为的是让各 tab 的状态(日历展开程度、AI 页
@ -21,19 +26,12 @@ class MainTabView extends GetView<MainTabController> {
/// tab 文案要跟随语言切换,所以不能是 const 列表(.tr 是扩展方法, /// tab 文案要跟随语言切换,所以不能是 const 列表(.tr 是扩展方法,
/// 在常量表达式里用不了),改成每次 build 时取。 /// 在常量表达式里用不了),改成每次 build 时取。
/// 下标与 PageView 页、[MainTabController.tabAi]/[MainTabController.tabMine] 一一对应。
static List<_TabItem> get _items => [ static List<_TabItem> get _items => [
_TabItem( _TabItem(
icon: Icons.headset_outlined, icon: Icons.home_outlined,
activeIcon: Icons.headset, activeIcon: Icons.home_rounded,
label: 'tabDevice'.tr), label: 'home'.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'),
_TabItem( _TabItem(
icon: Icons.person_outline, icon: Icons.person_outline,
activeIcon: Icons.person, activeIcon: Icons.person,
@ -50,11 +48,7 @@ class MainTabView extends GetView<MainTabController> {
body: PageView( body: PageView(
controller: controller.pageController, controller: controller.pageController,
onPageChanged: controller.onPageSettled, onPageChanged: controller.onPageSettled,
// 代办页的日历自带月/周两层横向 PageView,手指落在日历上时由内层
// 先抢到手势(翻月/翻周),落在别处才走外层切 tab。
children: const [ children: const [
_KeepAlivePage(child: DeviceTab()),
_KeepAlivePage(child: TodoTab()),
_KeepAlivePage(child: AiTab()), _KeepAlivePage(child: AiTab()),
_KeepAlivePage(child: MineTab()), _KeepAlivePage(child: MineTab()),
], ],
@ -82,8 +76,7 @@ class MainTabView extends GetView<MainTabController> {
height: 58.h, height: 58.h,
child: Obx(() { child: Obx(() {
final int current = controller.currentIndex.value; final int current = controller.currentIndex.value;
return Row( Widget tab(int i) {
children: List.generate(_items.length, (i) {
final item = _items[i]; final item = _items[i];
final bool active = i == current; final bool active = i == current;
final Color color = active final Color color = active
@ -112,7 +105,14 @@ class MainTabView extends GetView<MainTabController> {
), ),
), ),
); );
}), }
return Row(
children: [
tab(MainTabController.tabAi),
Expanded(child: _buildAiButton(isDarkMode)),
tab(MainTabController.tabMine),
],
); );
}), }),
), ),
@ -121,6 +121,51 @@ 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});

47
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 '../../../../data/services/agent_module_service.dart';
import '../../../home/controllers/home_controller.dart'; import '../../../home/controllers/home_controller.dart';
import '../../controllers/main_tab_controller.dart'; import '../../controllers/main_tab_controller.dart';
import '../widgets/device_status_chip.dart';
import '../widgets/module_card.dart'; import '../widgets/module_card.dart';
import '../widgets/tab_scaffold.dart'; import '../widgets/tab_scaffold.dart';
import '../../../../core/theme/app_palette.dart'; import '../../../../core/theme/app_palette.dart';
@ -63,23 +64,29 @@ class AiTab extends StatelessWidget {
slogan: 'sloganAi'.tr, slogan: 'sloganAi'.tr,
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
titleOnRight: true, titleOnRight: true,
leading: Obx(() { // 顶栏(2026-09-24):左上角设备图标(点进设备页),右上角「编辑」紧挨「日历」(点进拾忆页)。
// 设备、拾忆原本是底部 tab,现在收成这两个入口。
leading: DeviceStatusChip(
isDarkMode: isDarkMode,
onTap: tabCtrl.openDevice,
),
actions: [
SizedBox(width: 6.w),
Obx(() {
final bool editing = tabCtrl.isEditingAi.value; final bool editing = tabCtrl.isEditingAi.value;
return GestureDetector( return _topIcon(
behavior: HitTestBehavior.opaque, icon: editing ? Icons.check_rounded : Icons.edit_outlined,
onTap: tabCtrl.toggleAiEditing, 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,
),
),
); );
}), }),
_topIcon(
icon: Icons.calendar_month_outlined,
onTap: tabCtrl.openRecall,
),
],
child: Obx(() { child: Obx(() {
final modules = service.enabledModules; // 对话助手(EMAI / EaiCar 等)已挪到底部中间的「AI」按钮,首页网格不再展示
final modules = service.homeModules;
final bool editing = tabCtrl.isEditingAi.value; final bool editing = tabCtrl.isEditingAi.value;
// 三条链路任意一条连上都算已连接:之前只看了杰理那条, // 三条链路任意一条连上都算已连接:之前只看了杰理那条,
// 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。 // 恒玄耳机/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({ Widget _buildGrid({
required List<AgentModule> modules, required List<AgentModule> modules,
@ -350,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.move(d.data, index), onAcceptWithDetails: (d) => service.moveOnHome(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>(
@ -389,7 +408,7 @@ class AiTab extends StatelessWidget {
) { ) {
return DragTarget<int>( return DragTarget<int>(
onWillAcceptWithDetails: (d) => true, onWillAcceptWithDetails: (d) => true,
onAcceptWithDetails: (d) => service.move(d.data, index), onAcceptWithDetails: (d) => service.moveOnHome(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(

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

@ -19,7 +19,11 @@ import '../widgets/tab_scaffold.dart';
/// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), /// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]),
/// 电量按 [DeviceBattery] 里有什么显示什么。 /// 电量按 [DeviceBattery] 里有什么显示什么。
class DeviceTab extends StatelessWidget { 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; static const double _imageAreaHeight = 300;
@ -37,6 +41,7 @@ class DeviceTab extends StatelessWidget {
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
leading: _buildAddButton(isDarkMode), leading: _buildAddButton(isDarkMode),
titleOnRight: true, titleOnRight: true,
onBack: standalone ? Get.back : null,
child: Obx(() { child: Obx(() {
// revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它
hub.revision.value; hub.revision.value;

7
apps/client/lib/modules/main_tab/views/tabs/todo_tab.dart

@ -16,7 +16,11 @@ import '../widgets/todo_scope_list.dart';
/// 都在这一层与 [TodoScopeList] 里。改成 StatefulWidget 是为了在 initState 里 /// 都在这一层与 [TodoScopeList] 里。改成 StatefulWidget 是为了在 initState 里
/// 触发首次拉取——build 里发请求会在每次重建时重复触发。 /// 触发首次拉取——build 里发请求会在每次重建时重复触发。
class TodoTab extends StatefulWidget { 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 @override
State<TodoTab> createState() => _TodoTabState(); State<TodoTab> createState() => _TodoTabState();
@ -42,6 +46,7 @@ class _TodoTabState extends State<TodoTab> {
slogan: 'sloganHome'.tr, slogan: 'sloganHome'.tr,
titleOnRight: true, titleOnRight: true,
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
onBack: widget.standalone ? Get.back : null,
// ⚠️ 这里原来有个右下角的 + 号(手动新增事项)。2026-09-06 按产品要求摘掉: // ⚠️ 这里原来有个右下角的 + 号(手动新增事项)。2026-09-06 按产品要求摘掉:
// 拾忆的内容应当由助手/会议纪要自动落进来,没人有时间手动录。 // 拾忆的内容应当由助手/会议纪要自动落进来,没人有时间手动录。
// 卡片点开编辑仍然保留(TodoScopeList 里),那是改助手记错的时间/内容用的, // 卡片点开编辑仍然保留(TodoScopeList 里),那是改助手记错的时间/内容用的,

145
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(<double>[
0.299, 0.587, 0.114, 0, 0, //
0.299, 0.587, 0.114, 0, 0, //
0.299, 0.587, 0.114, 0, 0, //
0, 0, 0, 1, 0,
]);
@override
Widget build(BuildContext context) {
final hub = DeviceHub.to;
return Obx(() {
// revision 随任一会话的 info(含电量)变化 +1,读一下让 Obx 收集到它
hub.revision.value;
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,
),
],
],
),
);
});
}
}

23
apps/client/lib/modules/main_tab/views/widgets/tab_scaffold.dart

@ -16,8 +16,15 @@ class TabScaffold extends StatelessWidget {
this.leading, this.leading,
this.titleOnRight = false, this.titleOnRight = false,
this.actions = const [], this.actions = const [],
this.onBack,
}) : super(key: key); }) : super(key: key);
/// 非空时在标题栏最左侧画一个返回箭头。
///
/// 设备页、拾忆页原来是底部 tab,2026-09-24 改成从 AI 页点进去的独立页面,
/// 复用同一个组件;作为独立页面时要有返回入口,作为 tab 时不传。
final VoidCallback? onBack;
/// 标题左侧的小控件(设备页放「添加设备」的圆形加号 + 文字) /// 标题左侧的小控件(设备页放「添加设备」的圆形加号 + 文字)
final Widget? leading; final Widget? leading;
@ -87,6 +94,22 @@ class TabScaffold extends StatelessWidget {
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 4.h), padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 4.h),
child: Row( child: Row(
children: [ 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) ...[ if (leading != null) ...[
leading!, leading!,
SizedBox(width: 10.w), SizedBox(width: 10.w),

6
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/login/views/login_view.dart';
import '../modules/main_tab/bindings/main_tab_binding.dart'; import '../modules/main_tab/bindings/main_tab_binding.dart';
import '../modules/main_tab/views/main_tab_view.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/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/bindings/mobile_elf_binding.dart';
import '../modules/mobile_elf/views/mobile_elf_view.dart'; import '../modules/mobile_elf/views/mobile_elf_view.dart';
import '../modules/my_devices/bindings/my_devices_binding.dart'; import '../modules/my_devices/bindings/my_devices_binding.dart';
@ -96,6 +98,10 @@ abstract class AppPages {
_page(Routes.eaicarScan, () => const EaiCarScanView()), _page(Routes.eaicarScan, () => const EaiCarScanView()),
_page(Routes.besScan, () => const BesScanView()), _page(Routes.besScan, () => const BesScanView()),
_page(Routes.plaza, () => const PlazaTab()), _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(), _page(Routes.myDevices, () => const MyDevicesView(),
binding: MyDevicesBinding()), binding: MyDevicesBinding()),
_page(Routes.payment, () => const PaymentView(), _page(Routes.payment, () => const PaymentView(),

2
apps/client/lib/routes/app_routes.dart

@ -15,6 +15,8 @@ abstract class Routes {
// 设备 // 设备
static const otaUpgrade = '/ota_upgrade'; // 固件升级 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 deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类
static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接 static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接
static const eaicarScan = '/eaicar_scan'; // EaiCar 车载香薰扫描/连接 static const eaicarScan = '/eaicar_scan'; // EaiCar 车载香薰扫描/连接

Loading…
Cancel
Save