From f9be6dea13b2ab2cdc5dd148522e7e06e0087411 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 20 Sep 2026 13:20:50 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=9F=A5=E8=AF=A2=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=E9=9D=A2=E9=80=82=E9=85=8D=E5=B0=8F=E9=94=AE?= =?UTF-8?q?=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_ba68eecf-563f-4382-b242-dd01d49ac538.md | 51 +- .../page/reservation_menu_page_models.dart | 49 +- lib/common/utils/app/app_setting_tool.dart | 21 +- .../app/app_shortcut_ime_collector.dart | 91 ++++ lib/common/widgets/index.dart | 1 + lib/pages/terminal/menu_query/controller.dart | 81 +++- .../reservation_shortcut_actions.dart | 211 +++++++++ .../reservation_shortcut_keyboard.dart | 125 +++++ lib/pages/terminal/setting/controller.dart | 34 +- .../widgets/setting_menu_query_shortcut.dart | 440 ++++++++++++------ 10 files changed, 922 insertions(+), 182 deletions(-) create mode 100644 lib/common/widgets/app/app_shortcut_ime_collector.dart create mode 100644 lib/pages/terminal/menu_query/reservation_shortcut_actions.dart create mode 100644 lib/pages/terminal/menu_query/reservation_shortcut_keyboard.dart diff --git a/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md b/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md index 789d0f7..2db4758 100644 --- a/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md +++ b/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md @@ -1,34 +1,43 @@ -# 修复"绑定按键按下 e 后无响应/软键盘弹出"问题 +# 查询菜单键盘模式:设置开关 + 运行时接线 + 冲突矩阵 + 解耦 -## 根因 +## 一、设置页:「该页面是否开启键盘模式」开关(默认否) -截图证明"取消"行已进入"等待按键…"监听态,但按"e"后**没有任何 Toast、没有任何绑定变化** —— 说明按键事件根本没到达 `HardwareKeyboard` 的监听 handler(若到达,成功/冲突都必有 Toast)。 +- `ReservationQueryPagePreferences` 新增 `keyboardModeEnabled`(默认 false,旧终端兼容)。 +- `AppSettingTool`:sysJson 新键 `reservationQueryKeyboardModeEnabled`(缺失=false),随查询菜单偏好一起读写。 +- `SettingController`:Rx 字段 + `setReservationQueryKeyboardModeEnabled` + 载入/还原/串行持久化(复用现有模式)。 +- 设置 UI:「快捷按钮」分组顶部加开关行(SettingInfoRow+FSwitch):"开启键盘模式",副文案"开启后进入查询菜单,按已绑定的物理按键将语音播报并执行对应操作"。 -结合"按 e 时软键盘弹出",根因是 **Android IME(输入法)拦截**: +## 二、解耦结构(三个新文件 + controller 最小改动) -1. 当输入法连接处于激活状态(软键盘弹出、或此前某个输入框残留焦点未释放)时,Android 会把物理键盘的字符键(如 e)先交给输入法,**合成为文本提交**,不再作为 KeyEvent 分发给 Flutter —— 我们的 handler 因此收不到任何 KeyDownEvent。 -2. 若用户按的是**屏幕软键盘上的"e"**(小键盘尚未接入、用软键盘模拟测试),同样只产生 IME 文本提交,Flutter 侧本来就收不到"按了哪个键"的按键事件 —— 软键盘按键**机制上无法**用于绑定。 +键盘输入与动作编排全部移出主 controller,controller 只做接线(项目已有 `OrderMixin` 的 mixin-on-controller 先例): -排查确认:设置模块存在多个可激活 IME 的入口(日志预览关键词筛选框、读写卡密钥三个输入框、食堂机型人脸姓名搜索框、`autofocus: true` 的人工硬件输入弹窗);而我们的 `_startListening` **只注册了键盘监听器,没有断开输入焦点** —— 项目里 `BarcodeScanManage`(扫码枪)正是因为同样的问题,在监听前和每次事件时都先 `unfocus()`。 +1. **`menu_query/reservation_shortcut_keyboard.dart`(新)— 输入协调器** `ReservationShortcutKeyboard`: + 纯类(无 GetX 依赖、可单测)。职责:持有模式开关与绑定快照、挂/卸 `HardwareKeyboard` 处理器、KeyDownEvent+修饰键过滤、`conflictsWith` 匹配动作、命中后回调 `onAction(action)` 并 TTS「你已点击X」;提供 `handleImeText()` 供输入法文本兜底统一进入同一匹配。TTS/提示经构造注入(复用 controller 的 `_speaker`/`_errorNotifier`)。 +2. **`menu_query/reservation_shortcut_actions.dart`(新)— 动作 mixin** `ReservationMenuQueryShortcutActions on ReservationMenuQueryController`: + 实现五个动作的编排(冲突校验矩阵、防重、补打流程),调用 controller 现有公开方法,主 controller 零业务膨胀。 +3. **`common/widgets/app/app_shortcut_ime_collector.dart`(新)— 公共隐形输入收集器**: + 把设置页的 `_ShortcutImeCollector` 与字母反查函数公共化,设置页与查询菜单页共用(模拟器/输入法合成文本两条路径全覆盖)。 -## 修复内容(仅改 1 个文件) +controller 改动仅:`with` 动作 mixin、`_loadPagePreferences` 补读 `keyboardModeEnabled`+`shortcutKeyBindings`、偏好加载后启动键盘协调器(`onClose` 停止)、新增公开 getter `resultDialogPresented`、`_publishQuickVerificationRequest` 改公开 `requestQuickVerification()`(快速核销与快捷键共用同一入口)、核销成功时记录最近核销订单 ID(供补打)。view.dart 挂条件隐形收集器。 -`lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart`: +## 三、动作映射与冲突矩阵(核心细节) -1. **`_startListening` 进入监听时断开输入焦点并隐藏软键盘**(对齐项目既有防御写法 `barcode_scan_manage.dart:63` 与 `order_history/view.dart:433-434` 组合): - ```dart - FocusManager.instance.primaryFocus?.unfocus(); - SystemChannels.textInput.invokeMethod('TextInput.hide'); - ``` - 确保真实小键盘的物理按键走 KeyEvent 路径直达监听器,不被输入法吞掉。 +| 按键 | 动作 | 执行 | 拒绝条件(TTS+Toast 提示) | +|---|---|---|---| +| E | 取消 | `clearForPrivacy()`(现成,幂等) | 弹框展示期:「请先完成当前弹窗操作」 | +| D | 核销 | `requestQuickVerification()`(复用快速核销事件→同一核销弹框,自带"今天+选中餐段+可核销订单"校验) | 弹框期;`recognizing/querying`:「正在识别/查询,请稍后」;提交中 | +| A | 人脸识别 | 未识别时=启动/切换人脸识别(`selectMethod(face)`,自动模式自带启动;已是该方式且空闲则直接 `startRecognition()`,等同"开始识别"按钮) | `recognizing` 且当前方式为饭卡/人脸:「正在进行{当前方式}识别,不能切换为{目标}识别,请先按取消」;`querying`:「正在查询,请稍后」;弹框期 | +| B | 饭卡识别 | 对称同上 | 对称同上 | +| C | 重新打印 | 记录本页最近一次核销订单 → `HardApi.getPrinterRecords(orderId)` 找最新可补打记录 → `showVoucherReprintDialog` 选原因(复用核销记录页弹框)→ `HardApi.reprintVoucher` → `SyncPrintTool.pullPendingTasks()`;无记录时提示「本页暂无可补打的核销小票」 | 弹框期;`recognizing/querying`;补打进行中(防重标志) | -2. **提示文案说明软键盘不可用**:等待提示与空闲提示明确"请按外接小键盘的物理按键,屏幕软键盘按键无法完成绑定",避免现场用软键盘测试时误判功能损坏。 +通用规则:所有命中先 TTS「你已点击X」;拒绝时 TTS+Toast 说明原因并保持原状态;`_resultDialogPresented`(弹框占用期)一律拒绝;补打原因弹框打开期间同样进入占用态防重入。 -## 验证 +## 四、行为说明 -- `flutter analyze` + `dart format` 改动文件; -- 说明清楚:软键盘按键在 Flutter 中不产生按键事件,属于平台机制,绑定功能需接入真实外接小键盘后才能完整验证(本次修复保证接入后 IME 不再拦截)。 +- 默认关闭;开关与绑定均为进页面时一次性快照(与现有偏好读取时机一致,改设置需重新进入查询菜单生效——与现有自动识别等配置口径相同)。 +- 与扫码枪监听(BarcodeScanManage)共存:快捷键仅在键盘模式开启且命中已绑定字母时消费事件;查询菜单页无扫码业务,扫码缓冲不受影响。 +- 模拟器验证:页面同样挂隐形收集器(`keyboardType.none` 不弹软键盘),软键盘/注入文本可触发;真机小键盘走按键事件通道。 -## 变更统计 +## 五、验证 -预计新增约 12 行 / 删除 2 行(单文件小改)。 \ No newline at end of file +`dart format` + `flutter analyze`(0 新增错误)+ 跑 `test/pages/terminal/menu_query_controller_test.dart`(现有 38 例全过);键盘协调器为纯类,动作矩阵逻辑随 mixin 附带单测可后续补充。变更预计 9 个文件、新增约 700 行。 \ No newline at end of file diff --git a/lib/common/models/page/reservation_menu_page_models.dart b/lib/common/models/page/reservation_menu_page_models.dart index 4b8be11..17fce2a 100644 --- a/lib/common/models/page/reservation_menu_page_models.dart +++ b/lib/common/models/page/reservation_menu_page_models.dart @@ -96,31 +96,35 @@ enum ReservationVerificationDateScope { /// 查询菜单外接快捷小键盘上可绑定的动作。 /// /// [code] 会保存到终端设置中,必须保持稳定;动作语义与查询菜单页面操作一一对应。 +/// [defaultLogicalKey] 是该动作的出厂默认字母按键,仅在终端尚未保存过任何绑定配置时生效。 enum MenuQueryShortcutAction { - /// 取消当前识别流程并清空查询结果。 - cancel('cancel', '取消'), + /// 取消当前识别流程并清空查询结果;出厂默认按键 E。 + cancel('cancel', '取消', LogicalKeyboardKey.keyE), - /// 对当前识别出的用户提交核销。 - verification('verification', '核销'), + /// 对当前识别出的用户提交核销;出厂默认按键 D。 + verification('verification', '核销', LogicalKeyboardKey.keyD), - /// 切换到人脸识别并立即启动识别。 - faceRecognition('face', '人脸识别'), + /// 切换到人脸识别并立即启动识别;出厂默认按键 A。 + faceRecognition('face', '人脸识别', LogicalKeyboardKey.keyA), - /// 切换到饭卡识别并立即启动识别。 - cardRecognition('card', '饭卡识别'), + /// 切换到饭卡识别并立即启动识别;出厂默认按键 B。 + cardRecognition('card', '饭卡识别', LogicalKeyboardKey.keyB), - /// 重新打印最近一次小票。 - reprint('reprint', '重新打印'), + /// 重新打印最近一次小票;出厂默认按键 C。 + reprint('reprint', '重新打印', LogicalKeyboardKey.keyC), ; - /// 创建与稳定编码绑定的快捷动作。 - const MenuQueryShortcutAction(this.code, this.title); + /// 创建与稳定编码、默认按键绑定的快捷动作。 + const MenuQueryShortcutAction(this.code, this.title, this.defaultLogicalKey); /// 终端设置中持久化的稳定动作编码。 final String code; /// 设置页展示、查询菜单运行时复用的动作名称。 final String title; + + /// 出厂默认字母按键;仅逻辑键身份,被用户绑定覆盖前作为预置配置。 + final LogicalKeyboardKey defaultLogicalKey; } /// 单个快捷动作与物理按键的绑定关系,保存在本机 sysJson 中,不涉及数据库结构。 @@ -144,6 +148,18 @@ class MenuQueryShortcutKeyBinding { factory MenuQueryShortcutKeyBinding.fromJson(Map json) => _$MenuQueryShortcutKeyBindingFromJson(json); + /// 全部动作的出厂默认字母按键绑定(取消 E、核销 D、人脸识别 A、饭卡识别 B、重新打印 C)。 + /// + /// 仅逻辑键身份:字符型小键盘与按键事件通道都能稳定匹配; + /// 用户保存过任一绑定配置后,整体以用户配置为准,本默认不再参与读取。 + static final Map + defaults = Map.unmodifiable({ + for (final action in MenuQueryShortcutAction.values) + action: MenuQueryShortcutKeyBinding( + logicalKeyId: action.defaultLogicalKey.keyId, + ), + }); + /// 按键的 USB HID 物理键位;同一物理按键不随键盘布局变化,0 表示设备未提供。 final int usbHidUsage; @@ -206,9 +222,13 @@ class ReservationQueryPagePreferences { this.selectedMealSlot = ReservationMealSlot.morning, this.verificationDateScope = ReservationVerificationDateScope.today, this.shortcutKeyBindings = const {}, + this.keyboardModeEnabled = true, }); /// 未保存页面设置时使用的默认查询偏好。 + /// + /// 快捷按键绑定的出厂默认值受常量构造限制不在此处展开, + /// 统一由 [MenuQueryShortcutKeyBinding.defaults] 提供并在读取层应用。 static const defaults = ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, @@ -221,6 +241,7 @@ class ReservationQueryPagePreferences { selectedMealSlot: ReservationMealSlot.morning, verificationDateScope: ReservationVerificationDateScope.today, shortcutKeyBindings: {}, + keyboardModeEnabled: true, ); /// 查询方式的页面展示顺序。 @@ -258,6 +279,10 @@ class ReservationQueryPagePreferences { /// 外接快捷小键盘的动作按键绑定;键为动作,值为绑定的物理按键,未配置时为空集合。 final Map shortcutKeyBindings; + + /// 查询菜单是否启用外接快捷小键盘;默认开启以配合出厂默认键位开箱即用, + /// 页面只响应硬件按键事件通道,不弹出软键盘。 + final bool keyboardModeEnabled; } /// 预约订单中的套餐组成展示模型,统一处理缺失名称和异常份数。 diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 40caaeb..5dcfae6 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -82,6 +82,11 @@ abstract class AppSettingTool { static const String _menuQueryShortcutKeyBindingsKey = "menuQueryShortcutKeyBindings"; + /// 查询菜单快捷小键盘模式开关在现有 sysJson 中的稳定存储键; + /// 缺失时按开启处理(配合出厂默认键位开箱即用),仅明确保存 false 才关闭。 + static const String _reservationQueryKeyboardModeEnabledKey = + "reservationQueryKeyboardModeEnabled"; + /// 早餐、中餐和晚餐的本地时间范围;值使用现有 ProductHourType 后端编码。 static const String _productMealPeriodsKey = "productMealPeriods"; static const String _serialCardConfigKey = "serialCardConfig"; @@ -634,9 +639,14 @@ abstract class AppSettingTool { ) .firstOrNull ?? ReservationMealSlot.morning; - final shortcutKeyBindings = _parseMenuQueryShortcutKeyBindings( - sysJson[_menuQueryShortcutKeyBindingsKey], - ); + // 终端从未保存过快捷按键配置(全新安装或旧版本升级)时使用出厂默认字母按键; + // 键一旦存在就完全以用户配置为准,即使值为空对象(用户已主动清空全部绑定)。 + final shortcutKeyBindings = + sysJson.containsKey(_menuQueryShortcutKeyBindingsKey) + ? _parseMenuQueryShortcutKeyBindings( + sysJson[_menuQueryShortcutKeyBindingsKey], + ) + : MenuQueryShortcutKeyBinding.defaults; return ReservationQueryPagePreferences( methodOrder: List.unmodifiable(restored), defaultMethod: defaultMethod, @@ -658,6 +668,9 @@ abstract class AppSettingTool { selectedMealSlot: selectedMealSlot, verificationDateScope: verificationDateScope, shortcutKeyBindings: shortcutKeyBindings, + // 键盘模式默认开启:仅操作员明确保存过关闭状态才保持关闭。 + keyboardModeEnabled: + sysJson[_reservationQueryKeyboardModeEnabledKey] != false, ); } @@ -706,6 +719,7 @@ abstract class AppSettingTool { required ReservationVerificationDateScope verificationDateScope, required Map shortcutKeyBindings, + required bool keyboardModeEnabled, }) { final normalized = []; for (final method in order) { @@ -738,6 +752,7 @@ abstract class AppSettingTool { if (entry.value.usbHidUsage != 0 || entry.value.logicalKeyId != 0) entry.key.code: entry.value.toJson(), }; + sysJson[_reservationQueryKeyboardModeEnabledKey] = keyboardModeEnabled; }); } diff --git a/lib/common/widgets/app/app_shortcut_ime_collector.dart b/lib/common/widgets/app/app_shortcut_ime_collector.dart new file mode 100644 index 0000000..f9f4631 --- /dev/null +++ b/lib/common/widgets/app/app_shortcut_ime_collector.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 把输入法提交的文本反查为字母逻辑按键,只接受 26 个英文字母(不区分大小写)。 +/// +/// Flutter 的字母逻辑键使用小写 Unicode 码点作为稳定键 ID(a-z 对应 0x61-0x7A), +/// 大小写输入统一映射到同一个按键;中文候选、标点、多字符提交无法确认到唯一 +/// 按键,返回空由调用方忽略,不关心操作员当前使用什么输入法。 +LogicalKeyboardKey? shortcutLogicalKeyFromImeText(String text) { + final normalized = text.trim().toLowerCase(); + if (normalized.length != 1) return null; + final codeUnit = normalized.codeUnitAt(0); + if (codeUnit < 0x61 || codeUnit > 0x7A) return null; + return LogicalKeyboardKey.findKeyByKeyId(codeUnit); +} + +/// 承载输入法焦点的隐形快捷按键收集器。 +/// +/// 模拟器与输入法激活的真机上,字母按键会被合成为文本提交而不产生按键事件, +/// 只有持有焦点的编辑组件能收到这类输入;收集器挂载后立即抢占焦点, +/// 把提交文本原样回调给调用方做按键反查,自身不可见也不保留内容。 +/// 使用 [TextInputType.none] 抑制软键盘弹出,与扫码枪输入框的做法一致。 +class AppShortcutImeCollector extends StatefulWidget { + /// 创建收集器;[onCharacter] 收到输入法每次提交的原始文本。 + const AppShortcutImeCollector({super.key, required this.onCharacter}); + + /// 输入法提交文本时的回调,参数为本次提交的完整字符串。 + final ValueChanged onCharacter; + + @override + State createState() => + _AppShortcutImeCollectorState(); +} + +class _AppShortcutImeCollectorState extends State { + /// 收集器文本控制器,每次提交后立即清空,避免多键累积。 + final TextEditingController _textContent = TextEditingController(); + + /// 收集器焦点节点,随组件销毁释放。 + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + // 挂载后下一帧抢占输入焦点,让输入法把合成的按键文本提交到这里。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _textContent.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 1, + height: 1, + child: ExcludeSemantics( + child: Opacity( + opacity: 0, + child: TextField( + controller: _textContent, + focusNode: _focusNode, + showCursor: false, + autofocus: true, + // 使用该功能时现场必有外接键盘;none 输入类型抑制软键盘弹出, + // 输入连接仍然保留,被输入法合成为文本的按键依旧能提交到收集器。 + keyboardType: TextInputType.none, + textInputAction: TextInputAction.none, + style: const TextStyle(fontSize: 1), + decoration: const InputDecoration( + isDense: true, + counterText: '', + ), + onChanged: (text) { + if (text.isEmpty) return; + // 立即清空保证每个按键触发一次回调,长按重复字符也不会误传后续字符。 + _textContent.clear(); + widget.onCharacter(text); + }, + ), + ), + ), + ); + } +} diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 9cbe902..872189a 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -13,6 +13,7 @@ export './app/app_message_dialog.dart'; export './app/app_page.dart'; export './app/app_price.dart'; export './app/app_qr_code_dialog.dart'; +export './app/app_shortcut_ime_collector.dart'; export './app/reservation_schedule_card.dart'; export './app/app_radio_hour_type_dialog.dart'; export './app/app_wallet_selector.dart'; diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index a6ae738..47b9ccc 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; @@ -9,6 +9,8 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'reservation_menu_repository.dart'; import 'reservation_recognition_gateway.dart'; +import 'reservation_shortcut_actions.dart'; +import 'reservation_shortcut_keyboard.dart'; import 'reservation_sub_screen_gateway.dart'; import 'reservation_verification_gateway.dart'; @@ -49,8 +51,11 @@ class ReservationQuickVerificationRequest { final ReservationMenuMealData meal; } -/// 本人预约查询状态机,识别、网络、副屏和隐私计时都从这里统一收口。 -class ReservationMenuQueryController extends GetxController { +/// 本人预约查询状态机,识别、网络、副屏和隐私计时都从这里统一收口; +/// 快捷按键的动作编排与输入监听分别解耦到 [ReservationMenuQueryShortcutActions] +/// 与 [ReservationShortcutKeyboard],主状态机只保留接线。 +class ReservationMenuQueryController extends GetxController + with ReservationMenuQueryShortcutActions { /// 创建查询状态机;生产环境使用现有硬件与服务实现,测试可注入网关、时钟和续轮间隔。 ReservationMenuQueryController({ ReservationMenuRepository? repository, @@ -131,6 +136,8 @@ class ReservationMenuQueryController extends GetxController { final DateTime Function() _now; + /// 当前识别方式;同时满足快捷按键动作 mixin 的宿主依赖声明。 + @override final method = ReservationRecognitionMethod.card.obs; final methodOrder = [ ...ReservationRecognitionMethod.values, @@ -159,11 +166,20 @@ class ReservationMenuQueryController extends GetxController { late final ValueChanged> _mealSlotListener; /// 识别查询成功后是否直接请求页面打开今天所选餐段的核销弹框。 + @override final quickVerificationEnabled = false.obs; + /// 核销入口是否开启;同时满足快捷按键动作 mixin 的宿主依赖声明。 + @override final verificationEnabled = false.obs; final verificationDateScope = ReservationVerificationDateScope.today.obs; + + /// 当前查询状态;同时满足快捷按键动作 mixin 的宿主依赖声明。 + @override final status = ReservationMenuQueryStatus.idle.obs; + + /// 当前查询结果;同时满足快捷按键动作 mixin 的宿主依赖声明。 + @override final result = Rxn(); final currentPage = 1.obs; final remainingSeconds = 0.obs; @@ -193,6 +209,12 @@ class ReservationMenuQueryController extends GetxController { /// 当前真实人脸查询产生的本地证据会话;核销成功后移交上传队列,其他清屏路径必须删除。 String? _recognizedFaceEvidenceSessionId; + /// 本页最近一次核销成功的首笔订单 ID;快捷键补打据此查询兑换券打印记录。 + int? _lastVerifiedOrderId; + + /// 外接快捷小键盘输入协调器;负责按键匹配与语音播报,动作执行回到本 Controller 的 mixin。 + ReservationShortcutKeyboard? _shortcutKeyboard; + /// 页面层订阅的一次性快速核销请求;使用广播流避免测试或页面尚未挂载时缓存过期弹框。 final StreamController _quickVerificationRequestController = @@ -214,6 +236,18 @@ class ReservationMenuQueryController extends GetxController { continuousRecognitionEnabled.value && method.value == ReservationRecognitionMethod.face; + /// 页面是否已经关闭;快捷按键动作在关闭后不再响应。 + @override + bool get isClosed => _closed; + + /// 菜单详情或核销弹框是否正在展示;快捷按键在该占用期一律拒绝执行。 + @override + bool get resultDialogPresented => _resultDialogPresented; + + /// 本页最近一次核销成功的首笔订单 ID;供快捷键补打查询打印记录,无核销时为空。 + @override + int? get lastVerifiedOrderId => _lastVerifiedOrderId; + /// 当前是否为“自动识别 + 持续自动识别 + 饭卡”的结果期持续读卡模式。 bool get continuousCardRecognitionActive => autoRecognitionEnabled.value && @@ -280,9 +314,32 @@ class ReservationMenuQueryController extends GetxController { allowManualMealPeriodChange.value = preferences.allowManualMealPeriodChange; verificationDateScope.value = preferences.verificationDateScope; + _startShortcutKeyboard( + enabled: preferences.keyboardModeEnabled, + bindings: preferences.shortcutKeyBindings, + ); } catch (error, stack) { // 配置损坏或读取失败时继续使用安全默认值,不能让查询入口不可用。 - logger.e('|预约查询|加载页面配置失败error:|$error|stack:|$stack|'); + logger.e('|预约查询|加载页面配置失败error:|$error|stack:$stack|'); + } + } + + /// 按页面偏好快照启动外接快捷小键盘;开关关闭或无绑定时保持不监听。 + /// + /// 键盘模式只响应硬件按键事件通道,页面不挂任何输入组件;启动时断开 + /// 残留输入焦点,确保查询菜单页面不弹出软键盘,物理小键盘直达监听器。 + void _startShortcutKeyboard({ + required bool enabled, + required Map bindings, + }) { + _shortcutKeyboard?.dispose(); + _shortcutKeyboard = ReservationShortcutKeyboard( + onAction: handleShortcutAction, + speaker: _speaker, + )..start(enabled: enabled, bindings: bindings); + logger.i('|预约查询|快捷键盘启动enabled:$enabled|绑定数:${bindings.length}|'); + if (enabled && bindings.isNotEmpty) { + FocusManager.instance.primaryFocus?.unfocus(); } } @@ -331,6 +388,7 @@ class ReservationMenuQueryController extends GetxController { } /// 切换本人识别方式,清理旧身份与硬件回调,并按配置决定是否立即启动新方式。 + @override Future selectMethod(ReservationRecognitionMethod next) async { if (_closed || next == method.value) return; _cancelRecognitionRestart(); @@ -352,6 +410,7 @@ class ReservationMenuQueryController extends GetxController { } /// 主动开始一轮识别;持续饭卡模式会在单次刷卡或查询失败后自动恢复下一轮。 + @override Future startRecognition() => _startRecognition(); /// 建立一次防重复回调的识别代次;持续饭卡结果期可保留当前菜单并只重新布防读卡器。 @@ -656,7 +715,7 @@ class ReservationMenuQueryController extends GetxController { if (response.days.isEmpty) { _errorNotifier('当前用户暂无有效预约'); } - final quickVerificationPending = _publishQuickVerificationRequest(); + final quickVerificationPending = requestQuickVerification(); if (_closed || generation != _requestGeneration) return; if (continuousCardRecognitionActive && !quickVerificationPending && @@ -836,7 +895,10 @@ class ReservationMenuQueryController extends GetxController { } /// 在快速核销开启且页面正在监听时发布今天预选餐段,并返回是否需要等待弹框接管识别。 - bool _publishQuickVerificationRequest() { + /// + /// 快速核销事件与外接键盘“核销”按键共用此入口;返回值仅供调用方判断是否接管。 + @override + bool requestQuickVerification() { if (_closed || !verificationEnabled.value || !quickVerificationEnabled.value || @@ -1036,6 +1098,8 @@ class ReservationMenuQueryController extends GetxController { // 核销网关已把会话移交给持久化补传队列,后续页面清屏不得再取消这些任务。 _recognizedFaceEvidenceSessionId = null; } + // 记录本页最近一次核销成功的首笔订单,供快捷键补打查询兑换券打印记录。 + _lastVerifiedOrderId = orderedSelectedOrderIds.first; unawaited( _announceVerificationSuccess(verificationResult), ); @@ -1215,6 +1279,7 @@ class ReservationMenuQueryController extends GetxController { } /// 清除主屏身份与预约结果;自动模式会在清理完成后重新等待下一位用户。 + @override Future clearForPrivacy({bool expired = false}) async { if (_closed || _resultDialogPresented) return; final keepContinuousFacePreview = continuousFaceRecognitionActive; @@ -1285,7 +1350,7 @@ class ReservationMenuQueryController extends GetxController { } } - /// 退出页面时终止识别与副屏任务;生产环境仅解除全局餐段监听。 + /// 退出页面时终止识别、副屏与快捷键盘任务;生产环境仅解除全局餐段监听。 @override void onClose() { _closed = true; @@ -1293,6 +1358,8 @@ class ReservationMenuQueryController extends GetxController { _requestGeneration++; _cancelRecognitionRestart(); _stopPrivacyTimer(); + _shortcutKeyboard?.dispose(); + _shortcutKeyboard = null; unawaited(_recognitionGateway.stopAll()); unawaited(_discardCurrentFaceEvidence('退出预约查询页面')); unawaited(_clearSubScreen()); diff --git a/lib/pages/terminal/menu_query/reservation_shortcut_actions.dart b/lib/pages/terminal/menu_query/reservation_shortcut_actions.dart new file mode 100644 index 0000000..ab11975 --- /dev/null +++ b/lib/pages/terminal/menu_query/reservation_shortcut_actions.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; +import '../verification_records/widgets/voucher_reprint_dialog.dart' + as reprint_dialog; + +/// 查询菜单快捷按键动作编排 mixin。 +/// +/// 与主状态机(`ReservationMenuQueryController`)解耦:本 mixin 只承接 +/// “动作 -> 冲突校验 -> 执行/拒绝提示”的编排层。宿主通过实现下方抽象成员 +/// 提供识别、核销、清屏等既有能力,mixin 不反向依赖 Controller 类型, +/// 避免循环接口。所有拒绝都同时语音与 Toast 提示,与操作员在触屏上的 +/// 等效操作保持同一反馈强度。 +mixin ReservationMenuQueryShortcutActions { + // ---- 宿主(ReservationMenuQueryController)提供的依赖,由其既有成员自动满足 ---- + + /// 页面是否已经关闭;关闭后快捷动作不再响应。 + bool get isClosed; + + /// 菜单详情或核销弹框是否正在展示;占用期快捷动作一律拒绝。 + bool get resultDialogPresented; + + /// 本页最近一次核销成功的首笔订单 ID;无核销时为空。 + int? get lastVerifiedOrderId; + + /// 核销入口是否开启。 + RxBool get verificationEnabled; + + /// 快速核销是否开启。 + RxBool get quickVerificationEnabled; + + /// 当前识别方式。 + Rx get method; + + /// 当前查询状态。 + Rx get status; + + /// 当前查询结果;无身份时为空,快捷键“核销”要求先完成识别。 + Rxn get result; + + /// 取消当前识别并清空结果;快捷键“取消”的执行入口。 + Future clearForPrivacy({bool expired = false}); + + /// 切换识别方式并按配置决定是否立即启动。 + Future selectMethod(ReservationRecognitionMethod next); + + /// 立即启动当前识别方式的硬件识别。 + Future startRecognition(); + + /// 发布快速核销请求,由页面事件层弹出核销弹框;返回是否已接管。 + bool requestQuickVerification(); + + // ---- 编排实现 ---- + + /// 快捷补打是否正在进行;期间禁用重复触发补打与其他快捷动作弹框。 + bool _shortcutReprinting = false; + + /// 快捷键动作的统一入口;先按矩阵校验当前状态,再执行或播报拒绝原因。 + void handleShortcutAction(MenuQueryShortcutAction action) { + if (isClosed) return; + switch (action) { + case MenuQueryShortcutAction.cancel: + _runShortcutCancel(); + case MenuQueryShortcutAction.verification: + _runShortcutVerification(); + case MenuQueryShortcutAction.faceRecognition: + _runShortcutRecognitionSwitch(ReservationRecognitionMethod.face); + case MenuQueryShortcutAction.cardRecognition: + _runShortcutRecognitionSwitch(ReservationRecognitionMethod.card); + case MenuQueryShortcutAction.reprint: + unawaited(_runShortcutReprint()); + } + } + + /// 取消:清空当前识别与结果;弹框占用期拒绝,避免在弹框背后清屏。 + void _runShortcutCancel() { + if (_rejectShortcutWhenDialogPresented()) return; + unawaited(clearForPrivacy()); + } + + /// 核销:复用快速核销请求入口,由其校验“今天 + 选中餐段 + 可核销订单”。 + void _runShortcutVerification() { + if (_rejectShortcutWhenDialogPresented()) return; + if (!_requireShortcutIdleRecognition()) return; + if (!verificationEnabled.value || !quickVerificationEnabled.value) { + _announceShortcutRejection('当前未开启快速核销,请在页面中手动核销'); + return; + } + if (result.value == null) { + _announceShortcutRejection('请先识别用户后再核销'); + return; + } + // 快速核销请求自带完整校验与失败提示,未命中可核销餐段时由其 Toast 说明。 + requestQuickVerification(); + } + + /// 切换识别方式:识别或查询进行中一律拒绝,要求先按“取消”停止当前识别。 + void _runShortcutRecognitionSwitch(ReservationRecognitionMethod target) { + if (_rejectShortcutWhenDialogPresented()) return; + if (!_requireShortcutIdleRecognition()) return; + if (method.value == target) { + // 已是目标方式且空闲:等同点击“开始识别”,手动模式下直接启动。 + if (status.value != ReservationMenuQueryStatus.recognizing) { + unawaited(startRecognition()); + } + return; + } + unawaited(selectMethod(target)); + } + + /// 重新打印:补打本页最近一次核销订单的兑换券,复用核销记录页的补打链路。 + Future _runShortcutReprint() async { + if (_rejectShortcutWhenDialogPresented()) return; + if (!_requireShortcutIdleRecognition()) return; + if (_shortcutReprinting) return; + final orderId = lastVerifiedOrderId; + if (orderId == null) { + _announceShortcutRejection('本页暂无可补打的核销小票,请到核销记录页操作'); + return; + } + _shortcutReprinting = true; + try { + // 与核销记录页一致:只补打最新一条已完成打印的兑换券任务。 + final records = await HardApi.getPrinterRecords(orderId: '$orderId'); + ServicePrintRecord? voucher; + for (final record in records) { + if (record.printType == '2') { + voucher = record; + break; + } + } + if (voucher == null) { + _announceShortcutRejection('该核销订单暂无兑换券打印记录'); + return; + } + if (voucher.status != '2') { + _announceShortcutRejection('最新兑换券尚未打印完成,暂不能再次重打'); + return; + } + // 打印记录查询完成后重新获取并校验上下文,避免页面已销毁时仍弹出原因框。 + final context = Get.context; + if (context == null || !context.mounted) return; + final reason = await reprint_dialog.showVoucherReprintDialog(context); + if (reason == null || reason.trim().isEmpty) return; + final created = await HardApi.reprintVoucher( + historyId: voucher.id, + reason: reason.trim(), + ); + if (created == null) throw StateError('服务端未返回兑换券重打任务'); + // MQTT 是实时通道,HTTP 补拉用于覆盖消息延迟或重连窗口,二者由任务 ID 幂等去重。 + await SyncPrintTool.pullPendingTasks(); + _announceShortcutAcceptance('兑换券重打任务已提交'); + } catch (error, stack) { + logger.e('|预约查询|快捷键补打失败orderId:$orderId|error:$error|stack:$stack|'); + _announceShortcutRejection('兑换券重打失败,请检查网络和打印机后重试'); + } finally { + _shortcutReprinting = false; + } + } + + /// 弹框占用期统一拒绝:菜单详情、核销弹框展示或补打原因弹框打开期间, + /// 禁止快捷动作在弹框背后修改识别与结果状态。 + bool _rejectShortcutWhenDialogPresented() { + if (resultDialogPresented || _shortcutReprinting) { + _announceShortcutRejection('请先完成当前弹窗操作'); + return true; + } + return false; + } + + /// 识别或查询进行中统一拒绝;切换方式场景附加“不能切换”的具体原因。 + bool _requireShortcutIdleRecognition() { + final currentStatus = status.value; + if (currentStatus == ReservationMenuQueryStatus.querying) { + _announceShortcutRejection('正在查询预约,请稍后'); + return false; + } + if (currentStatus == ReservationMenuQueryStatus.recognizing) { + _announceShortcutRejection( + '正在进行${method.value.title}识别,不能切换为其他操作,请先按取消', + ); + return false; + } + return true; + } + + /// 拒绝提示:语音与 Toast 双通道,保证操作员无论注视屏幕与否都能获得反馈。 + void _announceShortcutRejection(String message) { + unawaited(shortcutSpeaker(message)); + shortcutErrorNotifier(message); + } + + /// 执行成功提示:与拒绝保持同一通道,便于统一覆写测试。 + void _announceShortcutAcceptance(String message) { + unawaited(shortcutSpeaker(message)); + shortcutSuccessNotifier(message); + } + + /// 快捷键语音通道;默认使用全局 TTS,测试可覆写。 + Future shortcutSpeaker(String message) => TtsManage.speak(message); + + /// 快捷键错误提示通道;默认使用全局 Toast,测试可覆写。 + void shortcutErrorNotifier(String message) => Toast.error(msg: message); + + /// 快捷键成功提示通道;默认使用全局 Toast,测试可覆写。 + void shortcutSuccessNotifier(String message) => Toast.success(msg: message); +} diff --git a/lib/pages/terminal/menu_query/reservation_shortcut_keyboard.dart b/lib/pages/terminal/menu_query/reservation_shortcut_keyboard.dart new file mode 100644 index 0000000..f5dd1f0 --- /dev/null +++ b/lib/pages/terminal/menu_query/reservation_shortcut_keyboard.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 单独按下的修饰键不作为快捷键来源,与设置页按键绑定保持一致的过滤口径。 +const List _ignoredModifierKeys = [ + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.altGraph, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, +]; + +/// 查询菜单外接快捷小键盘的输入协调器。 +/// +/// 只负责“按键 -> 动作”的输入侧:持有键盘模式开关与按键绑定快照、挂载与 +/// 卸载硬件键盘处理器、过滤修饰键与重复事件、命中绑定后语音播报并回调宿主 +/// 执行动作。状态机冲突校验与动作编排在宿主(动作 mixin)完成,本类不依赖 +/// GetX 与页面上下文,可通过注入回调独立测试。 +class ReservationShortcutKeyboard { + /// 创建输入协调器;语音播报复用宿主注入的可测试通道。 + ReservationShortcutKeyboard({ + required this.onAction, + required Future Function(String message) speaker, + this.logTag = '查询菜单键盘', + }) : _speaker = speaker; + + /// 命中已绑定动作时的回调;参数为按键对应的动作,由宿主完成冲突校验与执行。 + final void Function(MenuQueryShortcutAction action) onAction; + + /// 语音播报通道,注入自查询菜单 Controller,保持可测试性。 + final Future Function(String message) _speaker; + + /// 诊断日志标签,用于过滤本协调器产生的按键日志。 + final String logTag; + + /// 当前生效的按键绑定快照;键为动作,值为绑定的物理或逻辑按键。 + Map _bindings = + const {}; + + /// 硬件键盘处理器是否已注册,保证 add/remove 严格配对。 + bool _handlerAttached = false; + + /// 是否允许响应按键;对应“键盘模式”开关,关闭时即使已挂载也不分发动作。 + bool _enabled = false; + + /// 当前是否处于激活状态(开关开启且有可用绑定)。 + bool get active => _enabled && _bindings.isNotEmpty; + + /// 应用键盘模式开关与绑定快照,并按需挂载或卸载硬件键盘处理器。 + /// + /// 开关与绑定均为进入页面时的一次性快照,与查询菜单其他偏好口径一致; + /// 没有任何绑定时即使开关打开也不挂载处理器,避免空转拦截按键。 + void start({ + required bool enabled, + required Map bindings, + }) { + _enabled = enabled; + _bindings = Map.unmodifiable(bindings); + _syncHandler(); + } + + /// 停止监听并释放硬件键盘处理器;页面关闭或模式关闭时调用。 + void stop() { + _enabled = false; + _bindings = const {}; + _syncHandler(); + } + + /// 硬件键盘事件处理:仅响应按下,过滤修饰键,命中绑定后分发动作。 + /// + /// 命中已绑定按键时消费事件避免落入焦点链;未命中按键不消费, + /// 保证扫码枪等其他键盘输入设备的字符不被拦截。 + bool _handleKeyEvent(KeyEvent event) { + if (!active) return false; + if (event is! KeyDownEvent) return false; + if (_ignoredModifierKeys.contains(event.logicalKey)) return false; + final binding = MenuQueryShortcutKeyBinding.fromKeyEvent(event); + return _dispatch(binding, source: 'key:${event.logicalKey.debugName}'); + } + + /// 匹配绑定并分发动作;返回是否命中(命中方消费事件)。 + bool _dispatch(MenuQueryShortcutKeyBinding binding, + {required String source}) { + MenuQueryShortcutAction? matched; + for (final entry in _bindings.entries) { + if (entry.value.conflictsWith(binding)) { + matched = entry.key; + break; + } + } + if (matched == null) return false; + logger.i('|$logTag|命中动作${matched.code}|来源:$source|'); + unawaited(_speaker('你已点击${matched.title}')); + onAction(matched); + return true; + } + + /// 按激活状态统一挂载或卸载硬件键盘处理器。 + void _syncHandler() { + final shouldAttach = active; + if (shouldAttach == _handlerAttached) return; + if (shouldAttach) { + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } else { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + } + _handlerAttached = shouldAttach; + } + + /// 释放资源并确保处理器已卸载。 + void dispose() { + stop(); + } +} diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index eb58aec..19faeaf 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:common_utils/common_utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; @@ -97,6 +98,9 @@ class SettingController extends GetxController with WidgetsBindingObserver { final menuQueryShortcutKeyBindings = {}.obs; + /// 查询菜单是否启用外接快捷小键盘;默认开启,关闭时查询菜单不响应任何已绑定按键。 + final reservationQueryKeyboardModeEnabled = true.obs; + /// 早餐、中餐和晚餐的本地时间范围,设置加载后由 sysJson 中的持久化值覆盖。 final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; @@ -285,6 +289,8 @@ class SettingController extends GetxController with WidgetsBindingObserver { menuQueryShortcutKeyBindings.assignAll( Map.of(reservationQueryPreferences.shortcutKeyBindings), ); + reservationQueryKeyboardModeEnabled.value = + reservationQueryPreferences.keyboardModeEnabled; productMealPeriodSchedule.value = restoredProductMealPeriodSchedule; if (setting.usePayDisplayType != null) { setting.usePayDisplayType = restoredPayTypeOrder @@ -411,6 +417,13 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 切换查询菜单快捷小键盘模式并安排持久化。 + void setReservationQueryKeyboardModeEnabled(bool enabled) { + if (reservationQueryKeyboardModeEnabled.value == enabled) return; + reservationQueryKeyboardModeEnabled.value = enabled; + _scheduleReservationQueryPreferencesPersist(); + } + /// 绑定查询菜单快捷小键盘动作按键并安排持久化。 /// /// 同一物理按键已被其他动作占用时拒绝写入并提示,返回是否绑定成功; @@ -440,10 +453,18 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } - /// 清空全部快捷按键绑定并安排持久化,供“还原”操作复用。 + /// 将快捷按键绑定还原为出厂默认字母按键并安排持久化。 void resetMenuQueryShortcutKeyBindings() { - if (menuQueryShortcutKeyBindings.isEmpty) return; - menuQueryShortcutKeyBindings.clear(); + if (mapEquals( + menuQueryShortcutKeyBindings, + MenuQueryShortcutKeyBinding.defaults, + )) { + return; + } + // GetX 的 assignAll 是引用赋值,默认映射本身不可变,必须拷贝可变副本。 + menuQueryShortcutKeyBindings.assignAll( + Map.of(MenuQueryShortcutKeyBinding.defaults), + ); _scheduleReservationQueryPreferencesPersist(); } @@ -461,8 +482,10 @@ class SettingController extends GetxController with WidgetsBindingObserver { reservationQueryAllowManualMealPeriodChange.value = true; reservationQueryVerificationDateScope.value = ReservationVerificationDateScope.today; - // 快捷按键绑定属于可整体还原的本机偏好,还原后回到全部未绑定状态。 - menuQueryShortcutKeyBindings.clear(); + // 快捷按键绑定随查询菜单整体还原时回到出厂默认字母按键,键盘模式一并回到默认开启。 + resetMenuQueryShortcutKeyBindings(); + reservationQueryKeyboardModeEnabled.value = true; + // 其余查询菜单字段仍由外层统一调度持久化,避免全部为默认值时跳过写入。 _scheduleReservationQueryPreferencesPersist(); } @@ -494,6 +517,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { reservationQueryAllowManualMealPeriodChange.value, verificationDateScope: reservationQueryVerificationDateScope.value, shortcutKeyBindings: menuQueryShortcutKeyBindings, + keyboardModeEnabled: reservationQueryKeyboardModeEnabled.value, ); } catch (e, stack) { logger.e('|$_tag|保存查询菜单页面配置失败error:|$e|stack:|$stack|'); diff --git a/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart b/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart index 25cb514..dab2f74 100644 --- a/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart +++ b/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart @@ -37,18 +37,8 @@ const List _shortcutIgnoredModifierKeys = [ LogicalKeyboardKey.metaRight, ]; -/// 把输入法提交的文本反查为字母逻辑按键,只接受 26 个英文字母(不区分大小写)。 -/// -/// Flutter 的字母逻辑键使用小写 Unicode 码点作为稳定键 ID(a-z 对应 0x61-0x7A), -/// 大小写输入统一映射到同一个按键;中文候选、标点、多字符提交无法确认到唯一 -/// 按键,返回空由调用方忽略,不关心操作员当前使用什么输入法。 -LogicalKeyboardKey? _logicalKeyFromImeText(String text) { - final normalized = text.trim().toLowerCase(); - if (normalized.length != 1) return null; - final codeUnit = normalized.codeUnitAt(0); - if (codeUnit < 0x61 || codeUnit > 0x7A) return null; - return LogicalKeyboardKey.findKeyByKeyId(codeUnit); -} +/// 把输入法提交的文本反查为字母逻辑按键的逻辑已公共化到 +/// [shortcutLogicalKeyFromImeText],设置页与查询菜单页共用同一套字符兜底规则。 /// 查询菜单外接快捷小键盘的按键绑定设置组件。 /// @@ -78,10 +68,17 @@ class _SettingMenuQueryShortcutKeypadState /// 硬件键盘处理器是否已注册,保证 add/remove 严格配对。 bool _handlerAttached = false; - /// 组件销毁时退出监听并释放硬件键盘处理器与计时器。 + /// 是否处于键盘测试模式;测试期间按下已绑定按键会弹框并语音播报对应动作。 + bool _testing = false; + + /// 测试结果弹框是否正在展示,防止测试中连续按键叠加多个弹框。 + bool _testDialogShowing = false; + + /// 组件销毁时退出监听与测试,并释放硬件键盘处理器与计时器。 @override void dispose() { _stopListening(); + _stopTesting(); super.dispose(); } @@ -91,15 +88,13 @@ class _SettingMenuQueryShortcutKeypadState _stopListening(); return; } + // 绑定监听与键盘测试互斥,避免两种按键消费逻辑同时生效。 + if (_testing) _stopTesting(); // 断开页面上残留的输入焦点,避免文本进入旧输入框; // 监听期间由隐形输入收集器接管焦点,承接被输入法合成为文本的按键输入。 FocusManager.instance.primaryFocus?.unfocus(); setState(() => _listeningAction = action); - // 硬件键盘处理器只在监听期间注册,避免常态拦截设置页的普通按键。 - if (!_handlerAttached) { - HardwareKeyboard.instance.addHandler(_handleKeyEvent); - _handlerAttached = true; - } + _syncKeyboardListener(); _listeningTimer?.cancel(); _listeningTimer = Timer(_shortcutListeningTimeout, () { if (!mounted) return; @@ -107,79 +102,206 @@ class _SettingMenuQueryShortcutKeypadState }); } - /// 退出监听:取消计时器、移除键盘处理器并清除高亮状态。 + /// 退出监听:取消计时器、清除高亮状态并同步键盘处理器。 void _stopListening() { _listeningTimer?.cancel(); _listeningTimer = null; - if (_handlerAttached) { - HardwareKeyboard.instance.removeHandler(_handleKeyEvent); - _handlerAttached = false; - } - if (_listeningAction == null) return; - if (mounted) { - setState(() => _listeningAction = null); - } else { - _listeningAction = null; + // 先清状态再同步处理器,保证同步时按最新状态判断是否卸载。 + if (_listeningAction != null) { + if (mounted) { + setState(() => _listeningAction = null); + } else { + _listeningAction = null; + } } + _syncKeyboardListener(); } - /// 监听期间的硬件键盘事件处理。 + /// 切换键盘测试模式;没有可用绑定时提示先完成绑定。 + void _toggleTesting() { + if (_testing) { + _stopTesting(); + return; + } + if (widget.controller.menuQueryShortcutKeyBindings.isEmpty) { + Toast.error(msg: '请先为至少一个动作绑定按键'); + return; + } + _startTesting(); + } + + /// 进入键盘测试模式;与绑定监听互斥,按已绑定按键验证弹框与语音反馈。 + void _startTesting() { + if (_listeningAction != null) _stopListening(); + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _testing = true); + _syncKeyboardListener(); + } + + /// 退出键盘测试模式并同步键盘处理器。 + void _stopTesting() { + _testDialogShowing = false; + // 与退出监听相同,先清状态再同步,避免用旧状态判断造成处理器泄漏。 + if (_testing) { + if (mounted) { + setState(() => _testing = false); + } else { + _testing = false; + } + } + _syncKeyboardListener(); + } + + /// 按绑定监听与测试模式统一挂载或卸载硬件键盘处理器。 /// - /// 仅响应按下事件(忽略长按重复和抬起);Esc 取消本次绑定;修饰键忽略; - /// 其余按键交给 Controller 校验并绑定。监听期间消费所有非修饰按键, - /// 避免物理按键落入焦点链触发页面其他行为。 + /// 两种状态共用同一处理器,避免重复注册;全部退出后立即卸载, + /// 保证设置页常态下不拦截任何按键。 + void _syncKeyboardListener() { + final shouldAttach = _listeningAction != null || _testing; + if (shouldAttach == _handlerAttached) return; + if (shouldAttach) { + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } else { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + } + _handlerAttached = shouldAttach; + } + + /// 监听期间的硬件键盘事件处理:绑定监听优先,其次键盘测试。 + /// + /// 仅响应按下事件(忽略长按重复和抬起);Esc 按当前模式取消绑定或停止测试; + /// 修饰键忽略;其余按键按模式分别走绑定校验或测试匹配。监听期间消费所有 + /// 非修饰按键,避免物理按键落入焦点链触发页面其他行为。 bool _handleKeyEvent(KeyEvent event) { - final action = _listeningAction; - if (action == null) return false; + final listeningAction = _listeningAction; + if (listeningAction == null && !_testing) return false; if (event is! KeyDownEvent) return false; // 记录原始按键信息,用于区分“事件未到达”与“到达后处理失败”两类现场问题。 logger.i( - '|$_tag|收到按键logical:${event.logicalKey.debugName}|physical:${event.physicalKey.debugName}|usb:${event.physicalKey.usbHidUsage}|', + '|$_tag|收到按键logical:${event.logicalKey.debugName}|physical:${event.physicalKey.debugName}|usb:${event.physicalKey.usbHidUsage}|testing:$_testing|', ); if (event.logicalKey == LogicalKeyboardKey.escape) { - _stopListening(); + if (listeningAction != null) { + _stopListening(); + } else { + _stopTesting(); + } return true; } if (_shortcutIgnoredModifierKeys.contains(event.logicalKey)) return false; final binding = MenuQueryShortcutKeyBinding.fromKeyEvent(event); + if (listeningAction != null) { + _applyBinding(listeningAction, binding); + } else { + _handleBoundKeyPress(binding); + } + // 绑定冲突或测试未命中时保留当前模式,操作员可直接补按正确按键。 + return true; + } + + /// 提交绑定并统一处理提示与退出监听。 + void _applyBinding( + MenuQueryShortcutAction action, + MenuQueryShortcutKeyBinding binding, + ) { if (widget.controller.setMenuQueryShortcutKeyBinding(action, binding)) { Toast.success(msg: '「${binding.displayLabel}」已绑定为「${action.title}」'); _stopListening(); } - // 冲突时保留监听,操作员可直接补按正确按键,无需重新点击动作行。 - return true; } - /// 输入法文本兜底:把被合成为文本提交的字母按键转换为逻辑键绑定。 + /// 键盘测试:按当前按键匹配已绑定动作,命中后弹框并语音播报。 + void _handleBoundKeyPress(MenuQueryShortcutKeyBinding binding) { + MenuQueryShortcutAction? matchedAction; + for (final entry + in widget.controller.menuQueryShortcutKeyBindings.entries) { + if (entry.value.conflictsWith(binding)) { + matchedAction = entry.key; + break; + } + } + if (matchedAction == null) { + Toast.error(msg: '该按键尚未绑定动作'); + return; + } + TtsManage.speak('你已点击${matchedAction.title}'); + _showTestResultDialog(matchedAction, binding); + } + + /// 展示键盘测试结果弹框;已有弹框展示时跳过,避免连续按键叠加多个弹框。 + Future _showTestResultDialog( + MenuQueryShortcutAction action, + MenuQueryShortcutKeyBinding binding, + ) async { + if (_testDialogShowing || !mounted) return; + _testDialogShowing = true; + final theme = FTheme.of(context); + await showAppDialog( + context: context, + builder: (dialogContext) => AppDialog( + title: const Text('键盘测试'), + leading: FIcon( + FAssets.icons.volume2, + color: theme.colorScheme.primary, + size: theme.typography.xl2.fontSize, + ).center().width(44.w).height(44.h), + width: context.width * 0.6, + maxWidth: context.width * 0.6, + onClose: () => Navigator.of(dialogContext).pop(), + content: Text( + '你已点击「${action.title}」(按键 ${binding.displayLabel})', + style: theme.typography.xl, + ).width(double.infinity).padding(vertical: 40.h), + actions: [ + FButton( + label: Text( + '确定', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => Navigator.of(dialogContext).pop(), + ).width(180.w), + ], + ), + ); + _testDialogShowing = false; + } + + /// 输入法文本兜底:把被合成为文本提交的字母按键转换为逻辑键处理。 /// /// 模拟器以及输入法处于激活状态的真机上,物理字母键可能被输入法合成为 /// 文本注入而不产生按键事件;此时由隐形收集器持有焦点接收文本并反查按键, /// 与按键事件通道互补,覆盖“字符型小键盘”的全部接入场景。 void _handleImeText(String text) { - final action = _listeningAction; - if (action == null) return; - final key = _logicalKeyFromImeText(text); - // 无法确认到唯一字母按键的输入(中文候选、标点等)直接忽略,保持监听, + final listeningAction = _listeningAction; + if (listeningAction == null && !_testing) return; + final key = shortcutLogicalKeyFromImeText(text); + // 无法确认到唯一字母按键的输入(中文候选、标点等)直接忽略,保持当前模式, // 不提示错误也不关心操作员的输入法状态。 if (key == null) return; // 文本输入只有逻辑键身份,物理键位缺失时绑定仍可运行期兜底匹配。 final binding = MenuQueryShortcutKeyBinding(logicalKeyId: key.keyId); - logger.i('|$_tag|输入法兜底绑定action:${action.code}|key:${key.debugName}|'); - if (widget.controller.setMenuQueryShortcutKeyBinding(action, binding)) { - Toast.success(msg: '「${binding.displayLabel}」已绑定为「${action.title}」'); - _stopListening(); + if (listeningAction != null) { + logger.i( + '|$_tag|输入法兜底绑定action:${listeningAction.code}|key:${key.debugName}|'); + _applyBinding(listeningAction, binding); + } else { + logger.i('|$_tag|输入法兜底测试key:${key.debugName}|'); + _handleBoundKeyPress(binding); } } - /// 分组内依次渲染说明头、状态提示和五个动作行;动作行依赖绑定 Rx 独立重建。 + /// 分组内依次渲染键盘模式开关、说明头、状态提示、五个动作行和键盘测试区;动作行依赖绑定 Rx 独立重建。 @override Widget build(BuildContext context) { return SettingGroup( title: '快捷按钮', child: [ - // 监听期间挂载隐形输入收集器,承接输入法合成的文本按键输入。 - if (_listeningAction != null) - _ShortcutImeCollector(onCharacter: _handleImeText), + // 绑定监听或测试期间挂载隐形输入收集器,承接输入法合成的文本按键输入。 + if (_listeningAction != null || _testing) + AppShortcutImeCollector(onCharacter: _handleImeText), + _buildKeyboardModeRow(), _ShortcutBindingHeader( onReset: widget.controller.resetMenuQueryShortcutKeyBindings, ), @@ -202,27 +324,152 @@ class _SettingMenuQueryShortcutKeypadState ], ); }), + _ShortcutTestSection(testing: _testing, onToggle: _toggleTesting), ].toColumn(), ); } - /// 监听状态下切换为警示提示,指导操作员完成“选中动作 -> 按键”流程。 + /// 键盘模式总开关独立观察,切换时不重建下方绑定列表与测试区。 + Widget _buildKeyboardModeRow() => Obx(() { + final enabled = + widget.controller.reservationQueryKeyboardModeEnabled.value; + return SettingInfoRow( + icon: enabled ? FAssets.icons.keyboard : FAssets.icons.keyboardOff, + label: '开启键盘模式', + subtitle: enabled + ? '查询菜单只响应外接小键盘的物理按键并语音播报执行,页面不弹出软键盘;修改后重新进入查询菜单生效。' + : '默认开启:查询菜单只响应外接小键盘的物理按键,不弹出软键盘;关闭后不响应任何按键。', + value: enabled ? '已开启' : '已关闭', + showDivider: false, + trailing: FSwitch( + semanticLabel: '查询菜单键盘模式开关', + value: enabled, + onChange: widget.controller.setReservationQueryKeyboardModeEnabled, + ), + ); + }); + + /// 状态提示按绑定监听(警示)与键盘测试(主题色)区分,空闲时回落到流程说明。 Widget _buildListeningNotice() { final listeningAction = _listeningAction; final listening = listeningAction != null; + final testing = _testing; return SettingNotice( text: listening ? '正在等待「${listeningAction.title}」的按键:请按下外接小键盘上要绑定的字母按键;再次点击本行取消。' - : '点击动作行的“绑定按键”后,再按下外接小键盘的物理按键完成绑定;同一按键只能绑定一个动作。', - icon: listening ? FAssets.icons.keyboard : FAssets.icons.circleAlert, - color: listening ? XfjDesignTokens.warning : XfjDesignTokens.muted, - background: listening ? const Color(0xFFFFF8E1) : XfjDesignTokens.control, - borderColor: - listening ? XfjDesignTokens.warning.withValues(alpha: 0.35) : null, + : testing + ? '键盘测试中:按下已绑定的物理按键将弹框并语音提示对应动作;按 Esc 或点击“停止测试”结束。' + : '点击动作行的“绑定按键”后,再按下外接小键盘的物理按键完成绑定;同一按键只能绑定一个动作。', + icon: listening + ? FAssets.icons.keyboard + : testing + ? FAssets.icons.volume2 + : FAssets.icons.circleAlert, + color: listening + ? XfjDesignTokens.warning + : testing + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + background: listening + ? const Color(0xFFFFF8E1) + : testing + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.control, + borderColor: listening + ? XfjDesignTokens.warning.withValues(alpha: 0.35) + : testing + ? XfjDesignTokens.primary.withValues(alpha: 0.35) + : null, ).padding(left: 20.w, top: 14.h, right: 20.w, bottom: 4.h); } } +/// 快捷按钮分组底部的键盘测试区:开启后按已绑定按键验证弹框与语音反馈。 +class _ShortcutTestSection extends StatelessWidget { + /// 创建测试区;[onToggle] 负责进入或退出测试模式。 + const _ShortcutTestSection({required this.testing, required this.onToggle}); + + /// 是否处于键盘测试模式。 + final bool testing; + + /// 开始或停止测试的回调。 + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minHeight: 68.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + // 测试中底色使用主题浅色,与动作行的监听高亮保持一致的反馈语言。 + color: testing + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + testing ? FAssets.icons.volume2 : FAssets.icons.play, + color: testing ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 22.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '键盘测试', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + '开启后按下已绑定的物理按键,将弹框并语音提示对应动作。', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + SizedBox(width: 16.w), + FButton( + style: testing ? FButtonStyle.destructive : FButtonStyle.primary, + label: Text( + testing ? '停止测试' : '开始测试', + style: theme.typography.sm.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + onPress: onToggle, + ), + ], + ), + ); + } +} + /// 快捷按钮分组的说明头,右侧“还原”负责清空全部按键绑定。 class _ShortcutBindingHeader extends StatelessWidget { /// 创建说明头;[onReset] 由 Controller 提供并已包含持久化调度。 @@ -249,7 +496,7 @@ class _ShortcutBindingHeader extends StatelessWidget { ), SizedBox(height: 4.h), Text( - '外接快捷小键盘的五个物理按键可分别绑定为取消、核销、人脸识别、饭卡识别和重新打印。', + '外接快捷小键盘可绑定为取消、核销、人脸识别、饭卡识别和重新打印,出厂默认按键依次为 E、D、A、B、C。', style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), ), @@ -516,81 +763,6 @@ class _ClearBindingButton extends StatelessWidget { } } -/// 监听期间承载输入法焦点的隐形收集器。 -/// -/// 模拟器与输入法激活的真机上,字符按键会被合成为文本提交而不产生按键事件, -/// 只有持有焦点的编辑组件能收到这类输入;收集器挂载后立即抢占焦点, -/// 把提交文本原样回调给设置组件做按键反查,自身不可见也不保留内容。 -class _ShortcutImeCollector extends StatefulWidget { - /// 创建收集器;[onCharacter] 收到输入法每次提交的原始文本。 - const _ShortcutImeCollector({required this.onCharacter}); - - /// 输入法提交文本时的回调,参数为本次提交的完整字符串。 - final ValueChanged onCharacter; - - @override - State<_ShortcutImeCollector> createState() => _ShortcutImeCollectorState(); -} - -class _ShortcutImeCollectorState extends State<_ShortcutImeCollector> { - /// 收集器文本控制器,每次提交后立即清空,避免多键累积。 - final TextEditingController _textContent = TextEditingController(); - - /// 收集器焦点节点,随组件销毁释放。 - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - super.initState(); - // 挂载后下一帧抢占输入焦点,让输入法把合成的按键文本提交到这里。 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _textContent.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 1, - height: 1, - child: ExcludeSemantics( - child: Opacity( - opacity: 0, - child: TextField( - controller: _textContent, - focusNode: _focusNode, - showCursor: false, - autofocus: true, - // 使用该功能时现场必有外接键盘;none 输入类型抑制软键盘弹出 - //(与商品搜索框承接扫码枪输入的做法一致),输入连接仍然保留, - // 被输入法合成为文本的按键依旧能提交到收集器。 - keyboardType: TextInputType.none, - textInputAction: TextInputAction.none, - style: const TextStyle(fontSize: 1), - decoration: const InputDecoration( - isDense: true, - counterText: '', - ), - onChanged: (text) { - if (text.isEmpty) return; - // 立即清空保证每个按键触发一次回调,长按重复字符也不会误绑定后续字符。 - _textContent.clear(); - widget.onCharacter(text); - }, - ), - ), - ), - ); - } -} - /// 动作到图标的映射,与查询菜单页面各操作的语义保持一致。 SvgAsset _shortcutActionIcon(MenuQueryShortcutAction action) => switch (action) {