diff --git a/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md b/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md new file mode 100644 index 0000000..789d0f7 --- /dev/null +++ b/.zcode/plans/plan-sess_ba68eecf-563f-4382-b242-dd01d49ac538.md @@ -0,0 +1,34 @@ +# 修复"绑定按键按下 e 后无响应/软键盘弹出"问题 + +## 根因 + +截图证明"取消"行已进入"等待按键…"监听态,但按"e"后**没有任何 Toast、没有任何绑定变化** —— 说明按键事件根本没到达 `HardwareKeyboard` 的监听 handler(若到达,成功/冲突都必有 Toast)。 + +结合"按 e 时软键盘弹出",根因是 **Android IME(输入法)拦截**: + +1. 当输入法连接处于激活状态(软键盘弹出、或此前某个输入框残留焦点未释放)时,Android 会把物理键盘的字符键(如 e)先交给输入法,**合成为文本提交**,不再作为 KeyEvent 分发给 Flutter —— 我们的 handler 因此收不到任何 KeyDownEvent。 +2. 若用户按的是**屏幕软键盘上的"e"**(小键盘尚未接入、用软键盘模拟测试),同样只产生 IME 文本提交,Flutter 侧本来就收不到"按了哪个键"的按键事件 —— 软键盘按键**机制上无法**用于绑定。 + +排查确认:设置模块存在多个可激活 IME 的入口(日志预览关键词筛选框、读写卡密钥三个输入框、食堂机型人脸姓名搜索框、`autofocus: true` 的人工硬件输入弹窗);而我们的 `_startListening` **只注册了键盘监听器,没有断开输入焦点** —— 项目里 `BarcodeScanManage`(扫码枪)正是因为同样的问题,在监听前和每次事件时都先 `unfocus()`。 + +## 修复内容(仅改 1 个文件) + +`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 路径直达监听器,不被输入法吞掉。 + +2. **提示文案说明软键盘不可用**:等待提示与空闲提示明确"请按外接小键盘的物理按键,屏幕软键盘按键无法完成绑定",避免现场用软键盘测试时误判功能损坏。 + +## 验证 + +- `flutter analyze` + `dart format` 改动文件; +- 说明清楚:软键盘按键在 Flutter 中不产生按键事件,属于平台机制,绑定功能需接入真实外接小键盘后才能完整验证(本次修复保证接入后 IME 不再拦截)。 + +## 变更统计 + +预计新增约 12 行 / 删除 2 行(单文件小改)。 \ No newline at end of file diff --git a/README.md b/README.md index 72cf13e..19751b6 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.4.8 --build-number 148 +flutter build apk --build-name 1.5.0 --build-number 150 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/lib/common/models/page/reservation_menu_page_models.dart b/lib/common/models/page/reservation_menu_page_models.dart index 05239b4..4b8be11 100644 --- a/lib/common/models/page/reservation_menu_page_models.dart +++ b/lib/common/models/page/reservation_menu_page_models.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:json_annotation/json_annotation.dart'; import '../api/hard_api_models.dart'; @@ -92,6 +93,103 @@ enum ReservationVerificationDateScope { final String title; } +/// 查询菜单外接快捷小键盘上可绑定的动作。 +/// +/// [code] 会保存到终端设置中,必须保持稳定;动作语义与查询菜单页面操作一一对应。 +enum MenuQueryShortcutAction { + /// 取消当前识别流程并清空查询结果。 + cancel('cancel', '取消'), + + /// 对当前识别出的用户提交核销。 + verification('verification', '核销'), + + /// 切换到人脸识别并立即启动识别。 + faceRecognition('face', '人脸识别'), + + /// 切换到饭卡识别并立即启动识别。 + cardRecognition('card', '饭卡识别'), + + /// 重新打印最近一次小票。 + reprint('reprint', '重新打印'), + ; + + /// 创建与稳定编码绑定的快捷动作。 + const MenuQueryShortcutAction(this.code, this.title); + + /// 终端设置中持久化的稳定动作编码。 + final String code; + + /// 设置页展示、查询菜单运行时复用的动作名称。 + final String title; +} + +/// 单个快捷动作与物理按键的绑定关系,保存在本机 sysJson 中,不涉及数据库结构。 +@immutable +@JsonSerializable() +class MenuQueryShortcutKeyBinding { + /// 创建按键绑定;设备未提供物理键位时 [usbHidUsage] 允许为 0,仅以逻辑键兜底匹配。 + const MenuQueryShortcutKeyBinding({ + this.usbHidUsage = 0, + this.logicalKeyId = 0, + }); + + /// 从真实硬件键盘事件提取绑定;物理键位是首选匹配依据,逻辑键仅兜底。 + factory MenuQueryShortcutKeyBinding.fromKeyEvent(KeyEvent event) { + return MenuQueryShortcutKeyBinding( + usbHidUsage: event.physicalKey.usbHidUsage, + logicalKeyId: event.logicalKey.keyId, + ); + } + + factory MenuQueryShortcutKeyBinding.fromJson(Map json) => + _$MenuQueryShortcutKeyBindingFromJson(json); + + /// 按键的 USB HID 物理键位;同一物理按键不随键盘布局变化,0 表示设备未提供。 + final int usbHidUsage; + + /// 按键的逻辑键 ID;仅作为物理键位缺失时的兜底匹配与展示依据,0 表示未记录。 + final int logicalKeyId; + + Map toJson() => _$MenuQueryShortcutKeyBindingToJson(this); + + /// 判断两个绑定是否指向同一个按键。 + /// + /// 物理或逻辑键任一相同即视为冲突:物理键相同说明是同一个物理按键; + /// 物理键不同但逻辑键相同时,运行期匹配存在歧义风险,同样不允许重复绑定。 + bool conflictsWith(MenuQueryShortcutKeyBinding other) { + if (usbHidUsage != 0 && + other.usbHidUsage != 0 && + usbHidUsage == other.usbHidUsage) { + return true; + } + return logicalKeyId != 0 && + other.logicalKeyId != 0 && + logicalKeyId == other.logicalKeyId; + } + + /// 展示按键的可读名称;优先物理键名,其次逻辑键名,均缺失时回退到键值,便于现场排查。 + String get displayLabel { + final physicalName = + PhysicalKeyboardKey.findKeyByCode(usbHidUsage)?.debugName; + if (physicalName != null && physicalName.isNotEmpty) return physicalName; + final logicalName = + LogicalKeyboardKey.findKeyByKeyId(logicalKeyId)?.debugName; + if (logicalName != null && logicalName.isNotEmpty) return logicalName; + return '键码 0x${usbHidUsage.toRadixString(16).padLeft(4, '0').toUpperCase()}'; + } + + /// 按全部持久化字段比较,用于恢复配置与测试断言。 + @override + bool operator ==(Object other) => + other is MenuQueryShortcutKeyBinding && + other.usbHidUsage == usbHidUsage && + other.logicalKeyId == logicalKeyId; + + /// 与相等判断字段一致的哈希值。 + @override + int get hashCode => Object.hash(usbHidUsage, logicalKeyId); +} + /// 查询菜单在本机保存的页面交互偏好,不改变预约接口和硬件协议。 @immutable class ReservationQueryPagePreferences { @@ -107,6 +205,7 @@ class ReservationQueryPagePreferences { this.allowManualMealPeriodChange = true, this.selectedMealSlot = ReservationMealSlot.morning, this.verificationDateScope = ReservationVerificationDateScope.today, + this.shortcutKeyBindings = const {}, }); /// 未保存页面设置时使用的默认查询偏好。 @@ -121,6 +220,7 @@ class ReservationQueryPagePreferences { allowManualMealPeriodChange: true, selectedMealSlot: ReservationMealSlot.morning, verificationDateScope: ReservationVerificationDateScope.today, + shortcutKeyBindings: {}, ); /// 查询方式的页面展示顺序。 @@ -154,6 +254,10 @@ class ReservationQueryPagePreferences { /// 允许显示核销入口的预约日期范围。 final ReservationVerificationDateScope verificationDateScope; + + /// 外接快捷小键盘的动作按键绑定;键为动作,值为绑定的物理按键,未配置时为空集合。 + final Map + shortcutKeyBindings; } /// 预约订单中的套餐组成展示模型,统一处理缺失名称和异常份数。 diff --git a/lib/common/models/page/reservation_menu_page_models.g.dart b/lib/common/models/page/reservation_menu_page_models.g.dart index 8865778..4eb1c07 100644 --- a/lib/common/models/page/reservation_menu_page_models.g.dart +++ b/lib/common/models/page/reservation_menu_page_models.g.dart @@ -6,6 +6,20 @@ part of 'reservation_menu_page_models.dart'; // JsonSerializableGenerator // ************************************************************************** +MenuQueryShortcutKeyBinding _$MenuQueryShortcutKeyBindingFromJson( + Map json) => + MenuQueryShortcutKeyBinding( + usbHidUsage: (json['usbHidUsage'] as num?)?.toInt() ?? 0, + logicalKeyId: (json['logicalKeyId'] as num?)?.toInt() ?? 0, + ); + +Map _$MenuQueryShortcutKeyBindingToJson( + MenuQueryShortcutKeyBinding instance) => + { + 'usbHidUsage': instance.usbHidUsage, + 'logicalKeyId': instance.logicalKeyId, + }; + ReservationMenuMealData _$ReservationMenuMealDataFromJson( Map json) => ReservationMenuMealData( diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 96e5b7d..40caaeb 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -77,6 +77,11 @@ abstract class AppSettingTool { static const String _reservationQueryVerificationDateScopeKey = "reservationQueryVerificationDateScope"; + /// 查询菜单外接快捷小键盘动作绑定在现有 sysJson 中的稳定存储键; + /// 值为“动作编码 -> 按键绑定对象”,缺失或空对象表示全部未绑定。 + static const String _menuQueryShortcutKeyBindingsKey = + "menuQueryShortcutKeyBindings"; + /// 早餐、中餐和晚餐的本地时间范围;值使用现有 ProductHourType 后端编码。 static const String _productMealPeriodsKey = "productMealPeriods"; static const String _serialCardConfigKey = "serialCardConfig"; @@ -629,6 +634,9 @@ abstract class AppSettingTool { ) .firstOrNull ?? ReservationMealSlot.morning; + final shortcutKeyBindings = _parseMenuQueryShortcutKeyBindings( + sysJson[_menuQueryShortcutKeyBindingsKey], + ); return ReservationQueryPagePreferences( methodOrder: List.unmodifiable(restored), defaultMethod: defaultMethod, @@ -649,9 +657,42 @@ abstract class AppSettingTool { sysJson[_reservationQueryAllowManualMealPeriodChangeKey] != false, selectedMealSlot: selectedMealSlot, verificationDateScope: verificationDateScope, + shortcutKeyBindings: shortcutKeyBindings, ); } + /// 解析快捷小键盘绑定为强类型映射;损坏条目跳过,重复按键只保留首个动作。 + /// + /// sysJson 属于存储边界,可能被旧版本或人工编辑产生异常结构, + /// 单条解析失败不允许阻断其余查询菜单配置读取。 + static Map + _parseMenuQueryShortcutKeyBindings(Object? source) { + // 始终返回可变 Map:调用方可能直接写入,const 空 Map 会导致写入抛异常。 + if (source is! Map) { + return {}; + } + final byCode = { + for (final action in MenuQueryShortcutAction.values) action.code: action, + }; + final bindings = {}; + for (final entry in source.entries) { + final action = byCode[entry.key]; + if (action == null || entry.value is! Map) continue; + try { + final binding = MenuQueryShortcutKeyBinding.fromJson( + Map.from(entry.value as Map), + ); + // 空绑定没有运行期匹配价值;与已恢复动作冲突的按键保留先出现的动作。 + if (binding.usbHidUsage == 0 && binding.logicalKeyId == 0) continue; + if (bindings.values.any(binding.conflictsWith)) continue; + bindings[action] = binding; + } on Object { + // 忽略无法解析的历史绑定,继续恢复其余动作。 + } + } + return bindings; + } + /// 顺序与默认项一起保存,避免连续操作后出现“默认项不在当前列表中”的矛盾状态。 static Future setReservationQueryPagePreferences({ required List order, @@ -663,6 +704,8 @@ abstract class AppSettingTool { required bool quickVerificationEnabled, required bool allowManualMealPeriodChange, required ReservationVerificationDateScope verificationDateScope, + required Map + shortcutKeyBindings, }) { final normalized = []; for (final method in order) { @@ -689,6 +732,12 @@ abstract class AppSettingTool { allowManualMealPeriodChange; sysJson[_reservationQueryVerificationDateScopeKey] = verificationDateScope.code; + // 持久化边界再次收敛空绑定,保证落盘结构与“未绑定即不写入”的读取约定一致。 + sysJson[_menuQueryShortcutKeyBindingsKey] = { + for (final entry in shortcutKeyBindings.entries) + if (entry.value.usbHidUsage != 0 || entry.value.logicalKeyId != 0) + entry.key.code: entry.value.toJson(), + }; }); } diff --git a/lib/pages/root/device_setup/view.dart b/lib/pages/root/device_setup/view.dart index 8783932..5d0bc5a 100644 --- a/lib/pages/root/device_setup/view.dart +++ b/lib/pages/root/device_setup/view.dart @@ -93,8 +93,6 @@ class _DeviceSetupPageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.print_outlined, - size: 42, color: XfjDesignTokens.primary), const SizedBox(height: 20), const Text('绑定终端设备', style: TextStyle( diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index c010300..eb58aec 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -93,6 +93,10 @@ class SettingController extends GetxController with WidgetsBindingObserver { final reservationQueryVerificationDateScope = ReservationVerificationDateScope.today.obs; + /// 查询菜单外接快捷小键盘的动作按键绑定;键为动作,值为最近一次绑定的物理按键。 + final menuQueryShortcutKeyBindings = + {}.obs; + /// 早餐、中餐和晚餐的本地时间范围,设置加载后由 sysJson 中的持久化值覆盖。 final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; @@ -276,6 +280,11 @@ class SettingController extends GetxController with WidgetsBindingObserver { reservationQueryPreferences.allowManualMealPeriodChange; reservationQueryVerificationDateScope.value = reservationQueryPreferences.verificationDateScope; + // GetX 的 assignAll 对 RxMap 是引用赋值,而偏好默认值可能是 const 空 Map, + // 必须拷贝成可变副本,否则后续写入绑定会抛“Cannot modify unmodifiable map”。 + menuQueryShortcutKeyBindings.assignAll( + Map.of(reservationQueryPreferences.shortcutKeyBindings), + ); productMealPeriodSchedule.value = restoredProductMealPeriodSchedule; if (setting.usePayDisplayType != null) { setting.usePayDisplayType = restoredPayTypeOrder @@ -402,6 +411,42 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 绑定查询菜单快捷小键盘动作按键并安排持久化。 + /// + /// 同一物理按键已被其他动作占用时拒绝写入并提示,返回是否绑定成功; + /// 冲突校验收敛在 Controller,保证设置页与后续其他入口执行同一规则。 + bool setMenuQueryShortcutKeyBinding( + MenuQueryShortcutAction action, + MenuQueryShortcutKeyBinding binding, + ) { + final occupied = menuQueryShortcutKeyBindings.entries + .where( + (entry) => entry.key != action && entry.value.conflictsWith(binding), + ) + .firstOrNull; + if (occupied != null) { + Toast.error(msg: '该按键已绑定“${occupied.key.title}”,请先清除或改按其他按键'); + return false; + } + menuQueryShortcutKeyBindings[action] = binding; + _scheduleReservationQueryPreferencesPersist(); + return true; + } + + /// 清除指定动作的快捷按键绑定并安排持久化。 + void clearMenuQueryShortcutKeyBinding(MenuQueryShortcutAction action) { + if (!menuQueryShortcutKeyBindings.containsKey(action)) return; + menuQueryShortcutKeyBindings.remove(action); + _scheduleReservationQueryPreferencesPersist(); + } + + /// 清空全部快捷按键绑定并安排持久化,供“还原”操作复用。 + void resetMenuQueryShortcutKeyBindings() { + if (menuQueryShortcutKeyBindings.isEmpty) return; + menuQueryShortcutKeyBindings.clear(); + _scheduleReservationQueryPreferencesPersist(); + } + /// 将预约查询页面偏好恢复为产品默认值并安排持久化。 void resetReservationQueryPreferences() { reservationQueryMethodOrder.assignAll( @@ -416,6 +461,8 @@ class SettingController extends GetxController with WidgetsBindingObserver { reservationQueryAllowManualMealPeriodChange.value = true; reservationQueryVerificationDateScope.value = ReservationVerificationDateScope.today; + // 快捷按键绑定属于可整体还原的本机偏好,还原后回到全部未绑定状态。 + menuQueryShortcutKeyBindings.clear(); _scheduleReservationQueryPreferencesPersist(); } @@ -446,6 +493,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { allowManualMealPeriodChange: reservationQueryAllowManualMealPeriodChange.value, verificationDateScope: reservationQueryVerificationDateScope.value, + shortcutKeyBindings: menuQueryShortcutKeyBindings, ); } catch (e, stack) { logger.e('|$_tag|保存查询菜单页面配置失败error:|$e|stack:|$stack|'); diff --git a/lib/pages/terminal/setting/widgets/index.dart b/lib/pages/terminal/setting/widgets/index.dart index 1aa3f3f..7278779 100644 --- a/lib/pages/terminal/setting/widgets/index.dart +++ b/lib/pages/terminal/setting/widgets/index.dart @@ -8,6 +8,7 @@ export './setting_face_plugin.dart'; export './setting_kiosk_exit_button.dart'; export './setting_log_preview.dart'; export './setting_meal_periods.dart'; +export './setting_menu_query_shortcut.dart'; export './setting_pay_type_editor.dart'; export './setting_page.dart'; export './setting_printer_info.dart'; diff --git a/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart b/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart new file mode 100644 index 0000000..25cb514 --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_menu_query_shortcut.dart @@ -0,0 +1,602 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import 'setting_components.dart'; + +/// 组件统一日志标签,用于过滤快捷按键设置的现场诊断日志。 +const String _tag = '快捷按键设置'; + +/// 等待物理按键的超时时间;需覆盖现场切换输入法英文模式的时间,超时自动退出监听。 +const Duration _shortcutListeningTimeout = Duration(seconds: 30); + +/// 单独按下的修饰键不作为绑定目标,继续等待组合中的实际按键。 +/// +/// 修饰键不携带独立业务含义,允许其触发绑定会误占动作按键; +/// 使用常量列表而非集合,避免 LogicalKeyboardKey 自定义相等性无法参与常量集合。 +const List _shortcutIgnoredModifierKeys = [ + 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, +]; + +/// 把输入法提交的文本反查为字母逻辑按键,只接受 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); +} + +/// 查询菜单外接快捷小键盘的按键绑定设置组件。 +/// +/// 操作流程:点击动作行进入监听(行高亮并提示),再按下外接小键盘的物理按键 +/// 完成绑定;重复点击同一动作或按 Esc 取消监听。绑定冲突校验与持久化统一由 +/// [SettingController] 收口,本组件只负责监听状态与交互反馈。 +class SettingMenuQueryShortcutKeypad extends StatefulWidget { + /// 创建快捷小键盘设置组件;控制器必须由设置页 Binding 注册。 + const SettingMenuQueryShortcutKeypad({super.key, required this.controller}); + + /// 设置模块共享控制器,提供绑定读写、冲突校验和持久化调度。 + final SettingController controller; + + @override + State createState() => + _SettingMenuQueryShortcutKeypadState(); +} + +class _SettingMenuQueryShortcutKeypadState + extends State { + /// 当前正在等待物理按键的动作;空表示未在监听。 + MenuQueryShortcutAction? _listeningAction; + + /// 监听超时计时器;进入监听时启动,收到按键、取消或销毁时清空。 + Timer? _listeningTimer; + + /// 硬件键盘处理器是否已注册,保证 add/remove 严格配对。 + bool _handlerAttached = false; + + /// 组件销毁时退出监听并释放硬件键盘处理器与计时器。 + @override + void dispose() { + _stopListening(); + super.dispose(); + } + + /// 开始等待指定动作的物理按键;再次点击同一动作视为取消监听。 + void _startListening(MenuQueryShortcutAction action) { + if (_listeningAction == action) { + _stopListening(); + return; + } + // 断开页面上残留的输入焦点,避免文本进入旧输入框; + // 监听期间由隐形输入收集器接管焦点,承接被输入法合成为文本的按键输入。 + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _listeningAction = action); + // 硬件键盘处理器只在监听期间注册,避免常态拦截设置页的普通按键。 + if (!_handlerAttached) { + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + _handlerAttached = true; + } + _listeningTimer?.cancel(); + _listeningTimer = Timer(_shortcutListeningTimeout, () { + if (!mounted) return; + _stopListening(); + }); + } + + /// 退出监听:取消计时器、移除键盘处理器并清除高亮状态。 + 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; + } + } + + /// 监听期间的硬件键盘事件处理。 + /// + /// 仅响应按下事件(忽略长按重复和抬起);Esc 取消本次绑定;修饰键忽略; + /// 其余按键交给 Controller 校验并绑定。监听期间消费所有非修饰按键, + /// 避免物理按键落入焦点链触发页面其他行为。 + bool _handleKeyEvent(KeyEvent event) { + final action = _listeningAction; + if (action == null) return false; + if (event is! KeyDownEvent) return false; + // 记录原始按键信息,用于区分“事件未到达”与“到达后处理失败”两类现场问题。 + logger.i( + '|$_tag|收到按键logical:${event.logicalKey.debugName}|physical:${event.physicalKey.debugName}|usb:${event.physicalKey.usbHidUsage}|', + ); + if (event.logicalKey == LogicalKeyboardKey.escape) { + _stopListening(); + return true; + } + if (_shortcutIgnoredModifierKeys.contains(event.logicalKey)) return false; + final binding = MenuQueryShortcutKeyBinding.fromKeyEvent(event); + if (widget.controller.setMenuQueryShortcutKeyBinding(action, binding)) { + Toast.success(msg: '「${binding.displayLabel}」已绑定为「${action.title}」'); + _stopListening(); + } + // 冲突时保留监听,操作员可直接补按正确按键,无需重新点击动作行。 + return true; + } + + /// 输入法文本兜底:把被合成为文本提交的字母按键转换为逻辑键绑定。 + /// + /// 模拟器以及输入法处于激活状态的真机上,物理字母键可能被输入法合成为 + /// 文本注入而不产生按键事件;此时由隐形收集器持有焦点接收文本并反查按键, + /// 与按键事件通道互补,覆盖“字符型小键盘”的全部接入场景。 + void _handleImeText(String text) { + final action = _listeningAction; + if (action == null) return; + final key = _logicalKeyFromImeText(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(); + } + } + + /// 分组内依次渲染说明头、状态提示和五个动作行;动作行依赖绑定 Rx 独立重建。 + @override + Widget build(BuildContext context) { + return SettingGroup( + title: '快捷按钮', + child: [ + // 监听期间挂载隐形输入收集器,承接输入法合成的文本按键输入。 + if (_listeningAction != null) + _ShortcutImeCollector(onCharacter: _handleImeText), + _ShortcutBindingHeader( + onReset: widget.controller.resetMenuQueryShortcutKeyBindings, + ), + _buildListeningNotice(), + Obx(() { + final bindings = widget.controller.menuQueryShortcutKeyBindings; + final actions = MenuQueryShortcutAction.values; + return Column( + children: [ + for (final (index, action) in actions.indexed) + _ShortcutActionRow( + action: action, + binding: bindings[action], + listening: _listeningAction == action, + showDivider: index < actions.length - 1, + onTap: () => _startListening(action), + onClear: () => widget.controller + .clearMenuQueryShortcutKeyBinding(action), + ), + ], + ); + }), + ].toColumn(), + ); + } + + /// 监听状态下切换为警示提示,指导操作员完成“选中动作 -> 按键”流程。 + Widget _buildListeningNotice() { + final listeningAction = _listeningAction; + final listening = listeningAction != null; + 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, + ).padding(left: 20.w, top: 14.h, right: 20.w, bottom: 4.h); + } +} + +/// 快捷按钮分组的说明头,右侧“还原”负责清空全部按键绑定。 +class _ShortcutBindingHeader extends StatelessWidget { + /// 创建说明头;[onReset] 由 Controller 提供并已包含持久化调度。 + const _ShortcutBindingHeader({required this.onReset}); + + /// 还原按钮回调:清空全部快捷按键绑定。 + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '小键盘按键绑定', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4.h), + Text( + '外接快捷小键盘的五个物理按键可分别绑定为取消、核销、人脸识别、饭卡识别和重新打印。', + style: + theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ], + ), + ), + FButton( + style: FButtonStyle.outline, + prefix: FIcon( + FAssets.icons.rotateCcw, + color: XfjDesignTokens.ink, + size: 18.sp, + ), + label: Text( + '还原', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + onPress: onReset, + ), + ], + ).padding(left: 20.w, top: 16.h, right: 20.w); + } +} + +/// 单个快捷动作行:展示动作、已绑定的按键名,并提供绑定与清除入口。 +class _ShortcutActionRow extends StatelessWidget { + /// 创建动作行;[binding] 为当前绑定,空表示未绑定。 + const _ShortcutActionRow({ + required this.action, + required this.binding, + required this.listening, + required this.showDivider, + required this.onTap, + required this.onClear, + }); + + /// 当前行展示的快捷动作。 + final MenuQueryShortcutAction action; + + /// 当前动作已绑定的按键;空表示未绑定。 + final MenuQueryShortcutKeyBinding? binding; + + /// 是否正在等待该动作的物理按键。 + final bool listening; + + /// 是否绘制底部分隔线;监听高亮时由高亮边框替代分隔线。 + final bool showDivider; + + /// 点击整行的回调:进入或取消监听。 + final VoidCallback onTap; + + /// 清除该动作绑定的回调。 + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + button: true, + selected: listening, + label: '绑定${action.title}快捷按键', + child: Material( + color: listening ? XfjDesignTokens.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 68.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + border: listening + ? Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.35), + ) + : showDivider + ? const Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ) + : null, + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + // 监听高亮时底色换成白色,保持图标容器在浅蓝背景上的辨识度。 + color: listening + ? XfjDesignTokens.surface + : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + _shortcutActionIcon(action), + color: XfjDesignTokens.muted, + size: 22.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Text( + action.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + _buildKeyBadge(theme), + if (binding != null && !listening) ...[ + SizedBox(width: 8.w), + _ClearBindingButton( + semanticLabel: '清除${action.title}按键绑定', + onTap: onClear, + ), + ], + SizedBox(width: 8.w), + _buildBindChip(theme), + ], + ), + ), + ), + ), + ); + } + + /// 已绑定动作展示按键名徽标,未绑定展示占位文案,保持各行右侧基线对齐。 + Widget _buildKeyBadge(FThemeData theme) { + final currentBinding = binding; + if (currentBinding == null) { + return SizedBox( + width: 96.w, + child: Text( + '未绑定', + textAlign: TextAlign.right, + style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + ), + ); + } + return Container( + width: 96.w, + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + currentBinding.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); + } + + /// 右侧操作提示:监听中为等待态警示样式,其余为可点击的绑定入口样式。 + Widget _buildBindChip(FThemeData theme) { + if (listening) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w * XfjDesignTokens.buttonSizeScale, + vertical: 8.h * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + border: Border.all( + color: XfjDesignTokens.warning.withValues(alpha: 0.45), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + FAssets.icons.keyboard, + color: XfjDesignTokens.warning, + size: 18.sp, + ), + SizedBox(width: 6.w), + Text( + '等待按键…', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.warning, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w * XfjDesignTokens.buttonSizeScale, + vertical: 8.h * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + binding == null ? '绑定按键' : '重新绑定', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// 清除单个动作按键绑定的图标按钮,尺寸与触控面积对齐设置页既有操作按钮。 +class _ClearBindingButton extends StatelessWidget { + /// 创建清除按钮;[onTap] 为空时禁用。 + const _ClearBindingButton({ + required this.semanticLabel, + required this.onTap, + }); + + /// 无障碍语义标签,包含动作名以便读屏区分各行按钮。 + final String semanticLabel; + + /// 点击回调;由父行提供,已包含 Controller 清除与持久化。 + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: Material( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + FAssets.icons.x, + color: XfjDesignTokens.muted, + size: 18.sp, + ).center(), + ), + ), + ), + ); + } +} + +/// 监听期间承载输入法焦点的隐形收集器。 +/// +/// 模拟器与输入法激活的真机上,字符按键会被合成为文本提交而不产生按键事件, +/// 只有持有焦点的编辑组件能收到这类输入;收集器挂载后立即抢占焦点, +/// 把提交文本原样回调给设置组件做按键反查,自身不可见也不保留内容。 +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) { + MenuQueryShortcutAction.cancel => FAssets.icons.ban, + MenuQueryShortcutAction.verification => FAssets.icons.circleCheck, + MenuQueryShortcutAction.faceRecognition => FAssets.icons.scanFace, + MenuQueryShortcutAction.cardRecognition => FAssets.icons.idCard, + MenuQueryShortcutAction.reprint => FAssets.icons.printer, + }; diff --git a/lib/pages/terminal/setting/widgets/setting_page.dart b/lib/pages/terminal/setting/widgets/setting_page.dart index 6b1d624..fd2155a 100644 --- a/lib/pages/terminal/setting/widgets/setting_page.dart +++ b/lib/pages/terminal/setting/widgets/setting_page.dart @@ -7,6 +7,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import '../controller.dart'; import 'setting_components.dart'; +import 'setting_menu_query_shortcut.dart'; import 'setting_pay_type_editor.dart'; /// 页面设置采用与“基本信息”一致的分组表面,并用二级菜单承载各业务页配置。 @@ -175,7 +176,7 @@ class _ReservationQuerySettings extends StatelessWidget { final SettingController controller; - /// 组合查询方式、自动识别、隐私、核销和餐段设置,并保持各区域独立响应式刷新。 + /// 组合查询方式、自动识别、隐私、核销、餐段和快捷小键盘设置,并保持各区域独立响应式刷新。 @override Widget build(BuildContext context) => [ _queryMethodSettings(controller), @@ -183,8 +184,10 @@ class _ReservationQuerySettings extends StatelessWidget { _nameMaskSettings(controller), _verificationSettings(controller), _mealPeriodSettings(controller), + SettingMenuQueryShortcutKeypad(controller: controller), SettingNotice( - text: '查询顺序、默认项、自动识别、持续识别、姓名脱敏、核销、快速核销和餐段修改权限配置会在下次进入“查询菜单”时读取。', + text: + '查询顺序、默认项、自动识别、持续识别、姓名脱敏、核销、快速核销、餐段修改权限和快捷按键绑定配置会在下次进入“查询菜单”时读取。', icon: FAssets.icons.circleAlert, ), ].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable(); @@ -464,7 +467,8 @@ class _CashierSettings extends StatelessWidget { _cashierActionSettings(controller), _cashierMealPeriodSettings(controller), SettingNotice( - text: '支付方式顺序、右下角快捷操作和餐段修改权限都会同步到本机收银页;隐藏仅影响界面展示,不改变服务端权限、钱包范围和渠道支付能力。', + text: + '支付方式顺序、右下角快捷操作和餐段修改权限都会同步到本机收银页;隐藏仅影响界面展示,不改变服务端权限、钱包范围和渠道支付能力。', icon: FAssets.icons.circleAlert, ), ].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable(); diff --git a/pubspec.yaml b/pubspec.yaml index d6dfa1a..377a67d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.4.8+148 +version: 1.5.0+150 environment: sdk: ^3.6.0