diff --git a/README.md b/README.md index fb21452..849e611 100644 --- a/README.md +++ b/README.md @@ -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为版本号(内部比较) ``` diff --git a/assets/images/shortcut_keyboard_diagram.png b/assets/images/shortcut_keyboard_diagram.png new file mode 100644 index 0000000..2a9adbb Binary files /dev/null and b/assets/images/shortcut_keyboard_diagram.png differ diff --git a/lib/common/models/plugs/sub_screen_models.dart b/lib/common/models/plugs/sub_screen_models.dart index a48b5bf..eb0d4f2 100644 --- a/lib/common/models/plugs/sub_screen_models.dart +++ b/lib/common/models/plugs/sub_screen_models.dart @@ -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( diff --git a/lib/common/models/plugs/sub_screen_models.g.dart b/lib/common/models/plugs/sub_screen_models.g.dart index d0433bc..48263fe 100644 --- a/lib/common/models/plugs/sub_screen_models.g.dart +++ b/lib/common/models/plugs/sub_screen_models.g.dart @@ -77,6 +77,7 @@ SubScreenReservationQueryEvent _$SubScreenReservationQueryEventFromJson( expiresAt: json['expiresAt'] as String?, continuousFaceRecognition: json['continuousFaceRecognition'] as bool? ?? false, + keyboardHintsEnabled: json['keyboardHintsEnabled'] as bool? ?? false, ); Map _$SubScreenReservationQueryEventToJson( @@ -95,4 +96,5 @@ Map _$SubScreenReservationQueryEventToJson( 'days': instance.days.map((e) => e.toJson()).toList(), 'expiresAt': instance.expiresAt, 'continuousFaceRecognition': instance.continuousFaceRecognition, + 'keyboardHintsEnabled': instance.keyboardHintsEnabled, }; diff --git a/lib/common/widgets/app/app_soft_keyboard.dart b/lib/common/widgets/app/app_soft_keyboard.dart index 2a3dcc6..0f1c1eb 100644 --- a/lib/common/widgets/app/app_soft_keyboard.dart +++ b/lib/common/widgets/app/app_soft_keyboard.dart @@ -16,6 +16,16 @@ enum AppSoftKeyboardMode { symbols, } +/// 浮层软键盘的默认停靠位置。 +enum AppSoftKeyboardDock { + /// 屏幕底部水平居中;适配表单位于页面上方的常规场景。 + bottomCenter, + + /// 屏幕左侧垂直居中;适配表单在右侧的页面(如登录页),键盘停靠品牌区 + /// 一侧,不再遮挡输入框与登录按钮。 + centerLeft, +} + /// 自研软键盘面板:数字/字母/符号三面板 + 顶部切换 tab。 /// /// 定制 ROM 在检测到物理键盘连接时不渲染系统软键盘窗口,本面板以确定性 @@ -449,47 +459,59 @@ class _AppSoftKeyboardPanelState extends State { /// 自研可拖动软键盘弹出框。 /// /// 通过 [AppSoftKeyboard.show] 弹出:键盘浮在页面内容之上(透明遮罩), -/// 默认停靠屏幕底部,可按住顶部手柄行自由拖动,点击键盘外任意处或按 -/// “确认”收起。矮弹框内的输入场景应改用 [AppSoftKeyboardPanel] 内嵌。 +/// 默认停靠屏幕底部水平居中,需要避开页面内容时可用 [AppSoftKeyboardDock] +/// 自定义停靠位(如登录页停靠左侧垂直居中);弹出后仍可按住顶部手柄行 +/// 自由拖动,点击键盘外任意处或按“确认”收起。矮弹框内的输入场景应改用 +/// [AppSoftKeyboardPanel] 内嵌。 abstract final class AppSoftKeyboard { /// 浮层键盘的面板宽度(内容区,含左右内边距共 760)。 static final double floatingContentWidth = 736.w; /// 弹出浮层键盘并绑定目标控制器;返回的 Future 在键盘关闭时完成。 + /// + /// 浮层以 [RawDialogRoute](requestFocus: false)实现:键盘是输入法而非 + /// 模态任务,路由入栈不抢主焦点,被点击的输入框保持聚焦描边,实体键盘 + /// 与扫码枪的输入继续进入该输入框。 static Future show( BuildContext context, { required TextEditingController controller, AppSoftKeyboardMode mode = AppSoftKeyboardMode.digits, + AppSoftKeyboardDock dock = AppSoftKeyboardDock.bottomCenter, RegExp? allowPattern, int? maxLength, String semanticLabel = '终端软键盘', }) { - return showGeneralDialog( - 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( + RawDialogRoute( + // 不抢焦点:模态路由默认把主焦点移入弹层 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: [ + _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: [ - _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, + ), + ), + ), + ), ), ), ); diff --git a/lib/common/widgets/app/shortcut_keyboard_diagram.dart b/lib/common/widgets/app/shortcut_keyboard_diagram.dart new file mode 100644 index 0000000..169b206 --- /dev/null +++ b/lib/common/widgets/app/shortcut_keyboard_diagram.dart @@ -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, + ); + } +} diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 8e0d078..d23f7c7 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -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'; diff --git a/lib/pages/root/login/view.dart b/lib/pages/root/login/view.dart index 05d8f19..a72c404 100644 --- a/lib/pages/root/login/view.dart +++ b/lib/pages/root/login/view.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: '密码输入键盘', ); }, diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index f3bef90..cb6e4c7 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -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); diff --git a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart index c24acad..0eaaac1 100644 --- a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart +++ b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart @@ -130,6 +130,8 @@ class _SettingSubScreenRoutesState extends State { _SubScreenPreviewType.settlement => {'scenario': _scenario.name}, _SubScreenPreviewType.reservationQuery => { 'scenario': _reservationScenario.name, + // 预览固定演示开启键盘模式的等待布局,便于现场核对键盘操作提示。 + 'keyboardHints': true, }, }; Navigator.of(context, rootNavigator: true).push( diff --git a/lib/sub_screen/common/models/reservation_query_display_models.dart b/lib/sub_screen/common/models/reservation_query_display_models.dart index 5595aba..c448792 100644 --- a/lib/sub_screen/common/models/reservation_query_display_models.dart +++ b/lib/sub_screen/common/models/reservation_query_display_models.dart @@ -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, ); } diff --git a/lib/sub_screen/common/utils/reservation_query_coordinator.dart b/lib/sub_screen/common/utils/reservation_query_coordinator.dart index 64d419b..c6c0bb6 100644 --- a/lib/sub_screen/common/utils/reservation_query_coordinator.dart +++ b/lib/sub_screen/common/utils/reservation_query_coordinator.dart @@ -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, ); } diff --git a/lib/sub_screen/page/reservation_query_display.dart b/lib/sub_screen/page/reservation_query_display.dart index ca7a556..80778b7 100644 --- a/lib/sub_screen/page/reservation_query_display.dart +++ b/lib/sub_screen/page/reservation_query_display.dart @@ -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, + ), + ), + ), + ], + ), + ], + ], ), ); } diff --git a/lib/sub_screen/page/reservation_query_preview.dart b/lib/sub_screen/page/reservation_query_preview.dart index 206f3ac..9dd8d0d 100644 --- a/lib/sub_screen/page/reservation_query_preview.dart +++ b/lib/sub_screen/page/reservation_query_preview.dart @@ -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, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 42c40be..4073025 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/sub_screen/goldens/reservation_query_keyboard_hint_1024.png b/test/sub_screen/goldens/reservation_query_keyboard_hint_1024.png new file mode 100644 index 0000000..7c74087 Binary files /dev/null and b/test/sub_screen/goldens/reservation_query_keyboard_hint_1024.png differ diff --git a/test/sub_screen/goldens/reservation_query_keyboard_hint_1920.png b/test/sub_screen/goldens/reservation_query_keyboard_hint_1920.png new file mode 100644 index 0000000..3ea028e Binary files /dev/null and b/test/sub_screen/goldens/reservation_query_keyboard_hint_1920.png differ diff --git a/test/sub_screen/reservation_query_coordinator_test.dart b/test/sub_screen/reservation_query_coordinator_test.dart index d11a5c6..a7664cf 100644 --- a/test/sub_screen/reservation_query_coordinator_test.dart +++ b/test/sub_screen/reservation_query_coordinator_test.dart @@ -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 _reservationEvent({ required int revision, required DateTime expiresAt, bool continuousFaceRecognition = false, + bool keyboardHintsEnabled = false, }) { return { 'sessionId': sessionId, @@ -467,6 +548,7 @@ Map _reservationEvent({ 'totalPages': 1, 'expiresAt': expiresAt.toIso8601String(), 'continuousFaceRecognition': continuousFaceRecognition, + 'keyboardHintsEnabled': keyboardHintsEnabled, 'days': [ _day('2026-07-24'), _day('2026-07-25'), diff --git a/test/sub_screen/reservation_query_keyboard_hint_preview_test.dart b/test/sub_screen/reservation_query_keyboard_hint_preview_test.dart new file mode 100644 index 0000000..e2e755d --- /dev/null +++ b/test/sub_screen/reservation_query_keyboard_hint_preview_test.dart @@ -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 _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))); + } +} diff --git a/test/sub_screen/reservation_query_widget_test.dart b/test/sub_screen/reservation_query_widget_test.dart index ce2701e..c10fbee 100644 --- a/test/sub_screen/reservation_query_widget_test.dart +++ b/test/sub_screen/reservation_query_widget_test.dart @@ -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 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(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; diff --git a/test/widgets/app_soft_keyboard_test.dart b/test/widgets/app_soft_keyboard_test.dart index b6659a1..80e1280 100644 --- a/test/widgets/app_soft_keyboard_test.dart +++ b/test/widgets/app_soft_keyboard_test.dart @@ -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(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(keyScale).scale, 1.0); + + // 指针按下未抬起:键帽立即进入按压缩放态。 + final gesture = await tester.startGesture( + tester.getCenter(find.text('5')), + ); + await tester.pump(); + expect(tester.widget(keyScale).scale, closeTo(0.92, 0.001)); + + // 抬起后键帽复原并完成一次输入。 + await gesture.up(); + await tester.pumpAndSettle(); + expect(tester.widget(keyScale).scale, 1.0); + expect(controller.text, '5'); + }); }