feat:核销放大
feat:锁屏页面 feat:饭卡自动查询预约
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user