feat:查询菜单页面适配小键盘
This commit is contained in:
1 parent
8e9bcba3c3
commit
f9be6dea13
10 files changed
+922
-182
No files matched your search
@@ -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<void>('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 行(单文件小改)。
|
||||
`dart format` + `flutter analyze`(0 新增错误)+ 跑 `test/pages/terminal/menu_query_controller_test.dart`(现有 38 例全过);键盘协调器为纯类,动作矩阵逻辑随 mixin 附带单测可后续补充。变更预计 9 个文件、新增约 700 行。
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$MenuQueryShortcutKeyBindingFromJson(json);
|
||||
|
||||
/// 全部动作的出厂默认字母按键绑定(取消 E、核销 D、人脸识别 A、饭卡识别 B、重新打印 C)。
|
||||
///
|
||||
/// 仅逻辑键身份:字符型小键盘与按键事件通道都能稳定匹配;
|
||||
/// 用户保存过任一绑定配置后,整体以用户配置为准,本默认不再参与读取。
|
||||
static final Map<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
|
||||
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<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
|
||||
shortcutKeyBindings;
|
||||
|
||||
/// 查询菜单是否启用外接快捷小键盘;默认开启以配合出厂默认键位开箱即用,
|
||||
/// 页面只响应硬件按键事件通道,不弹出软键盘。
|
||||
final bool keyboardModeEnabled;
|
||||
}
|
||||
|
||||
/// 预约订单中的套餐组成展示模型,统一处理缺失名称和异常份数。
|
||||
|
||||
@@ -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<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>
|
||||
shortcutKeyBindings,
|
||||
required bool keyboardModeEnabled,
|
||||
}) {
|
||||
final normalized = <ReservationRecognitionMethod>[];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> onCharacter;
|
||||
|
||||
@override
|
||||
State<AppShortcutImeCollector> createState() =>
|
||||
_AppShortcutImeCollectorState();
|
||||
}
|
||||
|
||||
class _AppShortcutImeCollectorState extends State<AppShortcutImeCollector> {
|
||||
/// 收集器文本控制器,每次提交后立即清空,避免多键累积。
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>[
|
||||
...ReservationRecognitionMethod.values,
|
||||
@@ -159,11 +166,20 @@ class ReservationMenuQueryController extends GetxController {
|
||||
late final ValueChanged<Set<String>> _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<ReservationMenuDisplayData>();
|
||||
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<ReservationQuickVerificationRequest>
|
||||
_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<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding> 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<void> selectMethod(ReservationRecognitionMethod next) async {
|
||||
if (_closed || next == method.value) return;
|
||||
_cancelRecognitionRestart();
|
||||
@@ -352,6 +410,7 @@ class ReservationMenuQueryController extends GetxController {
|
||||
}
|
||||
|
||||
/// 主动开始一轮识别;持续饭卡模式会在单次刷卡或查询失败后自动恢复下一轮。
|
||||
@override
|
||||
Future<void> 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<void> 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());
|
||||
|
||||
@@ -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<ReservationRecognitionMethod> get method;
|
||||
|
||||
/// 当前查询状态。
|
||||
Rx<ReservationMenuQueryStatus> get status;
|
||||
|
||||
/// 当前查询结果;无身份时为空,快捷键“核销”要求先完成识别。
|
||||
Rxn<ReservationMenuDisplayData> get result;
|
||||
|
||||
/// 取消当前识别并清空结果;快捷键“取消”的执行入口。
|
||||
Future<void> clearForPrivacy({bool expired = false});
|
||||
|
||||
/// 切换识别方式并按配置决定是否立即启动。
|
||||
Future<void> selectMethod(ReservationRecognitionMethod next);
|
||||
|
||||
/// 立即启动当前识别方式的硬件识别。
|
||||
Future<void> 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<void> _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<void> shortcutSpeaker(String message) => TtsManage.speak(message);
|
||||
|
||||
/// 快捷键错误提示通道;默认使用全局 Toast,测试可覆写。
|
||||
void shortcutErrorNotifier(String message) => Toast.error(msg: message);
|
||||
|
||||
/// 快捷键成功提示通道;默认使用全局 Toast,测试可覆写。
|
||||
void shortcutSuccessNotifier(String message) => Toast.success(msg: message);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 单独按下的修饰键不作为快捷键来源,与设置页按键绑定保持一致的过滤口径。
|
||||
const List<LogicalKeyboardKey> _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<void> Function(String message) speaker,
|
||||
this.logTag = '查询菜单键盘',
|
||||
}) : _speaker = speaker;
|
||||
|
||||
/// 命中已绑定动作时的回调;参数为按键对应的动作,由宿主完成冲突校验与执行。
|
||||
final void Function(MenuQueryShortcutAction action) onAction;
|
||||
|
||||
/// 语音播报通道,注入自查询菜单 Controller,保持可测试性。
|
||||
final Future<void> Function(String message) _speaker;
|
||||
|
||||
/// 诊断日志标签,用于过滤本协调器产生的按键日志。
|
||||
final String logTag;
|
||||
|
||||
/// 当前生效的按键绑定快照;键为动作,值为绑定的物理或逻辑按键。
|
||||
Map<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding> _bindings =
|
||||
const {};
|
||||
|
||||
/// 硬件键盘处理器是否已注册,保证 add/remove 严格配对。
|
||||
bool _handlerAttached = false;
|
||||
|
||||
/// 是否允许响应按键;对应“键盘模式”开关,关闭时即使已挂载也不分发动作。
|
||||
bool _enabled = false;
|
||||
|
||||
/// 当前是否处于激活状态(开关开启且有可用绑定)。
|
||||
bool get active => _enabled && _bindings.isNotEmpty;
|
||||
|
||||
/// 应用键盘模式开关与绑定快照,并按需挂载或卸载硬件键盘处理器。
|
||||
///
|
||||
/// 开关与绑定均为进入页面时的一次性快照,与查询菜单其他偏好口径一致;
|
||||
/// 没有任何绑定时即使开关打开也不挂载处理器,避免空转拦截按键。
|
||||
void start({
|
||||
required bool enabled,
|
||||
required Map<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
<MenuQueryShortcutAction, MenuQueryShortcutKeyBinding>{}.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|');
|
||||
|
||||
@@ -37,18 +37,8 @@ const List<LogicalKeyboardKey> _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<void> _showTestResultDialog(
|
||||
MenuQueryShortcutAction action,
|
||||
MenuQueryShortcutKeyBinding binding,
|
||||
) async {
|
||||
if (_testDialogShowing || !mounted) return;
|
||||
_testDialogShowing = true;
|
||||
final theme = FTheme.of(context);
|
||||
await showAppDialog<void>(
|
||||
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: <Widget>[
|
||||
// 监听期间挂载隐形输入收集器,承接输入法合成的文本按键输入。
|
||||
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<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) {
|
||||
|
||||
Reference in new issue
Block a user