feat:登录页面键盘默认放到左边

feat:增加键盘按下动画
feat:增加"查询菜单"副屏键盘提示
This commit is contained in:
dzc committed 2026-09-28 12:08:36 +08:00
1 parent 96ae2b45c0
commit 56531aad9d
21 files changed
+805 -119

No files matched your search

+1 -1
View File
@@ -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,
};
+142 -76
View File
@@ -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,
);
}
}
+1
View File
@@ -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';
+4
View File
@@ -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
View File
@@ -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;
+141
View File
@@ -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');
});
}