feat:登录页面键盘默认放到左边
feat:增加键盘按下动画 feat:增加"查询菜单"副屏键盘提示
This commit is contained in:
1 parent
96ae2b45c0
commit
56531aad9d
21 files changed
+805
-119
No files matched your search
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
|
||||
## flutter打包
|
||||
|
||||
```
|
||||
flutter build apk --build-name 1.5.1 --build-number 151
|
||||
flutter build apk --build-name 1.5.2 --build-number 152
|
||||
--build-name为版本号(展示)
|
||||
--build-number为版本号(内部比较)
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -117,6 +117,9 @@ class SubScreenReservationQueryEvent {
|
||||
/// 是否启用查询菜单的持续人脸组合页面;旧副屏消息缺省为关闭。
|
||||
final bool continuousFaceRecognition;
|
||||
|
||||
/// 是否在副屏等待识别布局展示快捷键盘操作提示;对应主屏“键盘模式”开关。
|
||||
final bool keyboardHintsEnabled;
|
||||
|
||||
const SubScreenReservationQueryEvent({
|
||||
required this.sessionId,
|
||||
required this.revision,
|
||||
@@ -131,6 +134,7 @@ class SubScreenReservationQueryEvent {
|
||||
required this.days,
|
||||
this.expiresAt,
|
||||
this.continuousFaceRecognition = false,
|
||||
this.keyboardHintsEnabled = false,
|
||||
});
|
||||
|
||||
factory SubScreenReservationQueryEvent.fromJson(
|
||||
|
||||
@@ -77,6 +77,7 @@ SubScreenReservationQueryEvent _$SubScreenReservationQueryEventFromJson(
|
||||
expiresAt: json['expiresAt'] as String?,
|
||||
continuousFaceRecognition:
|
||||
json['continuousFaceRecognition'] as bool? ?? false,
|
||||
keyboardHintsEnabled: json['keyboardHintsEnabled'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SubScreenReservationQueryEventToJson(
|
||||
@@ -95,4 +96,5 @@ Map<String, dynamic> _$SubScreenReservationQueryEventToJson(
|
||||
'days': instance.days.map((e) => e.toJson()).toList(),
|
||||
'expiresAt': instance.expiresAt,
|
||||
'continuousFaceRecognition': instance.continuousFaceRecognition,
|
||||
'keyboardHintsEnabled': instance.keyboardHintsEnabled,
|
||||
};
|
||||
@@ -16,6 +16,16 @@ enum AppSoftKeyboardMode {
|
||||
symbols,
|
||||
}
|
||||
|
||||
/// 浮层软键盘的默认停靠位置。
|
||||
enum AppSoftKeyboardDock {
|
||||
/// 屏幕底部水平居中;适配表单位于页面上方的常规场景。
|
||||
bottomCenter,
|
||||
|
||||
/// 屏幕左侧垂直居中;适配表单在右侧的页面(如登录页),键盘停靠品牌区
|
||||
/// 一侧,不再遮挡输入框与登录按钮。
|
||||
centerLeft,
|
||||
}
|
||||
|
||||
/// 自研软键盘面板:数字/字母/符号三面板 + 顶部切换 tab。
|
||||
///
|
||||
/// 定制 ROM 在检测到物理键盘连接时不渲染系统软键盘窗口,本面板以确定性
|
||||
@@ -449,47 +459,59 @@ class _AppSoftKeyboardPanelState extends State<AppSoftKeyboardPanel> {
|
||||
/// 自研可拖动软键盘弹出框。
|
||||
///
|
||||
/// 通过 [AppSoftKeyboard.show] 弹出:键盘浮在页面内容之上(透明遮罩),
|
||||
/// 默认停靠屏幕底部,可按住顶部手柄行自由拖动,点击键盘外任意处或按
|
||||
/// “确认”收起。矮弹框内的输入场景应改用 [AppSoftKeyboardPanel] 内嵌。
|
||||
/// 默认停靠屏幕底部水平居中,需要避开页面内容时可用 [AppSoftKeyboardDock]
|
||||
/// 自定义停靠位(如登录页停靠左侧垂直居中);弹出后仍可按住顶部手柄行
|
||||
/// 自由拖动,点击键盘外任意处或按“确认”收起。矮弹框内的输入场景应改用
|
||||
/// [AppSoftKeyboardPanel] 内嵌。
|
||||
abstract final class AppSoftKeyboard {
|
||||
/// 浮层键盘的面板宽度(内容区,含左右内边距共 760)。
|
||||
static final double floatingContentWidth = 736.w;
|
||||
|
||||
/// 弹出浮层键盘并绑定目标控制器;返回的 Future 在键盘关闭时完成。
|
||||
///
|
||||
/// 浮层以 [RawDialogRoute](requestFocus: false)实现:键盘是输入法而非
|
||||
/// 模态任务,路由入栈不抢主焦点,被点击的输入框保持聚焦描边,实体键盘
|
||||
/// 与扫码枪的输入继续进入该输入框。
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required TextEditingController controller,
|
||||
AppSoftKeyboardMode mode = AppSoftKeyboardMode.digits,
|
||||
AppSoftKeyboardDock dock = AppSoftKeyboardDock.bottomCenter,
|
||||
RegExp? allowPattern,
|
||||
int? maxLength,
|
||||
String semanticLabel = '终端软键盘',
|
||||
}) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
// 透明遮罩:键盘期间页面内容可见,点击键盘外任意处即收起。
|
||||
barrierColor: Colors.transparent,
|
||||
// barrierDismissible 开启时框架要求提供遮罩语义标签。
|
||||
barrierLabel: '收起软键盘',
|
||||
// 键盘要求即时出现与消失,不做转场动画。
|
||||
transitionDuration: Duration.zero,
|
||||
pageBuilder: (dialogContext, _, __) => _SoftKeyboardDialog(
|
||||
controller: controller,
|
||||
mode: mode,
|
||||
allowPattern: allowPattern,
|
||||
maxLength: maxLength,
|
||||
semanticLabel: semanticLabel,
|
||||
return Navigator.of(context, rootNavigator: true).push<void>(
|
||||
RawDialogRoute<void>(
|
||||
// 不抢焦点:模态路由默认把主焦点移入弹层 FocusScope,会导致底层
|
||||
// 输入框失去聚焦描边、实体输入无法进入目标框。
|
||||
requestFocus: false,
|
||||
pageBuilder: (dialogContext, _, __) => _SoftKeyboardDialog(
|
||||
controller: controller,
|
||||
mode: mode,
|
||||
dock: dock,
|
||||
allowPattern: allowPattern,
|
||||
maxLength: maxLength,
|
||||
semanticLabel: semanticLabel,
|
||||
),
|
||||
barrierDismissible: true,
|
||||
// 透明遮罩:键盘期间页面内容可见,点击键盘外任意处即收起。
|
||||
barrierColor: Colors.transparent,
|
||||
// barrierDismissible 开启时框架要求提供遮罩语义标签。
|
||||
barrierLabel: '收起软键盘',
|
||||
// 键盘要求即时出现与消失,不做转场动画。
|
||||
transitionDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 浮层键盘宿主:停靠屏幕底部、承载拖动手柄行与内嵌面板。
|
||||
/// 浮层键盘宿主:按停靠位对齐面板、承载拖动手柄行与内嵌面板。
|
||||
class _SoftKeyboardDialog extends StatefulWidget {
|
||||
const _SoftKeyboardDialog({
|
||||
required this.controller,
|
||||
required this.mode,
|
||||
required this.dock,
|
||||
required this.semanticLabel,
|
||||
this.allowPattern,
|
||||
this.maxLength,
|
||||
@@ -497,6 +519,10 @@ class _SoftKeyboardDialog extends StatefulWidget {
|
||||
|
||||
final TextEditingController controller;
|
||||
final AppSoftKeyboardMode mode;
|
||||
|
||||
/// 弹出时的默认停靠位;拖动偏移在此基础上叠加。
|
||||
final AppSoftKeyboardDock dock;
|
||||
|
||||
final RegExp? allowPattern;
|
||||
final int? maxLength;
|
||||
final String semanticLabel;
|
||||
@@ -506,7 +532,7 @@ class _SoftKeyboardDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
|
||||
/// 拖动偏移;相对默认底部居中位置。
|
||||
/// 拖动偏移;相对当前停靠位的默认位置。
|
||||
Offset _dragOffset = Offset.zero;
|
||||
|
||||
/// 拖动手势更新偏移;按屏幕半宽钳制,保证键盘不会被完全拖出屏幕。
|
||||
@@ -525,45 +551,55 @@ class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 弹层约束为全屏;用 Spacer 把面板可靠地压到屏幕底部,底部留出安全边距。
|
||||
// 弹层约束为全屏;按停靠位对齐面板,停靠侧留出安全边距。
|
||||
final (alignment, margin) = switch (widget.dock) {
|
||||
AppSoftKeyboardDock.bottomCenter => (
|
||||
Alignment.bottomCenter,
|
||||
EdgeInsets.only(bottom: 16.h),
|
||||
),
|
||||
AppSoftKeyboardDock.centerLeft => (
|
||||
Alignment.centerLeft,
|
||||
EdgeInsets.only(left: 16.w),
|
||||
),
|
||||
};
|
||||
return SizedBox.expand(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 16.h),
|
||||
padding: margin,
|
||||
child: Transform.translate(
|
||||
offset: _dragOffset,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 760.w,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.canvas,
|
||||
borderRadius:
|
||||
BorderRadius.circular(XfjDesignTokens.cardRadius),
|
||||
border: Border.all(color: XfjDesignTokens.line),
|
||||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 760.w,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.canvas,
|
||||
borderRadius:
|
||||
BorderRadius.circular(XfjDesignTokens.cardRadius),
|
||||
border: Border.all(color: XfjDesignTokens.line),
|
||||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||||
),
|
||||
// Align 传入的是有界约束,列必须显式收缩,否则面板被拉满全屏高。
|
||||
child: <Widget>[
|
||||
_buildDragHandle(),
|
||||
SizedBox(height: 10.h),
|
||||
AppSoftKeyboardPanel(
|
||||
controller: widget.controller,
|
||||
mode: widget.mode,
|
||||
allowPattern: widget.allowPattern,
|
||||
maxLength: widget.maxLength,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
onCloseRequested: _close,
|
||||
contentWidth: AppSoftKeyboard.floatingContentWidth,
|
||||
),
|
||||
child: <Widget>[
|
||||
_buildDragHandle(),
|
||||
SizedBox(height: 10.h),
|
||||
AppSoftKeyboardPanel(
|
||||
controller: widget.controller,
|
||||
mode: widget.mode,
|
||||
allowPattern: widget.allowPattern,
|
||||
maxLength: widget.maxLength,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
onCloseRequested: _close,
|
||||
contentWidth: AppSoftKeyboard.floatingContentWidth,
|
||||
),
|
||||
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
|
||||
].toColumn(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -589,7 +625,11 @@ class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
|
||||
|
||||
/// 单个键盘按键;固定宽度布局(弹层处于无界高度约束,禁用 Expanded),
|
||||
/// 主操作键使用主题色底白字。
|
||||
class _Key extends StatelessWidget {
|
||||
///
|
||||
/// 按压反馈走“整键缩放”而非涟漪:Listener 直接监听指针按下/抬起,只触发
|
||||
/// 本键的一次 Transform 动画,不参与手势竞技场、不引起布局重排,是触摸
|
||||
/// 反馈里开销最低的实现;透明 Material 上涟漪本就不可见。
|
||||
class _Key extends StatefulWidget {
|
||||
const _Key({
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
@@ -614,33 +654,59 @@ class _Key extends StatelessWidget {
|
||||
/// 按键高度;默认 62。
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<_Key> createState() => _KeyState();
|
||||
}
|
||||
|
||||
class _KeyState extends State<_Key> {
|
||||
/// 按压目标缩放:模拟实体键帽下沉,幅度保证一眼可辨。
|
||||
static const double _pressedScale = 0.92;
|
||||
|
||||
/// 当前是否有指针按在本键上;驱动按压缩放。
|
||||
bool _pressed = false;
|
||||
|
||||
void _setPressed(bool value) {
|
||||
if (!mounted || _pressed == value) return;
|
||||
setState(() => _pressed = value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyHeight = height ?? 62.h;
|
||||
final keyHeight = widget.height ?? 62.h;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticLabel,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: (child ?? const SizedBox.shrink())
|
||||
.center()
|
||||
.tight(width: width, height: keyHeight)
|
||||
.decorated(
|
||||
color: emphasized
|
||||
? XfjDesignTokens.primary
|
||||
: XfjDesignTokens.surface,
|
||||
border: Border.all(
|
||||
color: emphasized
|
||||
? XfjDesignTokens.primary
|
||||
: XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
label: widget.semanticLabel,
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _setPressed(true),
|
||||
onPointerUp: (_) => _setPressed(false),
|
||||
onPointerCancel: (_) => _setPressed(false),
|
||||
child: AnimatedScale(
|
||||
scale: _pressed ? _pressedScale : 1,
|
||||
duration: const Duration(milliseconds: 90),
|
||||
curve: Curves.easeOut,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: (widget.child ?? const SizedBox.shrink())
|
||||
.center()
|
||||
.tight(width: widget.width, height: keyHeight)
|
||||
.decorated(
|
||||
color: widget.emphasized
|
||||
? XfjDesignTokens.primary
|
||||
: XfjDesignTokens.surface,
|
||||
border: Border.all(
|
||||
color: widget.emphasized
|
||||
? XfjDesignTokens.primary
|
||||
: XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 终端外接快捷小键盘的俯视示意图组件。
|
||||
///
|
||||
/// 键盘质感来自预渲染的 3D 素材图 [assetName]:奶油白机身、彩色键帽、
|
||||
/// 倒角高光与软阴影均已在图片中固化,键位为上排“人脸”“饭卡”与一颗未
|
||||
/// 绑定动作的留白键,下排为占两列的加宽“核销”键与“取消”键。文字随
|
||||
/// 图片等比缩放,布局按宿主尺寸传入 [width];素材为透明底,可直接落在
|
||||
/// 任意浅色页面上。
|
||||
///
|
||||
/// 仅为展示示意,不响应任何交互;主副屏共用。
|
||||
class ShortcutKeyboardDiagram extends StatelessWidget {
|
||||
/// 创建键盘示意图;高度按素材纵横比随 [width] 等比计算。
|
||||
const ShortcutKeyboardDiagram({
|
||||
super.key,
|
||||
this.width = 486,
|
||||
this.assetName = 'assets/images/shortcut_keyboard_diagram.png',
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// 示意图展示宽度,按 486 设计宽随副屏缩放系数传入。
|
||||
final double width;
|
||||
|
||||
/// 键盘素材地址;默认使用内置 3D 渲染图,便于全局替换升级。
|
||||
final String assetName;
|
||||
|
||||
/// 无障碍描述;为空时使用图片自带语义。
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image.asset(
|
||||
assetName,
|
||||
width: width,
|
||||
filterQuality: FilterQuality.medium,
|
||||
semanticLabel: semanticLabel,
|
||||
excludeFromSemantics: semanticLabel == null,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export './app/terminal_shell.dart';
|
||||
export './app/terminal_device_status.dart';
|
||||
export './app/terminal_toolbar.dart';
|
||||
export './app/terminal_data_scope_tabs.dart';
|
||||
export './app/shortcut_keyboard_diagram.dart';
|
||||
|
||||
export './cashier/cashier_workspace.dart';
|
||||
export './face/face_recognition_preview_layer.dart';
|
||||
|
||||
@@ -142,10 +142,12 @@ class _LoginFormPanelState extends State<_LoginFormPanel> {
|
||||
autofillHints: const [AutofillHints.username],
|
||||
onTap: () {
|
||||
widget.controller.markManualInputStarted();
|
||||
// 登录表单在页面右侧,键盘停靠左侧品牌区垂直居中,避免遮挡输入框。
|
||||
AppSoftKeyboard.show(
|
||||
context,
|
||||
controller: widget.controller.usernameC,
|
||||
mode: AppSoftKeyboardMode.letters,
|
||||
dock: AppSoftKeyboardDock.centerLeft,
|
||||
semanticLabel: '用户名输入键盘',
|
||||
);
|
||||
},
|
||||
@@ -169,10 +171,12 @@ class _LoginFormPanelState extends State<_LoginFormPanel> {
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onTap: () {
|
||||
widget.controller.markManualInputStarted();
|
||||
// 与用户名输入一致:键盘停靠左侧,不遮挡右侧表单。
|
||||
AppSoftKeyboard.show(
|
||||
context,
|
||||
controller: widget.controller.passwordC,
|
||||
mode: AppSoftKeyboardMode.letters,
|
||||
dock: AppSoftKeyboardDock.centerLeft,
|
||||
semanticLabel: '密码输入键盘',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -216,6 +216,9 @@ class ReservationMenuQueryController extends GetxController
|
||||
/// 外接快捷小键盘输入协调器;负责按键匹配与语音播报,动作执行回到本 Controller 的 mixin。
|
||||
ReservationShortcutKeyboard? _shortcutKeyboard;
|
||||
|
||||
/// 进入页面时加载的“键盘模式”开关快照;开启时副屏等待识别布局展示键盘操作提示。
|
||||
bool _keyboardHintsEnabled = false;
|
||||
|
||||
/// 当前预约弹框的快捷键远程控制句柄;随弹框展示生命周期由页面层挂载与摘除。
|
||||
ReservationOrderDialogHandle? _resultDialogHandle;
|
||||
|
||||
@@ -333,6 +336,8 @@ class ReservationMenuQueryController extends GetxController
|
||||
allowManualMealPeriodChange.value =
|
||||
preferences.allowManualMealPeriodChange;
|
||||
verificationDateScope.value = preferences.verificationDateScope;
|
||||
// 键盘模式同时决定副屏等待识别布局是否展示键盘操作提示,先落快照再启动键盘。
|
||||
_keyboardHintsEnabled = preferences.keyboardModeEnabled;
|
||||
_startShortcutKeyboard(
|
||||
enabled: preferences.keyboardModeEnabled,
|
||||
bindings: preferences.shortcutKeyBindings,
|
||||
@@ -1357,6 +1362,7 @@ class ReservationMenuQueryController extends GetxController
|
||||
days: includeResult ? currentDays : const [],
|
||||
expiresAt: _expiresAt?.toIso8601String(),
|
||||
continuousFaceRecognition: continuousFaceRecognitionActive,
|
||||
keyboardHintsEnabled: _keyboardHintsEnabled,
|
||||
);
|
||||
try {
|
||||
await _subScreenGateway.update(event);
|
||||
|
||||
@@ -130,6 +130,8 @@ class _SettingSubScreenRoutesState extends State<SettingSubScreenRoutes> {
|
||||
_SubScreenPreviewType.settlement => {'scenario': _scenario.name},
|
||||
_SubScreenPreviewType.reservationQuery => {
|
||||
'scenario': _reservationScenario.name,
|
||||
// 预览固定演示开启键盘模式的等待布局,便于现场核对键盘操作提示。
|
||||
'keyboardHints': true,
|
||||
},
|
||||
};
|
||||
Navigator.of(context, rootNavigator: true).push<void>(
|
||||
|
||||
@@ -78,11 +78,13 @@ class ReservationQueryDisplayState {
|
||||
this.expiresAt,
|
||||
this.remainingSeconds = 0,
|
||||
this.continuousFaceRecognition = false,
|
||||
this.keyboardHintsEnabled = false,
|
||||
});
|
||||
|
||||
factory ReservationQueryDisplayState.idle({
|
||||
String sessionId = '',
|
||||
int revision = 0,
|
||||
bool keyboardHintsEnabled = false,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
@@ -93,6 +95,7 @@ class ReservationQueryDisplayState {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +103,7 @@ class ReservationQueryDisplayState {
|
||||
factory ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario scenario, {
|
||||
DateTime? now,
|
||||
bool keyboardHintsEnabled = false,
|
||||
}) {
|
||||
final current = now ?? DateTime.now();
|
||||
if (scenario == ReservationQueryDisplayScenario.idle ||
|
||||
@@ -116,6 +120,7 @@ class ReservationQueryDisplayState {
|
||||
days: const [],
|
||||
continuousFaceRecognition:
|
||||
scenario == ReservationQueryDisplayScenario.recognizing,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.loading ||
|
||||
@@ -130,6 +135,7 @@ class ReservationQueryDisplayState {
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: false,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,11 +224,15 @@ class ReservationQueryDisplayState {
|
||||
/// 是否在副屏使用摄像头与预约内容并列的持续人脸布局。
|
||||
final bool continuousFaceRecognition;
|
||||
|
||||
/// 是否在等待识别布局展示快捷键盘操作提示;终端级配置,随主屏“键盘模式”开关联动。
|
||||
final bool keyboardHintsEnabled;
|
||||
|
||||
/// 复制计时器需要变化的场景或剩余秒数,其余快照内容保持不变。
|
||||
ReservationQueryDisplayState copyWith({
|
||||
ReservationQueryDisplayScenario? scenario,
|
||||
int? remainingSeconds,
|
||||
bool? continuousFaceRecognition,
|
||||
bool? keyboardHintsEnabled,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
@@ -240,6 +250,7 @@ class ReservationQueryDisplayState {
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
continuousFaceRecognition:
|
||||
continuousFaceRecognition ?? this.continuousFaceRecognition,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled ?? this.keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +270,8 @@ class ReservationQueryDisplayState {
|
||||
listEquals(other.days, days) &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.remainingSeconds == remainingSeconds &&
|
||||
other.continuousFaceRecognition == continuousFaceRecognition;
|
||||
other.continuousFaceRecognition == continuousFaceRecognition &&
|
||||
other.keyboardHintsEnabled == keyboardHintsEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -278,5 +290,6 @@ class ReservationQueryDisplayState {
|
||||
expiresAt,
|
||||
remainingSeconds,
|
||||
continuousFaceRecognition,
|
||||
keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,14 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
factory ReservationQueryCoordinator.demo(
|
||||
ReservationQueryDisplayScenario scenario,
|
||||
) {
|
||||
ReservationQueryDisplayScenario scenario, {
|
||||
bool keyboardHintsEnabled = false,
|
||||
}) {
|
||||
return ReservationQueryCoordinator(
|
||||
initialState: ReservationQueryDisplayState.demo(scenario),
|
||||
initialState: ReservationQueryDisplayState.demo(
|
||||
scenario,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,10 +81,12 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
}
|
||||
_commitEnvelope(sessionId, revision);
|
||||
_stopExpiryTimer();
|
||||
// 键盘提示是终端级展示配置,不随会话数据一起清除,清屏后回到等待布局仍继续展示。
|
||||
return _replace(
|
||||
ReservationQueryDisplayState.idle(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
keyboardHintsEnabled: _state.keyboardHintsEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -91,6 +97,7 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
final scenarioName = _nonEmptyString(data['scenario']);
|
||||
final continuousFaceRecognition =
|
||||
_optionalBool(data['continuousFaceRecognition']);
|
||||
final keyboardHintsEnabled = _optionalBool(data['keyboardHintsEnabled']);
|
||||
ReservationQueryDisplayScenario? scenario;
|
||||
for (final value in ReservationQueryDisplayScenario.values) {
|
||||
if (value.name == scenarioName) {
|
||||
@@ -114,6 +121,7 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +183,7 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
expiresAt: expiresAt,
|
||||
remainingSeconds: remaining,
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
keyboardHintsEnabled: keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,6 +284,7 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
return ReservationQueryDisplayState.idle(
|
||||
sessionId: current.sessionId,
|
||||
revision: current.revision,
|
||||
keyboardHintsEnabled: current.keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
return ReservationQueryDisplayState(
|
||||
@@ -287,6 +297,7 @@ class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: true,
|
||||
keyboardHintsEnabled: current.keyboardHintsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -572,53 +572,180 @@ class _ReservationStatePanel extends StatelessWidget {
|
||||
child: Semantics(
|
||||
liveRegion: state.scenario == ReservationQueryDisplayScenario.error,
|
||||
label: '$title,$description',
|
||||
child: Column(
|
||||
// scaleDown 兜底:键盘提示加入后小尺寸副屏可能超出剩余高度,
|
||||
// 放不下时整块按比例微缩,保证任何分辨率都不溢出裁切。
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 120 * scale,
|
||||
height: 120 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: state.scenario == ReservationQueryDisplayScenario.loading
|
||||
? SizedBox(
|
||||
width: 52 * scale,
|
||||
height: 52 * scale,
|
||||
child: CircularProgressIndicator(
|
||||
color: color,
|
||||
strokeWidth: 6 * scale,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_scenarioIcon(state.scenario),
|
||||
color: color,
|
||||
size: _fontSize(60, scale),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 26 * scale),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(38, scale),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(21, scale),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
// 键盘提示只在“请刷饭卡或进行人脸识别”等待布局展示,且跟随主屏键盘模式开关。
|
||||
if (state.scenario == ReservationQueryDisplayScenario.idle &&
|
||||
state.keyboardHintsEnabled) ...[
|
||||
SizedBox(height: 18 * scale),
|
||||
_ReservationKeyboardHint(scale: scale),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待识别布局中的外接快捷小键盘操作提示。
|
||||
///
|
||||
/// 键盘形态复用公共示意组件 [ShortcutKeyboardDiagram]:上排依次为“人脸”“饭卡”
|
||||
/// 与一个未绑定动作的留白键,下排左侧为占两列的加宽“核销”键、右侧为“取消”键。
|
||||
/// 提示仅为示意图形,不参与任何交互。
|
||||
class _ReservationKeyboardHint extends StatelessWidget {
|
||||
const _ReservationKeyboardHint({required this.scale});
|
||||
|
||||
/// 基于副屏实际尺寸计算的统一缩放系数。
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 120 * scale,
|
||||
height: 120 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: state.scenario == ReservationQueryDisplayScenario.loading
|
||||
? SizedBox(
|
||||
width: 52 * scale,
|
||||
height: 52 * scale,
|
||||
child: CircularProgressIndicator(
|
||||
color: color,
|
||||
strokeWidth: 6 * scale,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_scenarioIcon(state.scenario),
|
||||
color: color,
|
||||
size: _fontSize(60, scale),
|
||||
),
|
||||
Icon(
|
||||
Icons.keyboard_outlined,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: _fontSize(22, scale),
|
||||
),
|
||||
SizedBox(height: 26 * scale),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
title,
|
||||
'键盘操作提示',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(38, scale),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(21, scale),
|
||||
height: 1.5,
|
||||
fontSize: _fontSize(20, scale),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
// 键盘质感来自预渲染 3D 素材图,布局随副屏缩放系数等比收缩;
|
||||
// 显示宽度按视觉验收略小于 486 设计宽,避免在等待布局中占比过大。
|
||||
ShortcutKeyboardDiagram(
|
||||
key: const ValueKey('reservation-keyboard-hint'),
|
||||
width: 440 * scale,
|
||||
semanticLabel: '外接快捷小键盘操作提示示意图',
|
||||
),
|
||||
SizedBox(height: 16 * scale),
|
||||
_ReservationKeyboardHintLines(scale: scale),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘提示下方的通俗操作说明。
|
||||
///
|
||||
/// 面向全部年龄段的顾客与操作员,避免“识别方式”“餐段”等术语:
|
||||
/// 每行以对应按键的语义色圆点开头,说明“两段式按键”“核销前提”和“取消用途”。
|
||||
class _ReservationKeyboardHintLines extends StatelessWidget {
|
||||
const _ReservationKeyboardHintLines({required this.scale});
|
||||
|
||||
/// 基于副屏实际尺寸计算的统一缩放系数。
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const lines = [
|
||||
(
|
||||
color: XfjDesignTokens.primary,
|
||||
text: '按一下「人脸」或「饭卡」是选中,再按一下就开始识别',
|
||||
),
|
||||
(
|
||||
color: XfjDesignTokens.success,
|
||||
text: '按「核销」前要先识别出本人,且当天这一顿有预约才能成功',
|
||||
),
|
||||
(
|
||||
color: XfjDesignTokens.danger,
|
||||
text: '按「取消」可以随时停下来,回到开始的画面',
|
||||
),
|
||||
];
|
||||
return SizedBox(
|
||||
width: 620 * scale,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (index, line) in lines.indexed) ...[
|
||||
if (index > 0) SizedBox(height: 7 * scale),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 色点与按键同色,帮助把文字说明对应到实体按键。
|
||||
Container(
|
||||
width: 10 * scale,
|
||||
height: 10 * scale,
|
||||
margin: EdgeInsets.only(top: 8 * scale, right: 9 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: line.color,
|
||||
borderRadius: BorderRadius.circular(3 * scale),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
line.text,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(19, scale),
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ class _ReservationQueryPreviewPageState
|
||||
final arguments = widget.arguments;
|
||||
final scenarioName =
|
||||
arguments is Map ? arguments['scenario']?.toString() : null;
|
||||
// 设置页副屏预览用 keyboardHints 演示等待布局的键盘操作提示。
|
||||
final keyboardHints =
|
||||
arguments is Map && arguments['keyboardHints'] == true;
|
||||
_coordinator = ReservationQueryCoordinator.demo(
|
||||
reservationQueryDisplayScenarioFromName(scenarioName),
|
||||
keyboardHintsEnabled: keyboardHints,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pj_supermarket_cloud
|
||||
description: "资安信智慧后勤-测试"
|
||||
publish_to: "none"
|
||||
version: 1.5.1+151
|
||||
version: 1.5.2+152
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -189,6 +189,86 @@ void main() {
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
test('键盘提示开关随消息解析并在清屏后继续保留', () {
|
||||
final coordinator = ReservationQueryCoordinator();
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
// 旧主屏未发送 keyboardHintsEnabled 时按关闭处理。
|
||||
expect(
|
||||
coordinator.handleUpdate(
|
||||
const {
|
||||
'sessionId': '310',
|
||||
'revision': 1,
|
||||
'scenario': 'idle',
|
||||
},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.state.keyboardHintsEnabled, isFalse);
|
||||
|
||||
// 新主屏携带开关时等待布局进入键盘提示展示态。
|
||||
expect(
|
||||
coordinator.handleUpdate(
|
||||
const {
|
||||
'sessionId': '310',
|
||||
'revision': 2,
|
||||
'scenario': 'idle',
|
||||
'keyboardHintsEnabled': true,
|
||||
},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.state.keyboardHintsEnabled, isTrue);
|
||||
|
||||
// 显式清屏只清除身份数据,终端级键盘提示配置继续保留。
|
||||
expect(
|
||||
coordinator.handleClear(
|
||||
const {'sessionId': '310', 'revision': 3},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.state.scenario, ReservationQueryDisplayScenario.idle);
|
||||
expect(coordinator.state.keyboardHintsEnabled, isTrue);
|
||||
|
||||
// 字段类型错误时整条消息拒绝,保留最近有效快照。
|
||||
expect(
|
||||
coordinator.handleUpdate(
|
||||
const {
|
||||
'sessionId': '310',
|
||||
'revision': 4,
|
||||
'scenario': 'idle',
|
||||
'keyboardHintsEnabled': 'yes',
|
||||
},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(coordinator.state.keyboardHintsEnabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('结果自行过期清屏后键盘提示仍保留在等待布局', (tester) async {
|
||||
var now = DateTime(2026, 7, 24, 8);
|
||||
final coordinator = ReservationQueryCoordinator(now: () => now);
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
expect(
|
||||
coordinator.handleUpdate(
|
||||
_reservationEvent(
|
||||
sessionId: '320',
|
||||
revision: 1,
|
||||
expiresAt: now.add(const Duration(seconds: 2)),
|
||||
keyboardHintsEnabled: true,
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
now = now.add(const Duration(seconds: 2));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(coordinator.state.scenario, ReservationQueryDisplayScenario.idle);
|
||||
expect(coordinator.state.keyboardHintsEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('结算业务消息到达后自动恢复原顾客结算屏', () {
|
||||
final now = DateTime(2026, 7, 24, 8);
|
||||
final coordinator = SubDisplayCoordinator(
|
||||
@@ -453,6 +533,7 @@ Map<String, dynamic> _reservationEvent({
|
||||
required int revision,
|
||||
required DateTime expiresAt,
|
||||
bool continuousFaceRecognition = false,
|
||||
bool keyboardHintsEnabled = false,
|
||||
}) {
|
||||
return {
|
||||
'sessionId': sessionId,
|
||||
@@ -467,6 +548,7 @@ Map<String, dynamic> _reservationEvent({
|
||||
'totalPages': 1,
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'continuousFaceRecognition': continuousFaceRecognition,
|
||||
'keyboardHintsEnabled': keyboardHintsEnabled,
|
||||
'days': [
|
||||
_day('2026-07-24'),
|
||||
_day('2026-07-25'),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show FontLoader;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/utils/reservation_query_coordinator.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_display.dart';
|
||||
|
||||
/// 加载系统内置的中文字体,保证 Golden 预览图渲染真实文案而不是测试占位方块。
|
||||
Future<void> _loadCjkFont() async {
|
||||
final file = File('/System/Library/Fonts/Supplemental/Arial Unicode.ttf');
|
||||
final bytes = await file.readAsBytes();
|
||||
final loader = FontLoader('PreviewCJK')
|
||||
..addFont(Future.value(ByteData.view(bytes.buffer)));
|
||||
await loader.load();
|
||||
}
|
||||
|
||||
/// 以副屏设计分辨率渲染“请刷饭卡或进行人脸识别”等待布局,输出键盘操作提示的视觉预览 Golden 图。
|
||||
void main() {
|
||||
// 字体加载是真实异步引擎回调,必须放在 testWidgets 的 fake-async 区之外,否则永远挂起。
|
||||
setUpAll(_loadCjkFont);
|
||||
|
||||
for (final size in [
|
||||
const Size(1920, 1080),
|
||||
const Size(1024, 600),
|
||||
]) {
|
||||
testWidgets('${size.width.toInt()}x${size.height.toInt()} 等待布局键盘提示预览',
|
||||
(tester) async {
|
||||
addTearDown(tester.view.reset);
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
final coordinator = ReservationQueryCoordinator(
|
||||
initialState: ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario.idle,
|
||||
now: DateTime(2026, 9, 28, 8),
|
||||
keyboardHintsEnabled: true,
|
||||
),
|
||||
);
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(fontFamily: 'PreviewCJK'),
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light,
|
||||
child: ReservationQueryDisplayPage(coordinator: coordinator),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Image.asset 的磁盘解码是真实异步 I/O,fake-async 下不会自行完成;
|
||||
// 必须用 runAsync 预解码,否则 Golden 拍到的是图片未加载的空窗。
|
||||
await tester.runAsync(() async {
|
||||
await precacheImage(
|
||||
const AssetImage('assets/images/shortcut_keyboard_diagram.png'),
|
||||
tester.element(find.byType(ReservationQueryDisplayPage)),
|
||||
);
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile(
|
||||
'goldens/reservation_query_keyboard_hint_${size.width.toInt()}.png',
|
||||
),
|
||||
);
|
||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,114 @@ void main() {
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
testWidgets('键盘提示只在开启开关的等待识别布局展示', (tester) async {
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
Future<void> pumpState(ReservationQueryDisplayState state) async {
|
||||
final coordinator = ReservationQueryCoordinator(initialState: state);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light,
|
||||
child: ReservationQueryDisplayPage(coordinator: coordinator),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
addTearDown(coordinator.dispose);
|
||||
}
|
||||
|
||||
await pumpState(
|
||||
ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario.idle,
|
||||
now: DateTime(2026, 7, 24, 8),
|
||||
).copyWith(keyboardHintsEnabled: true),
|
||||
);
|
||||
expect(find.text('请刷饭卡或进行人脸识别'), findsOneWidget);
|
||||
expect(find.text('键盘操作提示'), findsOneWidget);
|
||||
// 键盘图形由预渲染 3D 素材图提供:上排 人脸/饭卡/留白键,下排 加宽核销/取消。
|
||||
final diagram = find.descendant(
|
||||
of: find.byKey(const ValueKey('reservation-keyboard-hint')),
|
||||
matching: find.byType(Image),
|
||||
);
|
||||
expect(diagram, findsOneWidget);
|
||||
final image = tester.widget<Image>(diagram);
|
||||
expect(
|
||||
(image.image as AssetImage).assetName,
|
||||
'assets/images/shortcut_keyboard_diagram.png',
|
||||
);
|
||||
// 键帽文字已固化在素材图中,页面不再出现独立按键文案与“目前无作用”。
|
||||
for (final label in const ['人脸', '饭卡', '核销', '取消', '目前无作用']) {
|
||||
expect(find.text(label), findsNothing);
|
||||
}
|
||||
// 键盘下方提供通俗操作说明:两段式按键、核销前提与取消用途。
|
||||
expect(find.textContaining('再按一下就开始识别'), findsOneWidget);
|
||||
expect(find.textContaining('当天这一顿有预约才能成功'), findsOneWidget);
|
||||
expect(find.textContaining('回到开始的画面'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// 关闭开关或非等待场景时不展示键盘提示。
|
||||
await pumpState(
|
||||
ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario.idle,
|
||||
now: DateTime(2026, 7, 24, 8),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('reservation-keyboard-hint')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await pumpState(
|
||||
ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario.loading,
|
||||
now: DateTime(2026, 7, 24, 8),
|
||||
).copyWith(keyboardHintsEnabled: true),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('reservation-keyboard-hint')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('小尺寸副屏开启键盘提示仍保持布局完整', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final coordinator = ReservationQueryCoordinator(
|
||||
initialState: ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario.idle,
|
||||
now: DateTime(2026, 7, 24, 8),
|
||||
).copyWith(keyboardHintsEnabled: true),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light,
|
||||
child: ReservationQueryDisplayPage(coordinator: coordinator),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('键盘操作提示'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('reservation-keyboard-hint')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
testWidgets('持续人脸副屏同时保留实时预览、预约菜单和十秒倒计时', (tester) async {
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
||||
@@ -53,6 +53,8 @@ void main() {
|
||||
final confirmRect = tester.getRect(find.text('确认'));
|
||||
expect(confirmRect.bottom, lessThan(1080));
|
||||
expect(confirmRect.bottom, greaterThan(1080 * 0.5));
|
||||
// 默认停靠位为底部水平居中。
|
||||
expect(confirmRect.center.dx, closeTo(960, 1));
|
||||
|
||||
// 点击数字 1 与 5 后字符进入目标控制器。
|
||||
await tester.tap(find.text('1'));
|
||||
@@ -173,4 +175,143 @@ void main() {
|
||||
await tester.tap(find.text('#+='));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('左侧停靠模式下键盘位于左半屏且垂直居中', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapHost(
|
||||
(context) => Scaffold(
|
||||
body: Center(
|
||||
child: FButton(
|
||||
onPress: () => AppSoftKeyboard.show(
|
||||
context,
|
||||
controller: controller,
|
||||
mode: AppSoftKeyboardMode.digits,
|
||||
dock: AppSoftKeyboardDock.centerLeft,
|
||||
),
|
||||
label: const Text('打开键盘'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('打开键盘'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final confirmRect = tester.getRect(find.text('确认'));
|
||||
// 面板贴左缘停靠,整体落在左半屏。
|
||||
expect(confirmRect.left, greaterThanOrEqualTo(0));
|
||||
expect(confirmRect.right, lessThan(1920 * 0.5));
|
||||
// 垂直居中:面板整体跨在屏幕纵向中线附近。
|
||||
expect(
|
||||
confirmRect.center.dy,
|
||||
inExclusiveRange(1080 * 0.25, 1080 * 0.75),
|
||||
);
|
||||
|
||||
// 输入与确认收起流程不受停靠位影响。
|
||||
await tester.tap(find.text('7'));
|
||||
await tester.pump();
|
||||
expect(controller.text, '7');
|
||||
await tester.tap(find.text('确认'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('确认'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('浮层键盘不抢走输入框焦点', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapHost(
|
||||
(context) => Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 480,
|
||||
child: FTextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.none,
|
||||
onTap: () => AppSoftKeyboard.show(
|
||||
context,
|
||||
controller: controller,
|
||||
mode: AppSoftKeyboardMode.digits,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(EditableText));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 键盘弹出后输入框必须保持聚焦:主焦点不落入弹层,聚焦描边不消失,
|
||||
// 实体键盘与扫码枪输入仍进入该输入框。
|
||||
final editable = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
expect(find.text('确认'), findsOneWidget);
|
||||
|
||||
// 聚焦保持的同时虚拟按键照常写入。
|
||||
await tester.tap(find.text('3'));
|
||||
await tester.pump();
|
||||
expect(controller.text, '3');
|
||||
|
||||
// 点击键盘外收起后输入框仍保持聚焦。
|
||||
await tester.tapAt(const Offset(1800, 60));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('确认'), findsNothing);
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('按键按压时整键缩放、抬起复原且完成输入', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapHost(
|
||||
(context) => Scaffold(
|
||||
body: Center(
|
||||
child: FButton(
|
||||
onPress: () => AppSoftKeyboard.show(
|
||||
context,
|
||||
controller: controller,
|
||||
mode: AppSoftKeyboardMode.digits,
|
||||
),
|
||||
label: const Text('打开键盘'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('打开键盘'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final keyScale = find
|
||||
.ancestor(of: find.text('5'), matching: find.byType(AnimatedScale))
|
||||
.first;
|
||||
expect(tester.widget<AnimatedScale>(keyScale).scale, 1.0);
|
||||
|
||||
// 指针按下未抬起:键帽立即进入按压缩放态。
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.text('5')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.widget<AnimatedScale>(keyScale).scale, closeTo(0.92, 0.001));
|
||||
|
||||
// 抬起后键帽复原并完成一次输入。
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<AnimatedScale>(keyScale).scale, 1.0);
|
||||
expect(controller.text, '5');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user