From bdc33b822f9dcac39951ed219aee68e973c19f14 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 4 Aug 2026 19:35:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=A0=B8=E9=94=80=E6=94=BE=E5=A4=A7=20fea?= =?UTF-8?q?t:=E9=94=81=E5=B1=8F=E9=A1=B5=E9=9D=A2=20feat:=E9=A5=AD?= =?UTF-8?q?=E5=8D=A1=E8=87=AA=E5=8A=A8=E6=9F=A5=E8=AF=A2=E9=A2=84=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../page/reservation_menu_page_models.dart | 7 + lib/common/utils/app/app_setting_tool.dart | 10 + .../app/control/terminal_sleep_control.dart | 43 ++ lib/common/utils/index.dart | 1 + lib/common/widgets/app/terminal_shell.dart | 405 +++++++++++++++++- lib/main.dart | 4 + lib/pages/terminal/menu_query/controller.dart | 59 ++- lib/pages/terminal/menu_query/view.dart | 2 + .../widgets/query_method_panel.dart | 116 ++++- .../reservation_verification_dialog.dart | 94 ++-- lib/pages/terminal/setting/controller.dart | 14 + .../setting/widgets/setting_page.dart | 26 +- pubspec.yaml | 2 +- test/common/terminal_sleep_control_test.dart | 96 +++++ .../terminal/menu_query_controller_test.dart | 115 +++++ .../terminal/query_method_panel_test.dart | 69 +++ .../reservation_verification_dialog_test.dart | 87 +++- 17 files changed, 1061 insertions(+), 89 deletions(-) create mode 100644 lib/common/utils/app/control/terminal_sleep_control.dart create mode 100644 test/common/terminal_sleep_control_test.dart create mode 100644 test/pages/terminal/query_method_panel_test.dart diff --git a/lib/common/models/page/reservation_menu_page_models.dart b/lib/common/models/page/reservation_menu_page_models.dart index cb3a938..8a05786 100644 --- a/lib/common/models/page/reservation_menu_page_models.dart +++ b/lib/common/models/page/reservation_menu_page_models.dart @@ -76,11 +76,14 @@ enum ReservationVerificationDateScope { final String title; } +/// 查询菜单在本机保存的页面交互偏好,不改变预约接口和硬件协议。 @immutable class ReservationQueryPagePreferences { + /// 创建查询菜单偏好;未显式保存的布尔配置使用兼容旧终端的安全默认值。 const ReservationQueryPagePreferences({ required this.methodOrder, required this.defaultMethod, + this.autoRecognitionEnabled = false, this.nameMaskEnabled = true, this.verificationEnabled = false, this.verificationDateScope = ReservationVerificationDateScope.today, @@ -90,6 +93,7 @@ class ReservationQueryPagePreferences { static const defaults = ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, + autoRecognitionEnabled: false, nameMaskEnabled: true, verificationEnabled: false, verificationDateScope: ReservationVerificationDateScope.today, @@ -101,6 +105,9 @@ class ReservationQueryPagePreferences { /// 页面首次进入时默认选中的查询方式。 final ReservationRecognitionMethod defaultMethod; + /// 是否在进入页面和切换查询方式后自动启动对应识别设备,默认关闭以保持旧终端行为。 + final bool autoRecognitionEnabled; + /// 查询结果中的用户姓名是否按服务端规则脱敏,默认开启以兼容旧终端。 final bool nameMaskEnabled; diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index f3821cf..5f38b9f 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -20,6 +20,10 @@ abstract class AppSettingTool { "reservationQueryMethodOrder"; static const String _reservationQueryDefaultMethodKey = "reservationQueryDefaultMethod"; + + /// 查询菜单自动识别开关在现有 sysJson 中的稳定存储键,缺失时按关闭处理。 + static const String _reservationQueryAutoRecognitionEnabledKey = + "reservationQueryAutoRecognitionEnabled"; static const String _reservationQueryNameMaskEnabledKey = "reservationQueryNameMaskEnabled"; static const String _reservationQueryVerificationEnabledKey = @@ -467,6 +471,9 @@ abstract class AppSettingTool { return ReservationQueryPagePreferences( methodOrder: List.unmodifiable(restored), defaultMethod: defaultMethod, + // 旧设置没有该键时保持手动启动,避免升级后直接拉起读卡器或相机。 + autoRecognitionEnabled: + sysJson[_reservationQueryAutoRecognitionEnabledKey] == true, // 旧设置没有该键时继续脱敏,避免升级后意外展示完整姓名。 nameMaskEnabled: sysJson[_reservationQueryNameMaskEnabledKey] != false, verificationEnabled: @@ -479,6 +486,7 @@ abstract class AppSettingTool { static Future setReservationQueryPagePreferences({ required List order, required ReservationRecognitionMethod defaultMethod, + required bool autoRecognitionEnabled, required bool nameMaskEnabled, required bool verificationEnabled, required ReservationVerificationDateScope verificationDateScope, @@ -496,6 +504,8 @@ abstract class AppSettingTool { sysJson[_reservationQueryMethodOrderKey] = normalized.map((method) => method.code).toList(); sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code; + sysJson[_reservationQueryAutoRecognitionEnabledKey] = + autoRecognitionEnabled; sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled; sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled; sysJson[_reservationQueryVerificationDateScopeKey] = diff --git a/lib/common/utils/app/control/terminal_sleep_control.dart b/lib/common/utils/app/control/terminal_sleep_control.dart new file mode 100644 index 0000000..e83047c --- /dev/null +++ b/lib/common/utils/app/control/terminal_sleep_control.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; + +/// 执行一次终端唤醒身份校验,返回是否允许解除睡眠遮罩。 +typedef TerminalWakeVerifier = Future Function(); + +/// 管理主屏睡眠遮罩及唤醒验证的应用级状态。 +/// +/// 睡眠仅阻止主屏查看和触摸,不切换路由、不销毁页面 Controller,也不暂停串口、 +/// 人脸、网络请求或副屏通信。状态仅保存在当前进程,设备重新启动后不自动进入睡眠。 +class TerminalSleepControl extends GetxController { + /// 创建一份默认处于唤醒状态的主屏睡眠控制器。 + TerminalSleepControl(); + + /// 主屏是否正在展示全屏睡眠遮罩。 + final isSleeping = false.obs; + + /// 是否已有唤醒密码校验正在进行,防止快速点击叠加多个密码弹窗。 + bool _wakeVerificationInProgress = false; + + /// 立即进入睡眠状态;重复调用保持幂等且不影响当前业务页面生命周期。 + void enterSleep() { + if (!isSleeping.value) isSleeping.value = true; + } + + /// 通过调用方提供的密码校验请求唤醒主屏。 + /// + /// 校验取消、失败或重复提交时继续保持睡眠;校验过程抛出的异常会向上返回, + /// 由界面层统一记录并提示,同时保证下一次唤醒仍可重新发起。 + Future requestWake(TerminalWakeVerifier verifier) async { + if (!isSleeping.value) return true; + if (_wakeVerificationInProgress) return false; + + _wakeVerificationInProgress = true; + try { + final verified = await verifier(); + if (!verified) return false; + isSleeping.value = false; + return true; + } finally { + _wakeVerificationInProgress = false; + } + } +} diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 35e32d8..5f45cd9 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -4,6 +4,7 @@ export './app/control/app_control.dart'; export './app/control/app_theme_control.dart'; export './app/control/terminal_admin_session_control.dart'; export './app/control/terminal_menu_control.dart'; +export './app/control/terminal_sleep_control.dart'; export './app/app_face_tool.dart'; export './app/app_device_status_tool.dart'; diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 79373bd..6246a2e 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -42,6 +42,7 @@ extension on TerminalNavType { /// 顶部设备状态和左侧一级导航必须常驻,业务页面只负责右侧工作区,避免每个页面 /// 自己创建返回标题栏后破坏收银终端的连续操作体验。 class TerminalShell extends StatelessWidget { + /// 创建一个保留终端顶栏、侧栏和业务页面生命周期的主屏外壳。 const TerminalShell({ super.key, required this.title, @@ -50,38 +51,58 @@ class TerminalShell extends StatelessWidget { this.headerLeading, }); + /// 当前内层业务路由显示在顶栏的标题。 final String title; + + /// 当前内层业务路由对应的侧栏选中项。 final TerminalNavType activeNav; + + /// 持续保留在睡眠遮罩下方的业务页面。 final Widget child; + + /// 标题右侧按业务页面需要插入的附加控件。 final Widget? headerLeading; + /// 构建终端常驻框架,并仅在最上层响应式切换睡眠遮罩。 @override Widget build(BuildContext context) { final menuController = Get.find(); + final sleepControl = Get.find(); return ColoredBox( color: XfjDesignTokens.canvas, - child: SafeArea( - child: Column( - children: [ - TerminalHeader(title: title, leading: headerLeading), - Padding( - padding: EdgeInsets.all(8.w), - child: Obx( - () => Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (menuController.shouldShowSideNav) ...[ - TerminalSideNav(active: activeNav), - SizedBox(width: XfjDesignTokens.cardGap.w), + child: [ + SafeArea( + child: Column( + children: [ + TerminalHeader(title: title, leading: headerLeading), + Padding( + padding: EdgeInsets.all(8.w), + child: Obx( + () => Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (menuController.shouldShowSideNav) ...[ + TerminalSideNav(active: activeNav), + SizedBox(width: XfjDesignTokens.cardGap.w), + ], + child.expanded(), ], - child.expanded(), - ], + ), ), - ), - ).expanded(), - ], + ).expanded(), + ], + ), ), - ), + Obx( + () { + if (!sleepControl.isSleeping.value) { + return const SizedBox.shrink(); + } + // 只叠加不透明交互层,底下的页面、Controller 和硬件监听继续保持原生命周期。 + return const TerminalSleepOverlay(); + }, + ), + ].toStack(fit: StackFit.expand), ); } } @@ -157,6 +178,8 @@ class TerminalHeader extends StatelessWidget { const TerminalPerformanceStatus(), const _TerminalHeaderDivider(), const _TerminalAdminLockStatus(), + const _TerminalHeaderDivider(), + const _TerminalSleepStatus(), ], ), ), @@ -655,6 +678,300 @@ class _TerminalAdminLockStatus extends StatelessWidget { } } +/// 顶栏睡眠入口,仅切换主屏遮罩状态,不触碰当前页面和硬件资源。 +class _TerminalSleepStatus extends StatelessWidget { + /// 创建固定尺寸的主屏睡眠入口。 + const _TerminalSleepStatus(); + + /// 构建带提示和触觉反馈的睡眠按钮。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sleepControl = Get.find(); + return Semantics( + button: true, + label: '进入终端睡眠', + child: Tooltip( + message: '进入睡眠', + child: InkWell( + onTap: () { + sleepControl.enterSleep(); + HapticFeedback.selectionClick(); + }, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + FIcon( + FAssets.icons.moonStar, + size: 19.w, + color: XfjDesignTokens.primary, + ), + Text( + '睡眠', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRowSpace( + space: 8.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .padding(horizontal: 12.w) + .tight(height: 40.h), + ), + ), + ); + } +} + +/// 覆盖主屏交互的睡眠层,业务页面仍保留在同一棵 Widget 树中持续运行。 +class TerminalSleepOverlay extends StatelessWidget { + /// 创建不可通过系统返回键绕过的全屏睡眠层。 + const TerminalSleepOverlay({super.key}); + + /// 与顾客欢迎副屏共用的安心守护插图,保证主副屏品牌视觉一致。 + static const _illustrationAsset = + 'assets/images/customer_welcome/abstract-care.webp'; + + /// 请求管理密码校验,只有验证成功才移除睡眠层。 + Future _requestWake( + BuildContext context, + TerminalSleepControl sleepControl, + ) async { + try { + final awakened = await sleepControl.requestWake( + () => terminalAdminPasswordDialog( + context, + actionLabel: '唤醒终端', + ), + ); + if (awakened) await HapticFeedback.selectionClick(); + } catch (error, stack) { + logger.e('|终端睡眠|唤醒验证失败error:|$error|stack:|$stack|'); + Toast.error(msg: '暂时无法唤醒终端,请重试'); + } + } + + /// 构建与顾客欢迎副屏一致的品牌栏,并明确后台识别服务不会随主屏锁定暂停。 + Widget _buildBrandHeader(FThemeData theme) { + return [ + Text( + 'ZAX', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + ), + ), + ColoredBox(color: XfjDesignTokens.line).tight(width: 1.w, height: 32.h), + Text( + '智慧收银', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12.w), + ), + child: Text( + '安全锁屏', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 14.w, vertical: 8.h), + ), + const Spacer(), + FIcon( + FAssets.icons.circleCheck, + size: 19.sp, + color: XfjDesignTokens.success, + ), + Text( + '人脸与饭卡服务保持运行', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRowSpace(space: 18.w, crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 32.w) + .tight(height: 80.h); + } + + /// 构建睡眠状态、时间与唯一唤醒操作,信息层级按大屏远距离阅读优化。 + Widget _buildSleepSummary( + BuildContext context, + FThemeData theme, + TerminalSleepControl sleepControl, + ) { + return [ + DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(24.w), + ), + child: [ + FIcon( + FAssets.icons.moonStar, + size: 20.sp, + color: XfjDesignTokens.primary, + ), + Text( + '主屏已锁定', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + ), + ), + ] + .toRowSpace(space: 10.w, mainAxisSize: MainAxisSize.min) + .padding(horizontal: 18.w, vertical: 11.h), + ), + const _TerminalClock(prominent: true), + Text( + '终端正在休息', + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w900, + ), + ), + Text( + '屏幕已安全锁定,查询、识别与设备服务仍在后台持续运行。', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + height: 1.55, + ), + ).constrained(maxWidth: 620.w), + FButton.raw( + key: const ValueKey('terminal-sleep-wake-button'), + child: [ + FIcon( + FAssets.icons.lockKeyholeOpen, + size: 22.sp, + color: Colors.white, + ), + Text( + '唤醒终端', + style: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + ] + .toRowSpace( + space: 12.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .center(), + onPress: () => unawaited(_requestWake(context, sleepControl)), + ).tight(width: 300.w, height: 72.h), + [ + FIcon( + FAssets.icons.shieldCheck, + size: 18.sp, + color: XfjDesignTokens.muted, + ), + Text( + '唤醒需要验证管理员密码', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toRowSpace(space: 8.w, mainAxisSize: MainAxisSize.min), + ].toColumnSpace( + space: 22.h, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ); + } + + /// 构建欢迎副屏同源的守护插图,资源异常时保留稳定占位而不暴露底层页面。 + Widget _buildIllustration() { + final radius = BorderRadius.circular(24.w); + return DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: radius, + boxShadow: [ + BoxShadow( + color: XfjDesignTokens.ink.withValues(alpha: 0.08), + blurRadius: 44, + offset: Offset(0, 16), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Image.asset( + _illustrationAsset, + key: const ValueKey('terminal-sleep-illustration'), + fit: BoxFit.cover, + semanticLabel: '安心守护终端的蓝绿色卡通插图', + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: XfjDesignTokens.primarySoft, + child: FIcon( + FAssets.icons.shieldCheck, + size: 72.sp, + color: XfjDesignTokens.primary, + ).center(), + ), + ), + ), + ); + } + + /// 构建不透明锁屏与唯一的密码唤醒入口,拦截对底层页面的所有触摸。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sleepControl = Get.find(); + return PopScope( + canPop: false, + child: Semantics( + container: true, + scopesRoute: true, + explicitChildNodes: true, + label: '终端睡眠中', + child: ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: [ + _buildBrandHeader(theme), + [ + _buildSleepSummary(context, theme, sleepControl) + .alignLeft() + .expanded(flex: 9), + _buildIllustration() + .aspectRatio(aspectRatio: 4 / 3) + .center() + .expanded(flex: 11), + ] + .toRowSpace( + space: 64.w, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) + .padding(horizontal: 64.w, vertical: 32.h) + .expanded(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ), + ), + ), + ); + } +} + /// 顶栏设置与退出使用的固定尺寸图标按钮,完整命令名称通过 Tooltip 和语义暴露。 class TerminalHeaderIconButton extends StatelessWidget { /// 创建一个固定 48dp 的顶栏命令按钮。 @@ -826,17 +1143,28 @@ class _TerminalNavButton extends StatelessWidget { } } +/// 顶栏与睡眠层共用的本地时间组件。 class _TerminalClock extends StatefulWidget { - const _TerminalClock(); + /// 创建顶栏或睡眠页时钟;醒目模式会增加日期,但不改变刷新频率。 + const _TerminalClock({this.prominent = false}); + /// 是否使用睡眠页的大号时间与日期组合样式。 + final bool prominent; + + /// 创建负责按分钟边界附近刷新时间的状态对象。 @override State<_TerminalClock> createState() => _TerminalClockState(); } +/// 终端时钟的当前时间与周期刷新资源。 class _TerminalClockState extends State<_TerminalClock> { + /// 最近一次用于渲染的本地时间。 late DateTime _now; + + /// 每 30 秒校准一次显示的计时器,页面销毁时必须取消。 Timer? _timer; + /// 初始化当前时间并启动低频刷新。 @override void initState() { super.initState(); @@ -846,22 +1174,55 @@ class _TerminalClockState extends State<_TerminalClock> { }); } + /// 释放时钟计时器,避免终端页面销毁后继续触发刷新。 @override void dispose() { _timer?.cancel(); super.dispose(); } + /// 将 Dart 的周序号转换为终端中文日期文案,不依赖额外国际化包。 + String _weekdayLabel() => switch (_now.weekday) { + DateTime.monday => '星期一', + DateTime.tuesday => '星期二', + DateTime.wednesday => '星期三', + DateTime.thursday => '星期四', + DateTime.friday => '星期五', + DateTime.saturday => '星期六', + DateTime.sunday => '星期日', + _ => '', + }; + + /// 按使用场景构建紧凑顶栏时间或醒目睡眠时间。 @override Widget build(BuildContext context) { final hour = _now.hour.toString().padLeft(2, '0'); final minute = _now.minute.toString().padLeft(2, '0'); - return Text( + final theme = FTheme.of(context); + final time = Text( '$hour:$minute', - style: FTheme.of(context).typography.lg.copyWith( + key: widget.prominent ? const ValueKey('terminal-sleep-clock') : null, + style: (widget.prominent ? theme.typography.xl6 : theme.typography.lg) + .copyWith( color: XfjDesignTokens.ink, + fontWeight: widget.prominent ? FontWeight.w700 : null, fontFeatures: const [FontFeature.tabularFigures()], ), ); + if (!widget.prominent) return time; + return [ + time, + Text( + '${_now.month}月${_now.day}日 ${_weekdayLabel()}', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ].toColumnSpace( + space: 8.h, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ); } } diff --git a/lib/main.dart b/lib/main.dart index 1558c7c..d661a70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,10 +9,12 @@ import 'package:pj_supermarket_cloud/app.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/index.dart'; +/// 启动 Flutter 主屏或双屏插件创建的独立副屏入口。 void main() { checkSubScreen(); } +/// 根据 Engine 默认路由初始化对应屏幕,避免副屏持有主屏的 GetX 与硬件状态。 Future checkSubScreen() async { final defaultRouteName = PlatformDispatcher.instance.defaultRouteName; logger.d("当前的屏幕$defaultRouteName"); @@ -23,6 +25,8 @@ Future checkSubScreen() async { await AppRuntimeTool.initializePlatform(); final appTheme = Get.put(AppThemeControl()); Get.put(TerminalAdminSessionControl(), permanent: true); + // 睡眠状态需要跨终端工作区路由常驻,但不会进入副屏 Engine。 + Get.put(TerminalSleepControl(), permanent: true); final terminalMenu = Get.put(TerminalMenuControl()); final app = Get.put(AppControl()); Get.put(FaceRecognitionSessionService(), permanent: true); diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index e4be2d8..f0f5ad8 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -78,6 +78,9 @@ class ReservationMenuQueryController extends GetxController { ...ReservationRecognitionMethod.values, ].obs; + /// 是否自动启动当前查询方式的硬件识别;页面生命周期内使用进入页面时读取的配置快照。 + final autoRecognitionEnabled = false.obs; + /// 当前页面查询请求是否要求服务端脱敏用户姓名。 final nameMaskEnabled = true.obs; @@ -118,12 +121,14 @@ class ReservationMenuQueryController extends GetxController { await _beginSession(); } + /// 读取当前页面的一次性配置快照;读取失败时保留安全默认值继续提供手动查询。 Future _loadPagePreferences() async { try { final preferences = await _preferencesLoader(); if (_closed) return; methodOrder.assignAll(preferences.methodOrder); method.value = preferences.defaultMethod; + autoRecognitionEnabled.value = preferences.autoRecognitionEnabled; nameMaskEnabled.value = preferences.nameMaskEnabled; verificationEnabled.value = preferences.verificationEnabled; verificationDateScope.value = preferences.verificationDateScope; @@ -133,9 +138,10 @@ class ReservationMenuQueryController extends GetxController { } } + /// 切换本人识别方式,清理旧身份与硬件回调,并按配置决定是否立即启动新方式。 Future selectMethod(ReservationRecognitionMethod next) async { if (_closed || next == method.value) return; - _requestGeneration++; + final generation = ++_requestGeneration; _stopPrivacyTimer(); result.value = null; _recognizedIdentityValue = null; @@ -143,12 +149,15 @@ class ReservationMenuQueryController extends GetxController { errorMessage.value = ''; method.value = next; status.value = ReservationMenuQueryStatus.idle; - // 切换方式只更新待识别状态,必须由用户点击“开始识别”后才操作读卡器或相机。 // generation 已先递增,即使旧硬件回调稍晚到达也不会进入查询流程。 - unawaited(_syncSubScreen('idle')); + // 自动模式直接交给新识别方式更新副屏,避免 idle 消息晚到并覆盖人脸实时画面。 + if (!autoRecognitionEnabled.value) unawaited(_syncSubScreen('idle')); await _recognitionGateway.stopAll(); + if (_closed || generation != _requestGeneration) return; + if (autoRecognitionEnabled.value) await startRecognition(); } + /// 启动当前识别方式并建立一次防重复回调的查询代次;失败后停留在可人工恢复状态。 Future startRecognition() async { if (_closed) return; final generation = ++_requestGeneration; @@ -182,9 +191,16 @@ class ReservationMenuQueryController extends GetxController { } _recognitionLocked = true; if (failure.type == ReservationRecognitionFailureType.cancelled) { - status.value = ReservationMenuQueryStatus.idle; - errorMessage.value = ''; - unawaited(_syncSubScreen('idle')); + if (autoRecognitionEnabled.value) { + // 用户主动取消后暂停自动识别并提供恢复入口,禁止立即拉起造成无法退出的弹窗循环。 + status.value = ReservationMenuQueryStatus.identifyError; + errorMessage.value = failure.message; + unawaited(_syncSubScreen('error')); + } else { + status.value = ReservationMenuQueryStatus.idle; + errorMessage.value = ''; + unawaited(_syncSubScreen('idle')); + } return; } errorMessage.value = failure.message; @@ -430,22 +446,34 @@ class ReservationMenuQueryController extends GetxController { } void _clearStaleResultAfterVerification() { - _requestGeneration++; + final generation = ++_requestGeneration; _stopPrivacyTimer(); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = ReservationMenuQueryStatus.idle; - unawaited(_recognitionGateway.stopAll()); - unawaited(_clearSubScreen()); + unawaited(_resumeAfterStaleResultClear(generation)); } + /// 核销后刷新失败时先清理主副屏,再按自动模式重新等待身份,避免页面停在无监听的空闲态。 + Future _resumeAfterStaleResultClear(int generation) async { + await _recognitionGateway.stopAll(); + if (_closed || generation != _requestGeneration) return; + unawaited(_clearSubScreen()); + if (!_closed && autoRecognitionEnabled.value) await startRecognition(); + } + + /// 建立本次页面会话,并按进入页面时加载的开关选择自动识别或手动等待态。 Future _beginSession() async { _sessionId = '${_now().microsecondsSinceEpoch}'; _revision = 0; - // 进入页面只同步等待态,饭卡和人脸硬件必须等用户点击“开始识别”后再启动。 - unawaited(_syncSubScreen('idle')); + if (autoRecognitionEnabled.value) { + // 配置加载完成后直接复用手动入口,确保饭卡、人脸、防重入和异常处理只有一套状态机。 + await startRecognition(); + } else { + unawaited(_syncSubScreen('idle')); + } } void _startPrivacyTimer() { @@ -470,10 +498,12 @@ class ReservationMenuQueryController extends GetxController { remainingSeconds.value = 0; } + /// 清除主屏身份与预约结果;自动模式会在清理完成后重新等待下一位用户。 Future clearForPrivacy({bool expired = false}) async { - _requestGeneration++; + final generation = ++_requestGeneration; _stopPrivacyTimer(); await _recognitionGateway.stopAll(); + if (_closed || generation != _requestGeneration) return; result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; @@ -481,7 +511,10 @@ class ReservationMenuQueryController extends GetxController { status.value = expired ? ReservationMenuQueryStatus.expired : ReservationMenuQueryStatus.idle; - await _clearSubScreen(); + unawaited(_clearSubScreen()); + if (_closed || generation != _requestGeneration) return; + // 自动模式只在隐私展示周期结束后重新布防,结果展示期间不会重复读取同一硬件回调。 + if (autoRecognitionEnabled.value) await startRecognition(); } Future _syncSubScreen( diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index a064cfb..1d62591 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -13,6 +13,7 @@ import 'widgets/reservation_verification_dialog.dart'; class ReservationMenuQueryPage extends GetView { const ReservationMenuQueryPage({super.key}); + /// 分别监听侧栏和结果区实际使用的状态,自动识别开关只触发侧栏局部重建。 @override Widget build(BuildContext context) { // 左右工作区各自监听实际使用的 Rx,避免核销倒计时或页码变化重建整张页面。 @@ -57,6 +58,7 @@ class ReservationMenuQueryPage extends GetView { methods: controller.methodOrder.toList(), selected: controller.method.value, status: controller.status.value, + autoRecognitionEnabled: controller.autoRecognitionEnabled.value, onSelect: controller.selectMethod, onStart: controller.startRecognition, ), diff --git a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart index d8acdc6..f010fa3 100644 --- a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart +++ b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart @@ -6,22 +6,38 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import '../controller.dart'; +/// 查询方式侧栏,在自动模式下用只读运行状态替代手动“开始识别”按钮。 class QueryMethodPanel extends StatelessWidget { + /// 创建查询方式侧栏,回调由页面 Controller 统一处理硬件与查询副作用。 const QueryMethodPanel({ super.key, required this.methods, required this.selected, required this.status, + required this.autoRecognitionEnabled, required this.onSelect, required this.onStart, }); + /// 按设置页排序后的可用识别方式。 final List methods; + + /// 当前选中的识别方式。 final ReservationRecognitionMethod selected; + + /// 当前识别与预约查询状态,用于禁用按钮或展示自动运行反馈。 final ReservationMenuQueryStatus status; + + /// 是否以自动识别状态区替换手动启动按钮。 + final bool autoRecognitionEnabled; + + /// 用户切换识别方式时调用的 Controller 回调。 final ValueChanged onSelect; + + /// 手动模式下启动识别时调用的 Controller 回调。 final VoidCallback onStart; + /// 构建固定宽度侧栏内容,自动状态与手动按钮保持相同高度以避免布局跳动。 @override Widget build(BuildContext context) { final busy = status == ReservationMenuQueryStatus.recognizing || @@ -38,27 +54,95 @@ class QueryMethodPanel extends StatelessWidget { if (index != methods.length - 1) SizedBox(height: 8.h), ], SizedBox(height: 10.h), - SizedBox( - height: 54.h, - child: FButton( - onPress: busy ? null : onStart, - prefix: FIcon( - selected == ReservationRecognitionMethod.card - ? FAssets.icons.idCard - : FAssets.icons.scanFace, - ), - label: Text( - busy - ? status == ReservationMenuQueryStatus.recognizing - ? '正在识别...' - : '正在查询...' - : '开始识别', + if (autoRecognitionEnabled) + _automaticRecognitionStatus() + else + SizedBox( + height: 54.h, + child: FButton( + onPress: busy ? null : onStart, + prefix: FIcon( + selected == ReservationRecognitionMethod.card + ? FAssets.icons.idCard + : FAssets.icons.scanFace, + ), + label: Text( + busy + ? status == ReservationMenuQueryStatus.recognizing + ? '正在识别...' + : '正在查询...' + : '开始识别', + ), ), ), - ), ], ); } + + /// 根据查询状态显示稳定高度的自动识别反馈,不提供重复启动硬件的点击入口。 + Widget _automaticRecognitionStatus() { + final (icon, label, color) = switch (status) { + ReservationMenuQueryStatus.recognizing => ( + selected == ReservationRecognitionMethod.card + ? FAssets.icons.idCard + : FAssets.icons.scanFace, + selected == ReservationRecognitionMethod.card ? '请放置饭卡' : '正在识别人脸', + XfjDesignTokens.primary, + ), + ReservationMenuQueryStatus.querying => ( + FAssets.icons.refreshCw, + '正在查询预约', + XfjDesignTokens.primary, + ), + ReservationMenuQueryStatus.success || + ReservationMenuQueryStatus.empty => + ( + FAssets.icons.circleCheck, + '查询结果展示中', + XfjDesignTokens.success, + ), + ReservationMenuQueryStatus.identifyError || + ReservationMenuQueryStatus.networkError || + ReservationMenuQueryStatus.deviceError => + ( + FAssets.icons.circleAlert, + '自动识别已暂停', + XfjDesignTokens.warning, + ), + _ => ( + FAssets.icons.scan, + '自动识别已开启', + XfjDesignTokens.primary, + ), + }; + final content = [ + FIcon(icon, color: color, size: 22.w), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 15.sp, + fontWeight: FontWeight.w700, + ), + ).expanded(), + ] + .toRowSpace(space: 10.w) + .padding(horizontal: 16.w, vertical: 8.h) + .constrained(minHeight: 54.h) + .decorated( + color: XfjDesignTokens.primarySoft, + border: Border.all(color: color.withValues(alpha: 0.24)), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + return Semantics( + container: true, + liveRegion: true, + label: label, + child: content, + ); + } } class _MethodTile extends StatelessWidget { diff --git a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart index b7b76a9..17a5253 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart @@ -120,14 +120,22 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { Navigator.of(context).pop(result); } + /// 构建接近全屏的横屏核销工作区,头尾按真实内容占高,剩余空间全部用于订单核对。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final baseStyle = theme.dialogStyle; + final viewportSize = MediaQuery.sizeOf(context); + // 弹框保留少量背景边缘帮助用户识别当前仍处于模态流程,同时充分利用横屏终端空间。 + final dialogWidth = viewportSize.width * 0.92; + final dialogHeight = viewportSize.height * 0.90; final dialogStyle = baseStyle.copyWith( - minWidth: 1040.w, - maxWidth: 1120.w, - insetPadding: EdgeInsets.symmetric(horizontal: 44.w, vertical: 32.h), + minWidth: dialogWidth, + maxWidth: dialogWidth, + insetPadding: EdgeInsets.symmetric( + horizontal: viewportSize.width * 0.025, + vertical: viewportSize.height * 0.025, + ), decoration: baseStyle.decoration.copyWith( color: XfjDesignTokens.surface, borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), @@ -141,22 +149,25 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { semanticLabel: widget.verificationEnabled ? '确认核销预约订单' : '预约订单详情', style: dialogStyle, builder: (_, __) => SizedBox( - width: 1080.w, - height: 760.h, + key: const ValueKey('reservation-verification-dialog-content'), + width: dialogWidth, + height: dialogHeight, child: [ _header(theme), const Divider(height: 1, color: XfjDesignTokens.line), _summary(theme), - [ - _orderNavigation(theme).width(292.w), - _cartPanel(theme).expanded(), - ] - .toRowSpace( - space: 14.w, - crossAxisAlignment: CrossAxisAlignment.stretch, - ) - .padding(horizontal: 24.w, vertical: 16.h) - .expanded(), + SizedBox( + key: const ValueKey('reservation-verification-workspace'), + child: [ + _orderNavigation(theme).width(dialogWidth * 0.28), + _cartPanel(theme).expanded(), + ] + .toRowSpace( + space: 14.w, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) + .padding(horizontal: 24.w, vertical: 16.h), + ).expanded(), const Divider(height: 1, color: XfjDesignTokens.line), _footer(theme), ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), @@ -183,6 +194,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { style: theme.typography.xl2.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, + height: 1.2, ), ), Text( @@ -600,6 +612,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { : AppNetworkTool.ossImage(productPic); } + /// 构建核销提示与大尺寸操作按钮,满足横屏收银设备连续触控的命中要求。 Widget _footer(FThemeData theme) { final helper = widget.verificationEnabled ? '只核销当前选中的 1 笔预约,核销后不可重复使用。' @@ -622,32 +635,55 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), ).expanded(), ].toRowSpace(space: 8.w).expanded(), - FButton( + FButton.raw( key: const ValueKey('reservation-verification-cancel'), style: widget.verificationEnabled ? FButtonStyle.outline : FButtonStyle.primary, - label: Text( + child: Text( widget.verificationEnabled ? '取消' : '关闭', - style: theme.typography.base.copyWith( + key: const ValueKey('reservation-verification-cancel-content'), + textAlign: TextAlign.center, + style: theme.typography.lg.copyWith( color: widget.verificationEnabled ? XfjDesignTokens.ink : Colors.white, + fontWeight: FontWeight.w700, + height: 1.2, ), - ), + ).center(), onPress: _submitting ? null : () => _close(false), - ).constrained(minWidth: 136.w, minHeight: 52.h), + ).tight(width: 176.w, height: 64.h), if (widget.verificationEnabled) - FButton( + FButton.raw( key: const ValueKey('reservation-verification-confirm'), - prefix: _submitting - ? const FButtonSpinner() - : FIcon(FAssets.icons.circleCheck), - label: Text( - _submitting ? '核销中...' : '确认核销', - style: theme.typography.base.copyWith(color: Colors.white), - ), + child: [ + _submitting + ? const FButtonSpinner() + : FIcon( + FAssets.icons.circleCheck, + color: Colors.white, + size: 21.sp, + ), + Text( + _submitting ? '核销中...' : '确认核销', + style: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + ] + .toRowSpace( + key: const ValueKey( + 'reservation-verification-confirm-content', + ), + space: 10.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .center(), onPress: _submitting ? null : _confirm, - ).constrained(minWidth: 172.w, minHeight: 52.h), + ).tight(width: 240.w, height: 64.h), ].toRowSpace(space: 12.w).padding(horizontal: 24.w, vertical: 16.h); } diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index 0972c68..de93301 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -64,6 +64,9 @@ class SettingController extends GetxController with WidgetsBindingObserver { /// 预约查询默认识别方式,必须始终包含在当前识别方式顺序中。 final reservationQueryDefaultMethod = ReservationRecognitionMethod.card.obs; + /// 查询菜单是否在进入页面和切换方式后自动启动当前识别设备。 + final reservationQueryAutoRecognitionEnabled = false.obs; + /// 查询菜单是否要求服务端脱敏用户姓名。 final reservationQueryNameMaskEnabled = true.obs; @@ -227,6 +230,8 @@ class SettingController extends GetxController with WidgetsBindingObserver { ); reservationQueryDefaultMethod.value = reservationQueryPreferences.defaultMethod; + reservationQueryAutoRecognitionEnabled.value = + reservationQueryPreferences.autoRecognitionEnabled; reservationQueryNameMaskEnabled.value = reservationQueryPreferences.nameMaskEnabled; reservationQueryVerificationEnabled.value = @@ -280,6 +285,13 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 切换查询菜单自动识别模式并安排持久化,当前已打开的查询页不被强制重启。 + void setReservationQueryAutoRecognitionEnabled(bool enabled) { + if (reservationQueryAutoRecognitionEnabled.value == enabled) return; + reservationQueryAutoRecognitionEnabled.value = enabled; + _scheduleReservationQueryPreferencesPersist(); + } + /// 切换查询菜单服务端二次核销校验并安排持久化。 void setReservationQueryVerificationEnabled(bool enabled) { if (reservationQueryVerificationEnabled.value == enabled) return; @@ -309,6 +321,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { ReservationRecognitionMethod.values, ); reservationQueryDefaultMethod.value = ReservationRecognitionMethod.card; + reservationQueryAutoRecognitionEnabled.value = false; reservationQueryNameMaskEnabled.value = true; reservationQueryVerificationEnabled.value = false; reservationQueryVerificationDateScope.value = @@ -333,6 +346,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { await AppSettingTool.setReservationQueryPagePreferences( order: reservationQueryMethodOrder.toList(), defaultMethod: reservationQueryDefaultMethod.value, + autoRecognitionEnabled: reservationQueryAutoRecognitionEnabled.value, nameMaskEnabled: reservationQueryNameMaskEnabled.value, verificationEnabled: reservationQueryVerificationEnabled.value, verificationDateScope: reservationQueryVerificationDateScope.value, diff --git a/lib/pages/terminal/setting/widgets/setting_page.dart b/lib/pages/terminal/setting/widgets/setting_page.dart index 48c4c22..e2b2288 100644 --- a/lib/pages/terminal/setting/widgets/setting_page.dart +++ b/lib/pages/terminal/setting/widgets/setting_page.dart @@ -175,13 +175,15 @@ class _ReservationQuerySettings extends StatelessWidget { final SettingController controller; + /// 组合查询方式、自动识别、隐私和核销设置,并保持各区域独立响应式刷新。 @override Widget build(BuildContext context) => [ _queryMethodSettings(controller), + _autoRecognitionSettings(controller), _nameMaskSettings(controller), _verificationSettings(controller), SettingNotice( - text: '查询顺序、默认项、姓名脱敏和核销配置会在下次进入“查询菜单”时读取;进入页面和切换方式都不会自动启动饭卡或人脸识别。', + text: '查询顺序、默认项、自动识别、姓名脱敏和核销配置会在下次进入“查询菜单”时读取。', icon: FAssets.icons.circleAlert, ), ].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable(); @@ -227,6 +229,28 @@ Widget _queryMethodSettings(SettingController controller) => Obx(() { ].toColumnSpace(space: XfjDesignTokens.cardGap.h); }); +/// 自动识别开关只监听自己的 Rx,切换时立即反馈并由 Controller 串行保存。 +Widget _autoRecognitionSettings(SettingController controller) => Obx(() { + final enabled = controller.reservationQueryAutoRecognitionEnabled.value; + return SettingGroup( + title: '识别设置', + child: SettingInfoRow( + icon: enabled ? FAssets.icons.scan : FAssets.icons.circleX, + label: '自动识别查询', + subtitle: enabled + ? '进入查询菜单或切换方式后,自动等待饭卡或启动人脸识别。' + : '保留“开始识别”按钮,由用户手动启动当前识别方式。', + value: enabled ? '已开启' : '已关闭', + showDivider: false, + trailing: FSwitch( + semanticLabel: '查询菜单自动识别查询', + value: enabled, + onChange: controller.setReservationQueryAutoRecognitionEnabled, + ), + ), + ); + }); + /// 姓名展示配置独立观察,切换时不会重建查询方式排序区域。 Widget _nameMaskSettings(SettingController controller) => Obx(() { final enabled = controller.reservationQueryNameMaskEnabled.value; diff --git a/pubspec.yaml b/pubspec.yaml index 5c08961..3347f51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.1.1+1 +version: 1.3.3+133 environment: sdk: ^3.6.0 diff --git a/test/common/terminal_sleep_control_test.dart b/test/common/terminal_sleep_control_test.dart new file mode 100644 index 0000000..79d1f3c --- /dev/null +++ b/test/common/terminal_sleep_control_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证睡眠状态只由成功的单次密码校验解除。 +void main() { + test('取消验证后保持睡眠,验证成功后恢复主屏', () async { + final control = TerminalSleepControl(); + + control.enterSleep(); + expect(control.isSleeping.value, isTrue); + expect(await control.requestWake(() async => false), isFalse); + expect(control.isSleeping.value, isTrue); + + expect(await control.requestWake(() async => true), isTrue); + expect(control.isSleeping.value, isFalse); + }); + + test('并发唤醒只执行首个密码校验', () async { + final control = TerminalSleepControl()..enterSleep(); + final verification = Completer(); + var verificationCount = 0; + + final firstWake = control.requestWake(() { + verificationCount++; + return verification.future; + }); + final duplicateWake = await control.requestWake(() async { + verificationCount++; + return true; + }); + + expect(duplicateWake, isFalse); + expect(verificationCount, 1); + expect(control.isSleeping.value, isTrue); + + verification.complete(true); + expect(await firstWake, isTrue); + expect(control.isSleeping.value, isFalse); + }); + + for (final size in [const Size(1920, 1080), const Size(1280, 720)]) { + testWidgets('${size.width.toInt()}x${size.height.toInt()} 睡眠遮罩布局正常', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + Get.testMode = true; + Get.put(TerminalSleepControl()..enterSleep()); + addTearDown(() { + Get.reset(); + Get.testMode = false; + }); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: const TerminalSleepOverlay(), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('主屏已锁定'), findsOneWidget); + expect(find.text('终端正在休息'), findsOneWidget); + expect(find.text('人脸与饭卡服务保持运行'), findsOneWidget); + expect( + find.byKey(const ValueKey('terminal-sleep-illustration')), + findsOneWidget, + ); + expect(find.text('唤醒终端'), findsOneWidget); + expect( + tester + .getSize( + find.byKey(const ValueKey('terminal-sleep-wake-button')), + ) + .height, + greaterThanOrEqualTo(48), + ); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 7c4dbf5..f3c7263 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -39,6 +39,7 @@ void main() { ReservationRecognitionMethod.card, ]); expect(controller.method.value, ReservationRecognitionMethod.face); + expect(controller.autoRecognitionEnabled.value, isFalse); expect(controller.nameMaskEnabled.value, isFalse); expect(controller.status.value, ReservationMenuQueryStatus.idle); expect(controller.verificationEnabled.value, isTrue); @@ -56,6 +57,106 @@ void main() { await controller.clearForPrivacy(); }); + testWidgets('自动饭卡模式进入页面即等待放卡,清屏后继续接收下一位用户', (tester) async { + var now = DateTime(2026, 8, 4, 8); + final repository = _ImmediateRepository(_result('张*明', 1)); + final recognition = _FakeRecognitionGateway(); + final controller = ReservationMenuQueryController( + repository: repository, + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + networkAvailable: () => true, + privacyDuration: const Duration(seconds: 2), + now: () => now, + preferencesLoader: _autoCardPreferences, + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + + expect(controller.autoRecognitionEnabled.value, isTrue); + expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(recognition.cardCallbacks, hasLength(1)); + + final first = recognition.emitCardAt(0, 'CARD-001'); + final duplicate = recognition.emitCardAt(0, 'CARD-001'); + await first; + await duplicate; + expect(repository.calls, hasLength(1)); + expect(controller.status.value, ReservationMenuQueryStatus.success); + + now = now.add(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(controller.result.value, isNull); + expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(recognition.cardCallbacks, hasLength(2)); + }); + + testWidgets('自动人脸模式进入页面即启动,切换饭卡后自动启动新方式', (tester) async { + final repository = _ImmediateRepository(_result('李*华', 1)); + final recognition = _FakeRecognitionGateway(); + final controller = ReservationMenuQueryController( + repository: repository, + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + networkAvailable: () => true, + preferencesLoader: _autoFacePreferences, + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + + expect(controller.method.value, ReservationRecognitionMethod.face); + expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(recognition.faceCallbacks, hasLength(1)); + + await recognition.emitFaceAt(0, '1001'); + expect(repository.calls, [ + const _QueryCall(identityValue: '1001', maskName: true), + ]); + expect(controller.status.value, ReservationMenuQueryStatus.success); + + await controller.selectMethod(ReservationRecognitionMethod.card); + expect(controller.method.value, ReservationRecognitionMethod.card); + expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(recognition.cardCallbacks, hasLength(1)); + }); + + testWidgets('自动人脸被取消后暂停且不循环弹窗,仍可人工重新识别', (tester) async { + final recognition = _FakeRecognitionGateway(); + final controller = ReservationMenuQueryController( + repository: _ImmediateRepository(_result('李*华', 1)), + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + networkAvailable: () => true, + preferencesLoader: _autoFacePreferences, + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + expect(recognition.faceCallbacks, hasLength(1)); + + recognition.faceFailures.single( + const ReservationRecognitionFailure( + ReservationRecognitionFailureType.cancelled, + '已取消人脸识别', + ), + ); + await tester.pump(); + + expect(controller.status.value, ReservationMenuQueryStatus.identifyError); + expect(controller.errorMessage.value, '已取消人脸识别'); + expect(recognition.faceCallbacks, hasLength(1)); + + await controller.startRecognition(); + expect(recognition.faceCallbacks, hasLength(2)); + }); + testWidgets('重复识别只查询一次,旧响应不能覆盖新身份', (tester) async { final repository = _CompleterRepository(); final recognition = _FakeRecognitionGateway(); @@ -500,6 +601,20 @@ void main() { Future _defaultPreferences() async => ReservationQueryPagePreferences.defaults; +Future _autoCardPreferences() async => + const ReservationQueryPagePreferences( + methodOrder: ReservationRecognitionMethod.values, + defaultMethod: ReservationRecognitionMethod.card, + autoRecognitionEnabled: true, + ); + +Future _autoFacePreferences() async => + const ReservationQueryPagePreferences( + methodOrder: ReservationRecognitionMethod.values, + defaultMethod: ReservationRecognitionMethod.face, + autoRecognitionEnabled: true, + ); + Future _verificationPreferences() async => const ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, diff --git a/test/pages/terminal/query_method_panel_test.dart b/test/pages/terminal/query_method_panel_test.dart new file mode 100644 index 0000000..9548af8 --- /dev/null +++ b/test/pages/terminal/query_method_panel_test.dart @@ -0,0 +1,69 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/menu_query/controller.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/menu_query/widgets/query_method_panel.dart'; + +void main() { + testWidgets('手动模式保留开始按钮,自动模式改为饭卡等待状态且仍可切换方式', (tester) async { + await tester.binding.setSurfaceSize(const Size(1920, 1080)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var startCalls = 0; + ReservationRecognitionMethod? selectedMethod; + + await tester.pumpWidget( + _testApp( + QueryMethodPanel( + methods: ReservationRecognitionMethod.values, + selected: ReservationRecognitionMethod.card, + status: ReservationMenuQueryStatus.idle, + autoRecognitionEnabled: false, + onSelect: (method) => selectedMethod = method, + onStart: () => startCalls++, + ), + ), + ); + + expect(find.text('开始识别'), findsOneWidget); + await tester.tap(find.text('开始识别')); + await tester.pump(const Duration(milliseconds: 250)); + expect(startCalls, 1); + + await tester.pumpWidget( + _testApp( + QueryMethodPanel( + methods: ReservationRecognitionMethod.values, + selected: ReservationRecognitionMethod.card, + status: ReservationMenuQueryStatus.recognizing, + autoRecognitionEnabled: true, + onSelect: (method) => selectedMethod = method, + onStart: () => startCalls++, + ), + ), + ); + + expect(find.text('开始识别'), findsNothing); + expect(find.text('请放置饭卡'), findsOneWidget); + await tester.tap(find.text('人脸查询')); + await tester.pump(const Duration(milliseconds: 250)); + expect(selectedMethod, ReservationRecognitionMethod.face); + expect(startCalls, 1); + expect(tester.takeException(), isNull); + }); +} + +/// 构建与终端设计基准一致的主题和尺寸环境,只验证本组件的可见状态与点击链路。 +Widget _testApp(Widget child) { + return ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: Scaffold(body: child), + ), + ), + ); +} diff --git a/test/pages/terminal/reservation_verification_dialog_test.dart b/test/pages/terminal/reservation_verification_dialog_test.dart index 79466f1..e74eaa1 100644 --- a/test/pages/terminal/reservation_verification_dialog_test.dart +++ b/test/pages/terminal/reservation_verification_dialog_test.dart @@ -60,7 +60,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationVerificationDialog( @@ -83,6 +83,71 @@ void main() { await tester.tap(find.text('打开')); await tester.pumpAndSettle(); + final dialogSize = tester.getSize( + find.byKey( + const ValueKey('reservation-verification-dialog-content'), + ), + ); + final workspaceSize = tester.getSize( + find.byKey(const ValueKey('reservation-verification-workspace')), + ); + final cancelButtonSize = tester.getSize( + find.byKey(const ValueKey('reservation-verification-cancel')), + ); + final confirmButtonSize = tester.getSize( + find.byKey(const ValueKey('reservation-verification-confirm')), + ); + const devicePixelRatio = 1.275; + const logicalViewport = Size( + 1920 / devicePixelRatio, + 1080 / devicePixelRatio, + ); + final widthScale = logicalViewport.width / 1920; + final heightScale = logicalViewport.height / 1080; + expect(dialogSize.width, closeTo(logicalViewport.width * 0.92, 0.1)); + expect(dialogSize.height, closeTo(logicalViewport.height * 0.90, 0.1)); + expect( + workspaceSize.height / dialogSize.height, + inInclusiveRange(0.66, 0.74), + ); + expect(cancelButtonSize.width, closeTo(176 * widthScale, 0.1)); + expect(cancelButtonSize.height, closeTo(64 * heightScale, 0.1)); + expect(confirmButtonSize.width, closeTo(240 * widthScale, 0.1)); + expect(confirmButtonSize.height, closeTo(64 * heightScale, 0.1)); + expect( + tester + .getCenter( + find.byKey( + const ValueKey('reservation-verification-cancel-content'), + ), + ) + .dx, + closeTo( + tester + .getCenter( + find.byKey(const ValueKey('reservation-verification-cancel')), + ) + .dx, + 0.1, + ), + ); + expect( + tester + .getCenter( + find.byKey( + const ValueKey('reservation-verification-confirm-content'), + ), + ) + .dx, + closeTo( + tester + .getCenter( + find.byKey(const ValueKey('reservation-verification-confirm')), + ) + .dx, + 0.1, + ), + ); expect(find.text('确认核销'), findsNWidgets(2)); expect(find.text('可核销'), findsOneWidget); expect(find.text('张*明 · 三年级二班'), findsOneWidget); @@ -139,7 +204,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationVerificationDialog( @@ -176,7 +241,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationOrderDialog( @@ -227,7 +292,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationVerificationDialog( @@ -265,7 +330,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationVerificationDialog( @@ -302,7 +367,7 @@ void main() { splitScreenMode: true, builder: (_, __) => MaterialApp( home: FTheme( - data: FThemes.zinc.light, + data: _terminalTheme(), child: Builder( builder: (context) => TextButton( onPressed: () => reservationVerificationDialog( @@ -342,9 +407,17 @@ void main() { }); } +/// 构造与终端默认设置一致的 1.4 倍 Forui 字号,覆盖真实设备的高字号布局风险。 +FThemeData _terminalTheme() => FThemeData.inherit( + colorScheme: FThemes.zinc.light.colorScheme, + typography: FThemes.zinc.light.typography.scale(sizeScalar: 1.4), + style: FThemes.zinc.light.style, + ); + +/// 使用 RK3568 当前 1920×1080、204dpi 对应的逻辑视口复现真机布局。 void _useTerminalViewport(WidgetTester tester) { tester.view.physicalSize = const Size(1920, 1080); - tester.view.devicePixelRatio = 1; + tester.view.devicePixelRatio = 1.275; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); }