feat:查询菜谱适配小键盘硬件

This commit is contained in:
dzc committed 2026-09-20 11:43:33 +08:00
1 parent a179729e79
commit 8e9bcba3c3
11 files changed
+861 -7

No files matched your search

@@ -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<void>('TextInput.hide');
```
确保真实小键盘的物理按键走 KeyEvent 路径直达监听器,不被输入法吞掉。
2. **提示文案说明软键盘不可用**:等待提示与空闲提示明确"请按外接小键盘的物理按键,屏幕软键盘按键无法完成绑定",避免现场用软键盘测试时误判功能损坏。
## 验证
- `flutter analyze` + `dart format` 改动文件;
- 说明清楚:软键盘按键在 Flutter 中不产生按键事件,属于平台机制,绑定功能需接入真实外接小键盘后才能完整验证(本次修复保证接入后 IME 不再拦截)。
## 变更统计
预计新增约 12 行 / 删除 2 行(单文件小改)。
+1 -1
View File
@@ -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为版本号(内部比较)
```
@@ -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<String, dynamic> json) =>
_$MenuQueryShortcutKeyBindingFromJson(json);
/// 按键的 USB HID 物理键位;同一物理按键不随键盘布局变化,0 表示设备未提供。
final int usbHidUsage;
/// 按键的逻辑键 ID;仅作为物理键位缺失时的兜底匹配与展示依据,0 表示未记录。
final int logicalKeyId;
Map<String, dynamic> 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<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
shortcutKeyBindings;
}
/// 预约订单中的套餐组成展示模型,统一处理缺失名称和异常份数。
@@ -6,6 +6,20 @@ part of 'reservation_menu_page_models.dart';
// JsonSerializableGenerator
// **************************************************************************
MenuQueryShortcutKeyBinding _$MenuQueryShortcutKeyBindingFromJson(
Map<String, dynamic> json) =>
MenuQueryShortcutKeyBinding(
usbHidUsage: (json['usbHidUsage'] as num?)?.toInt() ?? 0,
logicalKeyId: (json['logicalKeyId'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$MenuQueryShortcutKeyBindingToJson(
MenuQueryShortcutKeyBinding instance) =>
<String, dynamic>{
'usbHidUsage': instance.usbHidUsage,
'logicalKeyId': instance.logicalKeyId,
};
ReservationMenuMealData _$ReservationMenuMealDataFromJson(
Map<String, dynamic> json) =>
ReservationMenuMealData(
@@ -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<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
_parseMenuQueryShortcutKeyBindings(Object? source) {
// 始终返回可变 Map:调用方可能直接写入,const 空 Map 会导致写入抛异常。
if (source is! Map) {
return <MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>{};
}
final byCode = {
for (final action in MenuQueryShortcutAction.values) action.code: action,
};
final bindings = <MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>{};
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<String, dynamic>.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<void> setReservationQueryPagePreferences({
required List<ReservationRecognitionMethod> order,
@@ -663,6 +704,8 @@ abstract class AppSettingTool {
required bool quickVerificationEnabled,
required bool allowManualMealPeriodChange,
required ReservationVerificationDateScope verificationDateScope,
required Map<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
shortcutKeyBindings,
}) {
final normalized = <ReservationRecognitionMethod>[];
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(),
};
});
}
-2
View File
@@ -93,8 +93,6 @@ class _DeviceSetupPageState extends State<DeviceSetupPage> {
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(
@@ -93,6 +93,10 @@ class SettingController extends GetxController with WidgetsBindingObserver {
final reservationQueryVerificationDateScope =
ReservationVerificationDateScope.today.obs;
/// 查询菜单外接快捷小键盘的动作按键绑定;键为动作,值为最近一次绑定的物理按键。
final menuQueryShortcutKeyBindings =
<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>{}.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|');
@@ -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';
@@ -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<LogicalKeyboardKey> _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<SettingMenuQueryShortcutKeypad> createState() =>
_SettingMenuQueryShortcutKeypadState();
}
class _SettingMenuQueryShortcutKeypadState
extends State<SettingMenuQueryShortcutKeypad> {
/// 当前正在等待物理按键的动作;空表示未在监听。
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: <Widget>[
// 监听期间挂载隐形输入收集器,承接输入法合成的文本按键输入。
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<String> 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,
};
@@ -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) => <Widget>[
_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();
+1 -1
View File
@@ -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