feat:核销放大

feat:锁屏页面
feat:饭卡自动查询预约
This commit is contained in:
dzc committed 2026-08-04 19:35:06 +08:00
1 parent 0b287b0364
commit bdc33b822f
17 files changed
+1061 -89

No files matched your search

@@ -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;
@@ -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<void> setReservationQueryPagePreferences({
required List<ReservationRecognitionMethod> 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] =
@@ -0,0 +1,43 @@
import 'package:get/get.dart';
/// 执行一次终端唤醒身份校验,返回是否允许解除睡眠遮罩。
typedef TerminalWakeVerifier = Future<bool> Function();
/// 管理主屏睡眠遮罩及唤醒验证的应用级状态。
///
/// 睡眠仅阻止主屏查看和触摸,不切换路由、不销毁页面 Controller,也不暂停串口、
/// 人脸、网络请求或副屏通信。状态仅保存在当前进程,设备重新启动后不自动进入睡眠。
class TerminalSleepControl extends GetxController {
/// 创建一份默认处于唤醒状态的主屏睡眠控制器。
TerminalSleepControl();
/// 主屏是否正在展示全屏睡眠遮罩。
final isSleeping = false.obs;
/// 是否已有唤醒密码校验正在进行,防止快速点击叠加多个密码弹窗。
bool _wakeVerificationInProgress = false;
/// 立即进入睡眠状态;重复调用保持幂等且不影响当前业务页面生命周期。
void enterSleep() {
if (!isSleeping.value) isSleeping.value = true;
}
/// 通过调用方提供的密码校验请求唤醒主屏。
///
/// 校验取消、失败或重复提交时继续保持睡眠;校验过程抛出的异常会向上返回,
/// 由界面层统一记录并提示,同时保证下一次唤醒仍可重新发起。
Future<bool> 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;
}
}
}
+1
View File
@@ -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';
+383 -22
View File
@@ -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<TerminalMenuControl>();
final sleepControl = Get.find<TerminalSleepControl>();
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: <Widget>[
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<TerminalSleepControl>();
return Semantics(
button: true,
label: '进入终端睡眠',
child: Tooltip(
message: '进入睡眠',
child: InkWell(
onTap: () {
sleepControl.enterSleep();
HapticFeedback.selectionClick();
},
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: <Widget>[
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<void> _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 <Widget>[
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 <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(24.w),
),
child: <Widget>[
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: <Widget>[
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),
<Widget>[
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<TerminalSleepControl>();
return PopScope(
canPop: false,
child: Semantics(
container: true,
scopesRoute: true,
explicitChildNodes: true,
label: '终端睡眠中',
child: ColoredBox(
color: XfjDesignTokens.canvas,
child: SafeArea(
child: <Widget>[
_buildBrandHeader(theme),
<Widget>[
_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 <Widget>[
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,
);
}
}
+4
View File
@@ -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<void> checkSubScreen() async {
final defaultRouteName = PlatformDispatcher.instance.defaultRouteName;
logger.d("当前的屏幕$defaultRouteName");
@@ -23,6 +25,8 @@ Future<void> 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);
+46 -13
View File
@@ -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<void> _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<void> 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<void> 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<void> _resumeAfterStaleResultClear(int generation) async {
await _recognitionGateway.stopAll();
if (_closed || generation != _requestGeneration) return;
unawaited(_clearSubScreen());
if (!_closed && autoRecognitionEnabled.value) await startRecognition();
}
/// 建立本次页面会话,并按进入页面时加载的开关选择自动识别或手动等待态。
Future<void> _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<void> 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<void> _syncSubScreen(
+2
View File
@@ -13,6 +13,7 @@ import 'widgets/reservation_verification_dialog.dart';
class ReservationMenuQueryPage extends GetView<ReservationMenuQueryController> {
const ReservationMenuQueryPage({super.key});
/// 分别监听侧栏和结果区实际使用的状态,自动识别开关只触发侧栏局部重建。
@override
Widget build(BuildContext context) {
// 左右工作区各自监听实际使用的 Rx,避免核销倒计时或页码变化重建整张页面。
@@ -57,6 +58,7 @@ class ReservationMenuQueryPage extends GetView<ReservationMenuQueryController> {
methods: controller.methodOrder.toList(),
selected: controller.method.value,
status: controller.status.value,
autoRecognitionEnabled: controller.autoRecognitionEnabled.value,
onSelect: controller.selectMethod,
onStart: controller.startRecognition,
),
@@ -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<ReservationRecognitionMethod> methods;
/// 当前选中的识别方式。
final ReservationRecognitionMethod selected;
/// 当前识别与预约查询状态,用于禁用按钮或展示自动运行反馈。
final ReservationMenuQueryStatus status;
/// 是否以自动识别状态区替换手动启动按钮。
final bool autoRecognitionEnabled;
/// 用户切换识别方式时调用的 Controller 回调。
final ValueChanged<ReservationRecognitionMethod> 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 = <Widget>[
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 {
@@ -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: <Widget>[
_header(theme),
const Divider(height: 1, color: XfjDesignTokens.line),
_summary(theme),
<Widget>[
_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: <Widget>[
_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: <Widget>[
_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);
}
@@ -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,
@@ -175,13 +175,15 @@ class _ReservationQuerySettings extends StatelessWidget {
final SettingController controller;
/// 组合查询方式、自动识别、隐私和核销设置,并保持各区域独立响应式刷新。
@override
Widget build(BuildContext context) => <Widget>[
_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;
+1 -1
View File
@@ -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
@@ -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<bool>();
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);
});
}
}
@@ -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<ReservationQueryPagePreferences> _defaultPreferences() async =>
ReservationQueryPagePreferences.defaults;
Future<ReservationQueryPagePreferences> _autoCardPreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.card,
autoRecognitionEnabled: true,
);
Future<ReservationQueryPagePreferences> _autoFacePreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
defaultMethod: ReservationRecognitionMethod.face,
autoRecognitionEnabled: true,
);
Future<ReservationQueryPagePreferences> _verificationPreferences() async =>
const ReservationQueryPagePreferences(
methodOrder: ReservationRecognitionMethod.values,
@@ -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),
),
),
);
}
@@ -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);
}