feat:取消系统自带软键盘设计,改为系统自用键盘组件-初步

feat:将打印分为图片打印和命令式打印-初步
This commit is contained in:
dzc committed 2026-09-20 18:24:01 +08:00
1 parent 94ee7415b1
commit e68f41375b
38 files changed
+1471 -408

No files matched your search

@@ -1,43 +1,62 @@
# 查询菜单键盘模式:设置开关 + 运行时接线 + 冲突矩阵 + 解耦
# 自研可拖动软键盘组件 + 全面清理系统软键盘适配代码
## 一、设置页:「该页面是否开启键盘模式」开关(默认否)
## 一、新组件:`AppSoftKeyboard`(lib/common/widgets/app/app_soft_keyboard.dart)
- `ReservationQueryPagePreferences` 新增 `keyboardModeEnabled`(默认 false,旧终端兼容)。
- `AppSettingTool`:sysJson 新键 `reservationQueryKeyboardModeEnabled`(缺失=false),随查询菜单偏好一起读写。
- `SettingController`:Rx 字段 + `setReservationQueryKeyboardModeEnabled` + 载入/还原/串行持久化(复用现有模式)。
- 设置 UI:「快捷按钮」分组顶部加开关行(SettingInfoRow+FSwitch):"开启键盘模式",副文案"开启后进入查询菜单,按已绑定的物理按键将语音播报并执行对应操作"。
**一个可拖动的键盘弹出框,三个面板(数字/字母/符号),显式绑定 TextEditingController。**
## 二、解耦结构(三个新文件 + controller 最小改动)
### 弹层形态
- `showGeneralDialog`(`useRootNavigator: true`、`barrierColor: 透明`、`barrierDismissible: true`):点击键盘外任意处收起;键盘浮在页面内容之上,页面可见;生命周期由路由托管,零手工 OverlayEntry 管理。
- 默认停靠屏幕底部居中,整体可用 `GestureDetector(onPan...)` + `Transform.translate` 自由拖动(实现借鉴 app_dialog.dart:224-274 的基准矩形+安全区钳制方案),拖动手柄在面板顶部。
- 面板宽度约 760.w,按键高 62.h(与现 _KeyButton 一致),样式沿用 XfjDesignTokens(surface 底/line 描边/controlRadius)。
键盘输入与动作编排全部移出主 controller,controller 只做接线(项目已有 `OrderMixin` 的 mixin-on-controller 先例):
### 三面板
- **数字**:1-9、0、退格、清空、确认(3x4)。
- **字母**:QWERTY 三行 + 第四行(⇧大小写、123 切数字、空格、退格、确认)。
- **符号**:常用符号网格(`- . _ / * , : % # + @ & ? ! ( ) 空格` 等)+ 退格、确认。
- 面板切换:顶部手柄行内三个 tab(123 / ABC / #+=)。
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` 与字母反查函数公共化,设置页与查询菜单页共用(模拟器/输入法合成文本两条路径全覆盖)。
### API
```dart
static Future<void> show(BuildContext context, {
required TextEditingController controller, // 直接操作光标位置插入/退格
AppSoftKeyboardMode mode = ...digits, // digits | letters | symbols 初始面板
RegExp? allowPattern, // 场景字符白名单(替代 inputFormatters 语义)
int? maxLength,
})
```
- 插入:`controller.value.replaced(selection, ch)`(光标处插入,插完校验 allowPattern/maxLength,超限忽略);
- 退格/清空/确认同理操作 controller;确认=关闭弹层。
controller 改动仅:`with` 动作 mixin、`_loadPagePreferences` 补读 `keyboardModeEnabled`+`shortcutKeyBindings`、偏好加载后启动键盘协调器(`onClose` 停止)、新增公开 getter `resultDialogPresented`、`_publishQuickVerificationRequest` 改公开 `requestQuickVerification()`(快速核销与快捷键共用同一入口)、核销成功时记录最近核销订单 ID(供补打)。view.dart 挂条件隐形收集器。
## 二、接入点改造(第一期:终端高频场景)
## 三、动作映射与冲突矩阵(核心细节)
1. **`TerminalToolbarTextField`**(terminal_toolbar.dart,公共工具栏输入框)加参数 `softKeyboard`(AppSoftKeyboardMode?)与 `softKeyboardAllowPattern`:启用时 `keyboardType: TextInputType.none` + `onTap` 弹出自研键盘。物理键盘/扫码枪输入不受影响(none 只禁系统软键盘)。
2. **收银搜索框**(cashier_workspace.dart):删除 `softKeyboardEnabled`/`searchSoftKeyboardEnabled` 参数链,食堂与超市统一传 `softKeyboard: letters`(超市实体键盘照常,触摸用自研键盘)。
3. **开卡页卡号搜索**(card_active/view.dart):传 `softKeyboard: digits`。
4. **终端操作验证密码弹框**(setting_password_dialog.dart):删除内嵌 AppPinKeyboard,改为密码框 `keyboardType: none` + 打开弹框时主动 `AppSoftKeyboard.show(mode: digits, allowPattern: 数字)`(覆盖 autofocus 场景),点输入框也可再次唤起。
5. **绑定唯一编号弹框**(setting_baseInfo.dart):同样接 `digits`。
| 按键 | 动作 | 执行 | 拒绝条件(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`;补打进行中(防重标志) |
第二期(本次不做):设置页其余搜索/读卡器/Cron/硬件输入弹框/登录页/设备绑定页,以及中文拼音首字母搜索。
通用规则:所有命中先 TTS「你已点击X」;拒绝时 TTS+Toast 说明原因并保持原状态;`_resultDialogPresented`(弹框占用期)一律拒绝;补打原因弹框打开期间同样进入占用态防重入。
## 三、清理系统软键盘适配代码(全部删除)
## 四、行为说明
1. `lib/common/utils/app/app_ime_tool.dart` 整文件 + main.dart 安装点 + utils/index.dart 导出;
2. MainActivity.kt 的 `forceShowSoftInput` 分支(89-113 行)与 `InputMethodManager` import(通道本身保留,AppRuntimeTool 在用);
3. `lib/common/widgets/app/app_shortcut_ime_collector.dart` 整文件 + 设置页绑定区的挂载与 `_handleImeText` 兜底回调(快捷键绑定的 KeyEvent 通道完整保留,真机小键盘/adb 注入仍可绑定);
4. `lib/common/widgets/app/app_pin_keyboard.dart` 整文件(被 AppSoftKeyboard 取代)+ setting_password_dialog 内嵌段 + widgets/index.dart 两处导出更新;
5. 4 处 `SystemChannels.textInput.invokeMethod('TextInput.hide')`(快捷键协调器、查询菜单 controller 的 releaseResidualTextInput、verification_records、order_history)——**各处 `unfocus()` 全部保留**(焦点卫生,防物理输入落入旧输入框),只删 hide 与"等 100ms 键盘退场"类代码,相关注释同步改写。
- 默认关闭;开关与绑定均为进页面时一次性快照(与现有偏好读取时机一致,改设置需重新进入查询菜单生效——与现有自动识别等配置口径相同)。
- 与扫码枪监听(BarcodeScanManage)共存:快捷键仅在键盘模式开启且命中已绑定字母时消费事件;查询菜单页无扫码业务,扫码缓冲不受影响。
- 模拟器验证:页面同样挂隐形收集器(`keyboardType.none` 不弹软键盘),软键盘/注入文本可触发;真机小键盘走按键事件通道。
## 四、保留不动(说明理由)
- AndroidManifest `adjustResize`、登录页/AppDialog 的 viewInsets 消费(第二期接键盘后统一清);
- `_hasActiveTextInput` 快捷键让路、扫码枪白名单、各页面 unfocus 焦点管理;
- setting_face/setting_product 搜索框现状(第二期场景)。
## 五、验证
`dart format` + `flutter analyze`(0 新增错误)+ 跑 `test/pages/terminal/menu_query_controller_test.dart`(现有 38 例全过);键盘协调器为纯类,动作矩阵逻辑随 mixin 附带单测可后续补充。变更预计 9 个文件、新增约 700 行。
`dart format` + `flutter analyze`(0 新增错误)+ 38 个 menu_query 测试全过 + 构建 debug APK 装真机:
- 终端操作验证弹框打开 → 自研数字键盘自动弹出 → 触摸输入密码可提交;
- 食堂收银搜索框点击 → 自研键盘(字母默认)弹出 → 可拖动 → 输入+确认触发搜索 → 点击外部收起;
- 查询菜单快捷键 A/B/E 回归不受影响;
- 超市机型语义:实体键盘/扫码枪输入正常(不装真机则靠代码审查+测试保证)。
变更预计:新增 1 个组件文件(~400 行),修改约 12 个文件,删除约 350 行老代码。
@@ -0,0 +1,58 @@
# 打印机「指令打印」模式改造计划(后端拼命令 + 终端写字节)
## 方案总览
现状:后端 `ThermalReceiptImage.java` 把发票/兑换券渲染成 PNG 图片 → 任务只带 `pic` URL 下发(MQTT PRINT 消息 / `/hard/hard-api/ad/printer/tasks`)→ 终端 `PrintManage.printImg` 下载图片、转 ESC/POS 栅格指令、写 USB。图片栅格打印慢。
目标:后端在生成图片的同时,用平行的 ESC/POS 命令拼装器产出**命令字符串(Base64 字节流)**,随任务经新字段 `printCmd` 下发;终端在设置「打印机」页新增**打印方式**切换(图片打印=默认 / 指令打印),指令模式下把命令解码后直接写 USB(前后仍按本地 `printerAutoCut` 补走纸/切刀,与图片模式边界一致)。无命令或命令损坏的任务自动回退图片打印,保证兼容与可用性。
---
## 一、协议契约(前后端共同约定)
- 新字段 `printCmd`:String、可空、**Base64(ESC/POS 字节流)**,随 MQTT PRINT 消息体和 `/printer/tasks` 接口下发。
- 命令内容边界:凭证正文(标题、分隔线、字段行、商品明细、合计、打印时间),**不含**末尾走纸与切刀——由终端统一包装 `feed(1) + 命令 + feed(1) + [autoCut ? cut()]`,保持与现有图片模式相同的边界行为,且尊重终端本地的 `printerAutoCut` 配置。
- 中文编码 GBK,行宽按 80mm/48 列(与终端 `maxPerLine` 一致)。
- 向后兼容:字段可空可选;旧终端(含基线 `biz_supermarket_cloud`,json_serializable 忽略未知字段)不受影响;旧后端/旧任务无命令时终端回退图片打印。
## 二、后端改动(biz-api-cloud-zax / pjcloud-hardware)
1. **新增 `EscPosReceiptCommand`**(`pjcloud-hardware-api/.../hard/api/utils/`,与 `ThermalReceiptImage` 平行的流式 builder,同款 API:`title/banner/keyValue/sectionTitle/voucherOrderSummary/invoiceItem/voucherProduct/voucherTotal/divider`):
- 内部拼 ESC/POS:`ESC @` 初始化、`ESC a` 对齐、`ESC E` 加粗、`GS !` 放大、GBK 中文编码、按 48 列排版(名称/数量/单价/小计左右布局,长名称折行或截断,避免依赖宽度易错 API)。
- 输出 `toBase64()`;**不含**走纸/切刀。
2. **`HardInvoiceServiceImpl`**(1404 行):发票(printType=1)与兑换券(printType=2)生成处,用同一份数据流双输出(图片 `pic` + 命令 `printCmd`),版式对齐两张参考图(智慧食堂发票 / 预约核销兑换券)。
3. **`HardInvoiceEntity` 加 `print_cmd` 列**(可空,默认 NULL)支撑 tasks 补拉重下发;配套后端数据库迁移(可空列,兼容旧数据)。
4. **MQTT PRINT 消息体与 `/printer/tasks` 响应**加 `printCmd` 字段。
5. **单测**:新增 `EscPosReceiptCommandTest`(对照 `ThermalReceiptImageTest` 的用例结构),验证发票/兑换券两种凭证的命令拼装。
## 三、终端改动(biz_xfj_cloud,当前工作区)
1. **`SysSetting` 新字段 `printerOutputMode`**(String,默认 `'IMAGE'`,取值 `'IMAGE'`/`'COMMAND'`,`lib/common/models/database/database_models.dart`)——写入 sysJson JSON 内新键,**不改表结构**;`updatePrinterBinding()` 现有逻辑只逐字段赋值打印机配置,不会覆盖此本地开关。
2. **`AppSettingTool` 加本地 setter**(如 `setPrinterOutputMode`,走既有 `_updateSysJson` 串行落库模式,参考 `setCashierPagePreferences` 写法)。
3. **设置页「打印机」**(`lib/pages/terminal/setting/widgets/setting_printer_info.dart`):在「后台打印配置」组附近新增「打印方式」分段选择器(复用 `SettingSegmentedSelector`,选项:图片打印/指令打印),切换即时生效并持久化;附一行说明文案(指令模式需后台支持,无命令的任务自动回退图片打印)。UI 对齐现有分组、间距、风格。
4. **`MqttInvoiceMsg` 加 `printCmd` 字段**(`lib/common/models/mq/mq_models.dart`,可空 String)→ 跑 build_runner 更新 `mq_models.g.dart`;`SyncPrintTool.pullPendingTasks` 与 `app_mq_tool.dart` 入队处透传。
5. **`print_history` 表加列 `printCmd`**(TEXT 可空):
- `print_history_entity.dart` 加字段 + 注释;
- `database_migration.dart` 新增连续 `Migration(13, 14, ...)`(`ALTER TABLE print_history ADD COLUMN printCmd TEXT`)并按版本顺序加入 `getDatabaseMigrations()`;
- `database.dart` `@Database(version: 14)`;
- **同步** `android/.../facediscern/database/DatabaseHelper.java` `DATABASE_VERSION = 14`(Floor 先迁移、虹软后访问的既有顺序不变)。
6. **`PrintManage` 新增 `printCommand(String base64Cmd)`**:Base64 解码(空/损坏 → `lastError` + 返回 false)→ `feed(1) + 命令字节 + feed(1) + [printerAutoCut ? cut()]` → `writeBytes`;设备状态上报(`updatePrinterWorking` 等)与 `printImg` 完全一致。
7. **`SyncPrintTool._print` 打印方式分支**:
- 图片模式:照旧 `printImg(task.invoiceUrl)`(忽略命令)。
- 指令模式:有命令 → `printCommand`;**USB 写入失败只按既有 0/5/30s 重试命令,不回退图片**(避免一次任务两种内容重复出纸);仅在**无命令或 Base64 解码失败**时回退 `printImg`。
8. **测试打印适配**:指令模式下 `printTestPage` 改为终端本地拼简单中文文本指令(`EscGenerator.text(containsChinese: true)` + `hr` + `feed` + `cut`,注意行宽 48 列内),用于验证指令链路;图片模式照旧画图。
9. 既有重打(`reprintVoucher` 重试版本)、结果上报(`reportPrinterResult`)、幂等逻辑**均不改动**——重打任务经服务端重新下发自然携带命令。
## 四、验证
- 终端:`fvm dart run build_runner build --delete-conflicting-outputs`(受影响模型);`dart format` 本次改动文件;`fvm flutter analyze` 区分新增/既有告警。
- 后端:pjcloud-hardware 模块 `mvn` 编译 + 单测(含新 EscPosReceiptCommandTest)。
- 数据库:迁移需在真机验证「13 升 14」与「全新安装」两条路径,`user_version` 与虹软版本一致。
- 真机联调(需实际收银机):指令模式打印发票与兑换券(对照参考图核对版式/中文/合计)、无命令回退图片、设置切换即时生效、指令模式测试打印、切刀行为。
- 执行期间遵守通用规则:会话步骤条 + 变更统计(新增/删除/总变动),首次写入后保持 Diff 预览。
## 五、边界与不做的事
- 不改 `printImg` 现有行为、不改上报/幂等协议、不改 `printerImageMode`(GS_V0/ESC_STAR 仍只作用于图片模式)。
- 基线项目 `biz_supermarket_cloud` 本次不引入(协议向后兼容,旧终端忽略新字段);如需同步另开任务。
- 指令模式版式为「按图片版式近似还原」而非像素级一致,字重/放大效果以后端拼装为准,真机验收时微调。
@@ -16,7 +16,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
private static final String DATABASE_PATH = "pj_cashier/database";
private static final String DATABASE_NAME = "cash_register.db";
/** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */
private static final int DATABASE_VERSION = 13;
private static final int DATABASE_VERSION = 14;
public DatabaseHelper(Context context) {
super(context, getCustomDatabasePath(context), null, DATABASE_VERSION);
+5 -5
View File
@@ -196,11 +196,11 @@ abstract class HardApi {
required TerminalOrderRecordQueryRequest request,
}) async {
final res = await HttpManage().dio.post(
'/hard/hard-api/ad/order-record/page',
queryParameters: {'current': current, 'size': size},
data: request.toJson(),
options: dio.Options(headers: {'DEVICE-ID': '${request.hardId}'}),
);
'/hard/hard-api/ad/order-record/page',
queryParameters: {'current': current, 'size': size},
data: request.toJson(),
options: dio.Options(headers: {'DEVICE-ID': '${request.hardId}'}),
);
if (res.data == null) return null;
return ServiceTerminalOrderRecordPage.fromJson(res.data);
}
+2 -4
View File
@@ -702,8 +702,7 @@ class TerminalOrderRecordQueryRequest {
/// 用餐时段编码,留空或 -1 不区分。
final String? hourType;
factory TerminalOrderRecordQueryRequest.fromJson(
Map<String, dynamic> json) =>
factory TerminalOrderRecordQueryRequest.fromJson(Map<String, dynamic> json) =>
_$TerminalOrderRecordQueryRequestFromJson(json);
Map<String, dynamic> toJson() =>
@@ -772,8 +771,7 @@ class ServiceTerminalOrderRecordPage extends ServicePageBase {
final List<ServiceTerminalOrderRecord>? records;
factory ServiceTerminalOrderRecordPage.fromJson(
Map<String, dynamic> json) =>
factory ServiceTerminalOrderRecordPage.fromJson(Map<String, dynamic> json) =>
_$ServiceTerminalOrderRecordPageFromJson(json);
@override
@@ -46,6 +46,12 @@ class SysSetting extends Equatable {
/// 当前绑定打印机的 ESC/POS 图片指令模式,默认使用 GS_V0。
String printerImageMode;
/// 打印输出方式本机开关:IMAGE=下载后端图片并转 ESC/POS 栅格指令(默认),
/// COMMAND=直接写入后端随任务下发的指令流(Base64),打印更快。
/// 属于本机偏好,不由后台打印机绑定配置下发或覆盖;旧配置缺失时保持 IMAGE。
@JsonKey(defaultValue: 'IMAGE')
String printerOutputMode;
/// 商户部门id
String? merchantDeptId;
@@ -165,6 +171,7 @@ class SysSetting extends Equatable {
this.printerPaperWidth = 80,
this.printerAutoCut = true,
this.printerImageMode = 'GS_V0',
this.printerOutputMode = 'IMAGE',
this.tenantList,
this.manualHardwareInput = false,
this.rootAccessStatus = RootAccessStatus.unknown,
@@ -195,6 +202,7 @@ class SysSetting extends Equatable {
printerPaperWidth,
printerAutoCut,
printerImageMode,
printerOutputMode,
merchantDeptId,
hardGroupId,
mqSocketAddress,
@@ -18,6 +18,7 @@ SysSetting _$SysSettingFromJson(Map<String, dynamic> json) => SysSetting(
printerPaperWidth: (json['printerPaperWidth'] as num?)?.toInt() ?? 80,
printerAutoCut: json['printerAutoCut'] as bool? ?? true,
printerImageMode: json['printerImageMode'] as String? ?? 'GS_V0',
printerOutputMode: json['printerOutputMode'] as String? ?? 'IMAGE',
tenantList: (json['tenantList'] as List<dynamic>?)
?.map((e) => ServiceTenant.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -103,6 +104,7 @@ Map<String, dynamic> _$SysSettingToJson(SysSetting instance) =>
'printerPaperWidth': instance.printerPaperWidth,
'printerAutoCut': instance.printerAutoCut,
'printerImageMode': instance.printerImageMode,
'printerOutputMode': instance.printerOutputMode,
'merchantDeptId': instance.merchantDeptId,
'hardGroupId': instance.hardGroupId,
'mqSocketAddress': instance.mqSocketAddress,
+6 -1
View File
@@ -57,9 +57,13 @@ class MqttInvoiceMsg {
/// 同一服务端任务的人工重试版本,用于终端幂等去重。
final int? retryVersion;
// 图片地址url
/// 图片地址url
final String? pic;
/// 后端拼装的凭证打印指令流(Base64 编码的 ESC/POS 字节),指令打印模式直写 USB。
/// 可空:旧后端或历史任务无指令时终端回退 [pic] 图片打印。
final String? printCmd;
// 是否设置为全局人脸
final bool? isGlobalFace;
@@ -75,6 +79,7 @@ class MqttInvoiceMsg {
this.printNo,
this.retryVersion,
this.pic,
this.printCmd,
this.isGlobalFace,
});
+2
View File
@@ -40,6 +40,7 @@ MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map<String, dynamic> json) =>
printNo: (json['printNo'] as num?)?.toInt(),
retryVersion: (json['retryVersion'] as num?)?.toInt(),
pic: json['pic'] as String?,
printCmd: json['printCmd'] as String?,
isGlobalFace: json['isGlobalFace'] as bool?,
);
@@ -56,6 +57,7 @@ Map<String, dynamic> _$MqttInvoiceMsgToJson(MqttInvoiceMsg instance) =>
'printNo': instance.printNo,
'retryVersion': instance.retryVersion,
'pic': instance.pic,
'printCmd': instance.printCmd,
'isGlobalFace': instance.isGlobalFace,
};
+1 -1
View File
@@ -48,7 +48,7 @@ const typeConverters = <Type>[
];
/// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。
@Database(version: 13, entities: entities)
@Database(version: 14, entities: entities)
@TypeConverters(typeConverters)
abstract class AppDatabase extends FloorDatabase {
/// 取餐广播队列 DAO。
+9 -3
View File
@@ -112,7 +112,7 @@ class _$AppDatabase extends AppDatabase {
Callback? callback,
]) async {
final databaseOptions = sqflite.OpenDatabaseOptions(
version: 13,
version: 14,
onConfigure: (database) async {
await database.execute('PRAGMA foreign_keys = ON');
await callback?.onConfigure?.call(database);
@@ -148,7 +148,7 @@ class _$AppDatabase extends AppDatabase {
await database.execute(
'CREATE TABLE IF NOT EXISTS `classify` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `sysType` TEXT NOT NULL, `name` TEXT NOT NULL, `categoryType` TEXT NOT NULL, `sort` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `delFlag` INTEGER NOT NULL)');
await database.execute(
'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `printType` TEXT NOT NULL, `printNo` INTEGER NOT NULL, `retryVersion` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `resultPending` INTEGER NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)');
'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `printCmd` TEXT, `printType` TEXT NOT NULL, `printNo` INTEGER NOT NULL, `retryVersion` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `resultPending` INTEGER NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)');
await database.execute(
'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)');
await database.execute(
@@ -1582,6 +1582,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
'serviceOrderId': item.serviceOrderId,
'servicePrintId': item.servicePrintId,
'invoiceUrl': item.invoiceUrl,
'printCmd': item.printCmd,
'printType': item.printType,
'printNo': item.printNo,
'retryVersion': item.retryVersion,
@@ -1603,6 +1604,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
'serviceOrderId': item.serviceOrderId,
'servicePrintId': item.servicePrintId,
'invoiceUrl': item.invoiceUrl,
'printCmd': item.printCmd,
'printType': item.printType,
'printNo': item.printNo,
'retryVersion': item.retryVersion,
@@ -1624,6 +1626,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
'serviceOrderId': item.serviceOrderId,
'servicePrintId': item.servicePrintId,
'invoiceUrl': item.invoiceUrl,
'printCmd': item.printCmd,
'printType': item.printType,
'printNo': item.printNo,
'retryVersion': item.retryVersion,
@@ -1658,7 +1661,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
Future<List<PrintHistoryEntity>> getAll(String status) async {
return _queryAdapter.queryList(
'SELECT * FROM print_history WHERE (?1 = \'-1\' OR status = ?1)',
mapper: (Map<String, Object?> row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, printType: row['printType'] as String, printNo: row['printNo'] as int, retryVersion: row['retryVersion'] as int, attemptCount: row['attemptCount'] as int, lastError: row['lastError'] as String?, resultPending: _boolConverter.decode(row['resultPending'] as int), status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)),
mapper: (Map<String, Object?> row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, printCmd: row['printCmd'] as String?, printType: row['printType'] as String, printNo: row['printNo'] as int, retryVersion: row['retryVersion'] as int, attemptCount: row['attemptCount'] as int, lastError: row['lastError'] as String?, resultPending: _boolConverter.decode(row['resultPending'] as int), status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)),
arguments: [status]);
}
@@ -1673,6 +1676,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
serviceOrderId: row['serviceOrderId'] as String,
servicePrintId: row['servicePrintId'] as String,
invoiceUrl: row['invoiceUrl'] as String,
printCmd: row['printCmd'] as String?,
printType: row['printType'] as String,
printNo: row['printNo'] as int,
retryVersion: row['retryVersion'] as int,
@@ -1696,6 +1700,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
serviceOrderId: row['serviceOrderId'] as String,
servicePrintId: row['servicePrintId'] as String,
invoiceUrl: row['invoiceUrl'] as String,
printCmd: row['printCmd'] as String?,
printType: row['printType'] as String,
printNo: row['printNo'] as int,
retryVersion: row['retryVersion'] as int,
@@ -1733,6 +1738,7 @@ class _$PrintHistoryDao extends PrintHistoryDao {
serviceOrderId: row['serviceOrderId'] as String,
servicePrintId: row['servicePrintId'] as String,
invoiceUrl: row['invoiceUrl'] as String,
printCmd: row['printCmd'] as String?,
printType: row['printType'] as String,
printNo: row['printNo'] as int,
retryVersion: row['retryVersion'] as int,
@@ -410,6 +410,14 @@ abstract class DatabaseMigration {
);
});
/// 版本14为打印任务补充后端指令流列,指令打印模式可离线重试且不改变出纸内容。
///
/// 列可空且不回填:旧任务与旧后端无指令,指令模式下回退 invoiceUrl 图片打印。
static Migration migration13To14 = Migration(13, 14, (database) async {
await database
.execute('ALTER TABLE print_history ADD COLUMN printCmd TEXT');
});
/// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。
static List<Migration> getDatabaseMigrations() {
return [
@@ -425,6 +433,7 @@ abstract class DatabaseMigration {
migration10To11,
migration11To12,
migration12To13,
migration13To14,
];
}
}
@@ -29,6 +29,10 @@ class PrintHistoryEntity {
/// 发票图片地址
String invoiceUrl;
/// 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节)。
/// 旧后端或历史任务无指令时为空,指令打印模式下回退 [invoiceUrl] 图片打印。
String? printCmd;
/// 服务端凭证类型、打印序号与重试版本。
String printType;
int printNo;
@@ -59,6 +63,7 @@ class PrintHistoryEntity {
required this.serviceOrderId,
required this.servicePrintId,
required this.invoiceUrl,
this.printCmd,
this.printType = '1',
this.printNo = 1,
this.retryVersion = 0,
@@ -13,6 +13,7 @@ PrintHistoryEntity _$PrintHistoryEntityFromJson(Map<String, dynamic> json) =>
serviceOrderId: json['serviceOrderId'] as String,
servicePrintId: json['servicePrintId'] as String,
invoiceUrl: json['invoiceUrl'] as String,
printCmd: json['printCmd'] as String?,
printType: json['printType'] as String? ?? '1',
printNo: (json['printNo'] as num?)?.toInt() ?? 1,
retryVersion: (json['retryVersion'] as num?)?.toInt() ?? 0,
@@ -34,6 +35,7 @@ Map<String, dynamic> _$PrintHistoryEntityToJson(PrintHistoryEntity instance) =>
'serviceOrderId': instance.serviceOrderId,
'servicePrintId': instance.servicePrintId,
'invoiceUrl': instance.invoiceUrl,
'printCmd': instance.printCmd,
'printType': instance.printType,
'printNo': instance.printNo,
'retryVersion': instance.retryVersion,
+1
View File
@@ -147,6 +147,7 @@ class AppMqTool {
androidOrderId: msg.androidOrderCode,
historyId: msg.historyId!,
pic: msg.pic!,
printCmd: msg.printCmd,
printType: msg.printType ?? '1',
printNo: msg.printNo ?? 1,
retryVersion: msg.retryVersion ?? 0,
@@ -91,6 +91,15 @@ abstract class AppSettingTool {
static const String _productMealPeriodsKey = "productMealPeriods";
static const String _serialCardConfigKey = "serialCardConfig";
/// 打印输出方式在 sysJson 中的键,与 [SysSetting.printerOutputMode] 字段名一致。
static const String _printerOutputModeKey = "printerOutputMode";
/// 打印输出方式取值:下载后端图片转 ESC/POS 栅格指令(默认,兼容所有后端版本)。
static const String printerOutputModeImage = 'IMAGE';
/// 打印输出方式取值:直接写入后端随打印任务下发的 Base64 指令流,出纸更快。
static const String printerOutputModeCommand = 'COMMAND';
/// v8 之前短暂保存定时任务配置的旧 sysJson 键,仅供一次性迁移读取。
static const String _scheduledTaskConfigsKey = "scheduledTaskConfigs";
@@ -947,6 +956,15 @@ abstract class AppSettingTool {
return setting;
}
/// 切换本机打印输出方式:IMAGE=图片栅格打印,COMMAND=指令流直写。
///
/// 仅写 sysJson 局部键,不触碰后台下发的打印机绑定字段;指令模式下
/// 无指令数据的旧任务会自动回退图片打印,因此可随时来回切换。
static Future<void> setPrinterOutputMode(String mode) =>
_updateSysJson((sysJson) {
sysJson[_printerOutputModeKey] = mode;
});
/// 绑定设备信息
static Future<bool> bindSys(int serviceId) async {
var androidId = await AppSysTool.getAndroidId();
+111 -15
View File
@@ -178,6 +178,47 @@ class PrintManage extends BaseFacilityManage {
}
}
/// 将后端拼装的 ESC/POS 指令流写入 USB;前后补走纸,并按本机配置追加切刀。
///
/// 指令内容由服务端按凭证版式拼装,本方法只负责包装边界与写入;
/// 返回 true 只表示指令、走纸和可选切刀命令均被 USB 接口接受,不代表从硬件层确认出纸。
Future<bool> printCommand(List<int> commandBytes) async {
if (ObjectUtil.isEmpty(manager)) {
AppDeviceStatusTool.updatePrinterWorking(false);
return false;
}
try {
if (commandBytes.isEmpty) {
lastError = '打印指令内容为空';
return false;
}
final setting = await AppSettingTool.getSysSetting();
final generator = EscGenerator(
paperSizeWidthMM,
maxPerLine,
profile!,
);
final bytes = <int>[
...generator.feed(1),
...commandBytes,
...generator.feed(1),
if (setting.printerAutoCut) ...generator.cut(),
];
AppDeviceStatusTool.updatePrinterWorking(true);
await manager!.writeBytes(bytes);
lastError = null;
AppDeviceStatusTool.updatePrinter(true);
return true;
} catch (e) {
lastError = e.toString();
AppDeviceStatusTool.updatePrinter(false);
logger.e("|$_tag|指令打印失败error|$e|");
return false;
} finally {
AppDeviceStatusTool.updatePrinterWorking(false);
}
}
/// 将测试文字和灰度校准块渲染成 576px PNG,避免依赖打印机中文代码页。
Future<imglib.Image> _buildTestPageImage(
PrinterUsbDeviceInfo device,
@@ -351,21 +392,76 @@ class PrintManage extends BaseFacilityManage {
DateTime.now(),
format: DateFormats.full,
);
final imageMode =
setting.printerImageMode == 'ESC_STAR' ? 'ESC_STAR' : 'GS_V0';
final testPage = await _buildTestPageImage(
device,
printedAt,
imageMode,
);
final bytes = <int>[
...generator.feed(1),
...(imageMode == 'ESC_STAR'
? generator.image(testPage)
: generator.imageRaster(testPage)),
...generator.feed(2),
if (setting.printerAutoCut) ...generator.cut(),
];
// 指令模式下测试打印同样走指令链路,验证的才是本机实际使用的打印通道;
// 文本行控制在 48 列(80mm)以内,避免依赖包内 row 折行的既有缺陷。
final List<int> bytes;
if (setting.printerOutputMode ==
AppSettingTool.printerOutputModeCommand) {
bytes = <int>[
...generator.feed(1),
...generator.text(
'HQ 打印机测试',
styles: const PosStyles(
align: PosAlign.center,
bold: true,
height: PosTextSize.size2,
width: PosTextSize.size2,
),
containsChinese: true,
linesAfter: 1,
),
...generator.text(
'指令链路测试',
styles: const PosStyles(align: PosAlign.center),
containsChinese: true,
linesAfter: 1,
),
...generator.hr(),
...generator.text('打印时间:$printedAt', containsChinese: true),
...generator.text(
'打印机:${device.displayName}',
containsChinese: true,
),
...generator.text(
'VID:${device.vendorId}',
containsChinese: true,
),
...generator.text(
'PID:${device.productId}',
containsChinese: true,
),
...generator.text('纸宽:80mm / 48列', containsChinese: true),
...generator.text('打印方式:指令打印', containsChinese: true),
...generator.hr(),
...generator.text(
'TEST PRINT COMPLETED',
styles: const PosStyles(align: PosAlign.center, bold: true),
),
...generator.text(
'如能看到本页文字,指令打印链路正常',
styles: const PosStyles(align: PosAlign.center),
containsChinese: true,
),
...generator.feed(2),
if (setting.printerAutoCut) ...generator.cut(),
];
} else {
final imageMode =
setting.printerImageMode == 'ESC_STAR' ? 'ESC_STAR' : 'GS_V0';
final testPage = await _buildTestPageImage(
device,
printedAt,
imageMode,
);
bytes = <int>[
...generator.feed(1),
...(imageMode == 'ESC_STAR'
? generator.image(testPage)
: generator.imageRaster(testPage)),
...generator.feed(2),
if (setting.printerAutoCut) ...generator.cut(),
];
}
testManager = USBPrinterManager(
USBPrinter(
name: device.productName,
+43 -2
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:common_utils/common_utils.dart';
import 'package:get/get.dart';
@@ -34,12 +35,14 @@ abstract class SyncPrintTool {
/// 将 MQTT 或 HTTP 待办按服务端任务 ID 转换为唯一的本地任务。
///
/// [orderId] 是服务端订单 ID,[androidOrderId] 允许线上预约订单为空;[historyId]
/// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列。
/// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列;
/// [printCmd] 是后端拼装的 Base64 指令流,指令打印模式直写 USB,可空。
static Future<void> addTask({
required String orderId,
String? androidOrderId,
required String historyId,
required String pic,
String? printCmd,
String printType = '1',
int printNo = 1,
int retryVersion = 0,
@@ -49,6 +52,7 @@ abstract class SyncPrintTool {
androidOrderId: androidOrderId,
historyId: historyId,
pic: pic,
printCmd: printCmd,
printType: printType,
printNo: printNo,
retryVersion: retryVersion,
@@ -67,6 +71,7 @@ abstract class SyncPrintTool {
String? androidOrderId,
required String historyId,
required String pic,
required String? printCmd,
required String printType,
required int printNo,
required int retryVersion,
@@ -90,6 +95,7 @@ abstract class SyncPrintTool {
: orderId
..serviceOrderId = orderId
..invoiceUrl = pic
..printCmd = printCmd?.trim().isNotEmpty == true ? printCmd : null
..printType = printType
..printNo = printNo
..retryVersion = retryVersion
@@ -108,6 +114,7 @@ abstract class SyncPrintTool {
serviceOrderId: orderId,
servicePrintId: historyId,
invoiceUrl: pic,
printCmd: printCmd?.trim().isNotEmpty == true ? printCmd : null,
printType: printType,
printNo: printNo,
retryVersion: retryVersion,
@@ -150,6 +157,7 @@ abstract class SyncPrintTool {
androidOrderId: task.androidOrderCode,
historyId: task.historyId!,
pic: task.pic!,
printCmd: task.printCmd,
printType: task.printType ?? '1',
printNo: task.printNo ?? 1,
retryVersion: task.retryVersion ?? 0,
@@ -207,6 +215,8 @@ abstract class SyncPrintTool {
static Future<void> _print(PrintHistoryEntity task) async {
final app = Get.find<AppControl>();
final dao = app.dataBase.getPrintHistoryDao;
// 指令解码在尝试循环外完成一次:Base64 损坏属于任务数据问题,重试不会好转,直接回退图片。
final commandBytes = await _resolveCommandBytes(task);
for (var index = 0; index < attemptDelays.length; index++) {
final delay = attemptDelays[index];
if (delay > Duration.zero) {
@@ -220,7 +230,11 @@ abstract class SyncPrintTool {
if (!opened) {
throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败');
}
final accepted = await app.printManage.printImg(task.invoiceUrl);
// 指令字节可用时直写指令流;图片模式、无指令或指令损坏时走图片栅格打印。
// 指令写入失败只按原节奏重试指令,不回退图片,避免同一任务两种内容先后出纸。
final accepted = commandBytes != null
? await app.printManage.printCommand(commandBytes)
: await app.printManage.printImg(task.invoiceUrl);
if (!accepted) {
throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据');
}
@@ -250,6 +264,33 @@ abstract class SyncPrintTool {
await _report(task);
}
/// 指令打印模式下解码任务携带的 Base64 打印指令流。
///
/// 图片模式、任务无指令、内容为空或 Base64 损坏时返回 null,调用方回退图片栅格打印;
/// 解码失败不进入 USB 重试节奏,因为重试同一份损坏数据不会改变结果。
static Future<List<int>?> _resolveCommandBytes(
PrintHistoryEntity task,
) async {
final setting = await AppSettingTool.getSysSetting();
if (setting.printerOutputMode != AppSettingTool.printerOutputModeCommand) {
return null;
}
final raw = task.printCmd?.trim() ?? '';
if (raw.isEmpty) return null;
try {
final decoded = base64.decode(raw.replaceAll(RegExp(r'\s'), ''));
if (decoded.isEmpty) {
logger.w('|$_tag|打印指令内容为空,回退图片打印|task:${task.servicePrintId}|');
return null;
}
return decoded;
} catch (error) {
logger.w(
'|$_tag|打印指令解码失败,回退图片打印|task:${task.servicePrintId}|error:$error|');
return null;
}
}
/// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。
static Future<void> _report(PrintHistoryEntity task) async {
if (!AppNetworkTool.isAutoNetwork()) {
+1 -2
View File
@@ -1,3 +1,2 @@
/// svgs assets
class AssetsSvgs {
}
class AssetsSvgs {}
@@ -1,93 +0,0 @@
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();
// 监听结束即隐藏输入法窗口,避免软键盘或残留的输入连接跟随到其他页面。
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
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);
},
),
),
),
);
}
}
@@ -0,0 +1,644 @@
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 自研软键盘的初始面板类型。
enum AppSoftKeyboardMode {
/// 数字面板(1-9、0、退格、清空)。
digits,
/// 字母面板(QWERTY 布局,支持大小写切换与空格)。
letters,
/// 符号面板(常用标点与特殊符号)。
symbols,
}
/// 自研软键盘面板:数字/字母/符号三面板 + 顶部切换 tab。
///
/// 定制 ROM 在检测到物理键盘连接时不渲染系统软键盘窗口,本面板以确定性
/// 自绘接管触摸输入:显式绑定目标 [TextEditingController],在光标位置插入、
/// 退格或清空,字符白名单与长度上限由调用场景声明。两种使用方式:
///
/// - 页面输入框:[AppSoftKeyboard.show] 弹出可拖动的浮层键盘;
/// - 矮弹框内的输入(如终端操作验证):直接把 [AppSoftKeyboardPanel] 放进
/// 弹框 content,键盘跟随弹框而不与弹框分离。
///
/// 目标输入框仍应保持物理输入能力:设置 [TextInputType.none] 只抑制系统
/// 软键盘,实体键盘与扫码枪的按键事件不受影响。
class AppSoftKeyboardPanel extends StatefulWidget {
/// 创建内嵌键盘面板;宽度默认跟随父级约束。
const AppSoftKeyboardPanel({
super.key,
required this.controller,
this.mode = AppSoftKeyboardMode.digits,
this.allowPattern,
this.maxLength,
this.semanticLabel = '终端软键盘',
this.onCloseRequested,
this.contentWidth,
});
/// 绑定的目标输入控制器;按键在光标位置插入或删除。
final TextEditingController controller;
/// 初始面板类型。
final AppSoftKeyboardMode mode;
/// 可插入字符白名单(正则逐字符匹配);为空时不限制。
final RegExp? allowPattern;
/// 目标文本总长度上限;超限时新插入被忽略。
final int? maxLength;
/// 无障碍标签,用于区分宿主场景中的多个键盘实例。
final String semanticLabel;
/// 请求收起键盘的回调;非空时面板显示通栏“确认”键(浮层模式),
/// 为空表示内嵌在弹框中,提交由弹框自身按钮承担。
final VoidCallback? onCloseRequested;
/// 面板内容区宽度;为空时跟随父级约束(内嵌弹框场景)。
final double? contentWidth;
@override
State<AppSoftKeyboardPanel> createState() => _AppSoftKeyboardPanelState();
}
class _AppSoftKeyboardPanelState extends State<AppSoftKeyboardPanel> {
/// 当前面板类型;由顶部 tab 与字母面板的 123 键切换。
AppSoftKeyboardMode _mode = AppSoftKeyboardMode.digits;
/// 字母面板当前是否大写;默认小写,按 ⇧ 切换。
bool _uppercase = false;
@override
void initState() {
super.initState();
_mode = widget.mode;
}
/// 校验并插入文本到光标位置;替换当前选区,插入后光标落在文本末尾。
void _insert(String text) {
if (text.isEmpty) return;
final allowPattern = widget.allowPattern;
if (allowPattern != null &&
text.runes
.any((code) => !allowPattern.hasMatch(String.fromCharCode(code)))) {
return;
}
final value = widget.controller.value;
final selection = value.selection.isValid
? value.selection
: TextSelection.collapsed(offset: value.text.length);
final removedLength = selection.end - selection.start;
final maxLength = widget.maxLength;
if (maxLength != null &&
value.text.length - removedLength + text.length > maxLength) {
return;
}
widget.controller.value = value.replaced(selection, text);
}
/// 删除选区或光标前一位字符。
void _backspace() {
final value = widget.controller.value;
if (value.text.isEmpty) return;
final selection = value.selection.isValid
? value.selection
: TextSelection.collapsed(offset: value.text.length);
final range = selection.isCollapsed
? TextRange(start: selection.start - 1, end: selection.end)
: selection;
widget.controller.value = value.replaced(range, '');
}
/// 清空全部文本。
void _clear() {
if (widget.controller.text.isEmpty) return;
widget.controller.clear();
}
void _switchMode(AppSoftKeyboardMode mode) {
if (mode == _mode) return;
setState(() => _mode = mode);
}
@override
Widget build(BuildContext context) {
return Semantics(
label: widget.semanticLabel,
child: LayoutBuilder(
builder: (context, constraints) {
// 内嵌场景宽度跟随弹框;浮层场景使用固定面板宽。
final contentWidth = widget.contentWidth ?? constraints.maxWidth;
return <Widget>[
_buildTabRow(FTheme.of(context)),
SizedBox(height: 10.h),
switch (_mode) {
AppSoftKeyboardMode.digits =>
_buildDigitsPanel(context, contentWidth),
AppSoftKeyboardMode.letters =>
_buildLettersPanel(context, contentWidth),
AppSoftKeyboardMode.symbols =>
_buildSymbolsPanel(context, contentWidth),
},
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch);
},
),
);
}
/// 顶部面板切换 tab 行。
Widget _buildTabRow(FThemeData theme) {
Widget tab(String label, AppSoftKeyboardMode mode) {
final active = _mode == mode;
return InkWell(
onTap: () => _switchMode(mode),
borderRadius: BorderRadius.circular(20.w),
child: Text(
label,
style: theme.typography.sm.copyWith(
color: active ? XfjDesignTokens.primary : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
).center().tight(width: 84.w, height: 44.h).decorated(
color: active
? XfjDesignTokens.primarySoft
: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(20.w),
),
);
}
return <Widget>[
const Spacer(),
tab('123', AppSoftKeyboardMode.digits),
tab('ABC', AppSoftKeyboardMode.letters),
tab('#+=', AppSoftKeyboardMode.symbols),
].toRowSpace(space: 8.w).tight(height: 44.h).padding(horizontal: 4.w);
}
/// 数字面板:三行数字(1-9)+ 操作行(清空/0/退格),浮层模式追加通栏确认。
Widget _buildDigitsPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
final keyWidth = (contentWidth - 20.w) / 3 - 1;
Widget key(
String semantic, {
Widget? content,
VoidCallback? onTap,
}) =>
_Key(
semanticLabel: semantic,
onTap: onTap ?? () {},
width: keyWidth,
child: content,
);
Widget digitRow(List<String> digits) => <Widget>[
for (final value in digits)
key(
'输入$value',
onTap: () => _insert(value),
content: Text(
value,
style: theme.typography.xl.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
].toRowSpace(space: 10.w);
return <Widget>[
digitRow(['1', '2', '3']),
digitRow(['4', '5', '6']),
digitRow(['7', '8', '9']),
<Widget>[
key(
'清空全部输入',
onTap: _clear,
content: Text(
'清空',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
key(
'输入0',
onTap: () => _insert('0'),
content: Text(
'0',
style: theme.typography.xl.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
key(
'退格删除一位',
onTap: _backspace,
content: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
),
].toRowSpace(space: 10.w),
if (widget.onCloseRequested != null)
_Key(
semanticLabel: '确认并收起键盘',
onTap: widget.onCloseRequested!,
width: contentWidth,
height: 54.h,
emphasized: true,
child: Text(
'确认',
style: theme.typography.base.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
].toColumnSpace(space: 10.h);
}
/// 字母面板:QWERTY 三行(键等宽、整行居中)+ 操作行(⇧/123/空格/退格/确认)。
Widget _buildLettersPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
final keyWidth = (contentWidth - 9 * 8.w) / 10 - 1;
Widget letter(String value) => _Key(
semanticLabel: '输入字母$value',
onTap: () => _insert(_uppercase ? value.toUpperCase() : value),
width: keyWidth,
child: Text(
_uppercase ? value.toUpperCase() : value,
style: theme.typography.lg.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
);
Widget letterRow(List<String> letters) => <Widget>[
for (final value in letters) letter(value),
].toRowSpace(
space: 8.w,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
);
final actionStyle = theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
);
final whiteActionStyle = theme.typography.sm.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
);
Widget actionKey(
String semantic, {
required double width,
bool emphasized = false,
Widget? content,
required VoidCallback onTap,
}) =>
_Key(
semanticLabel: semantic,
onTap: onTap,
width: width,
emphasized: emphasized,
child: content,
);
return <Widget>[
letterRow(['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']),
letterRow(['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']),
letterRow(['z', 'x', 'c', 'v', 'b', 'n', 'm']),
<Widget>[
actionKey(
_uppercase ? '切换为小写输入' : '切换为大写输入',
width: 88.w,
emphasized: _uppercase,
content: Text(
_uppercase ? '⇧ 大' : '⇧ 小',
style: _uppercase ? whiteActionStyle : actionStyle,
),
onTap: () => setState(() => _uppercase = !_uppercase),
),
actionKey(
'切到数字面板',
width: 88.w,
content: Text('123', style: actionStyle),
onTap: () => _switchMode(AppSoftKeyboardMode.digits),
),
actionKey(
'输入空格',
width: 264.w,
content: Text('空格', style: actionStyle),
onTap: () => _insert(' '),
),
actionKey(
'退格删除一位',
width: 130.w,
content: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
onTap: _backspace,
),
if (widget.onCloseRequested != null)
actionKey(
'确认并收起键盘',
width: 130.w,
emphasized: true,
content: Text('确认', style: whiteActionStyle),
onTap: widget.onCloseRequested!,
),
].toRowSpace(space: 8.w),
].toColumnSpace(space: 10.h);
}
/// 符号面板:常用符号五行五列 + 操作行(空格/退格/确认)。
Widget _buildSymbolsPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
const symbols = [
'-',
'.',
'_',
'/',
'*',
',',
':',
';',
'%',
'#',
'+',
'@',
'&',
'?',
'!',
'(',
')',
'[',
']',
'=',
];
final keyWidth = (contentWidth - 4 * 8.w) / 5 - 1;
Widget symbol(String value) => _Key(
semanticLabel: '输入符号$value',
onTap: () => _insert(value),
width: keyWidth,
child: Text(
value,
style: theme.typography.lg.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
);
final actionStyle = theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
);
final whiteActionStyle = theme.typography.sm.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
);
return <Widget>[
for (var index = 0; index < symbols.length; index += 5)
<Widget>[
for (final value in symbols.skip(index).take(5)) symbol(value),
].toRowSpace(space: 8.w),
<Widget>[
_Key(
semanticLabel: '输入空格',
onTap: () => _insert(' '),
width: contentWidth / 2,
child: Text('空格', style: actionStyle),
),
_Key(
semanticLabel: '退格删除一位',
onTap: _backspace,
width: contentWidth / 4,
child: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
),
if (widget.onCloseRequested != null)
_Key(
semanticLabel: '确认并收起键盘',
onTap: widget.onCloseRequested!,
width: contentWidth / 4,
emphasized: true,
child: Text('确认', style: whiteActionStyle),
),
].toRowSpace(space: 8.w),
].toColumnSpace(space: 10.h);
}
}
/// 自研可拖动软键盘弹出框。
///
/// 通过 [AppSoftKeyboard.show] 弹出:键盘浮在页面内容之上(透明遮罩),
/// 默认停靠屏幕底部,可按住顶部手柄行自由拖动,点击键盘外任意处或按
/// “确认”收起。矮弹框内的输入场景应改用 [AppSoftKeyboardPanel] 内嵌。
abstract final class AppSoftKeyboard {
/// 浮层键盘的面板宽度(内容区,含左右内边距共 760)。
static final double floatingContentWidth = 736.w;
/// 弹出浮层键盘并绑定目标控制器;返回的 Future 在键盘关闭时完成。
static Future<void> show(
BuildContext context, {
required TextEditingController controller,
AppSoftKeyboardMode mode = AppSoftKeyboardMode.digits,
RegExp? allowPattern,
int? maxLength,
String semanticLabel = '终端软键盘',
}) {
return showGeneralDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
// 透明遮罩:键盘期间页面内容可见,点击键盘外任意处即收起。
barrierColor: Colors.transparent,
// barrierDismissible 开启时框架要求提供遮罩语义标签。
barrierLabel: '收起软键盘',
// 键盘要求即时出现与消失,不做转场动画。
transitionDuration: Duration.zero,
pageBuilder: (dialogContext, _, __) => _SoftKeyboardDialog(
controller: controller,
mode: mode,
allowPattern: allowPattern,
maxLength: maxLength,
semanticLabel: semanticLabel,
),
);
}
}
/// 浮层键盘宿主:停靠屏幕底部、承载拖动手柄行与内嵌面板。
class _SoftKeyboardDialog extends StatefulWidget {
const _SoftKeyboardDialog({
required this.controller,
required this.mode,
required this.semanticLabel,
this.allowPattern,
this.maxLength,
});
final TextEditingController controller;
final AppSoftKeyboardMode mode;
final RegExp? allowPattern;
final int? maxLength;
final String semanticLabel;
@override
State<_SoftKeyboardDialog> createState() => _SoftKeyboardDialogState();
}
class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
/// 拖动偏移;相对默认底部居中位置。
Offset _dragOffset = Offset.zero;
/// 拖动手势更新偏移;按屏幕半宽钳制,保证键盘不会被完全拖出屏幕。
void _onDragUpdate(DragUpdateDetails details) {
final halfScreen = MediaQuery.sizeOf(context) / 2;
setState(() {
_dragOffset += details.delta;
_dragOffset = Offset(
_dragOffset.dx.clamp(-halfScreen.width, halfScreen.width),
_dragOffset.dy.clamp(-halfScreen.height, halfScreen.height),
);
});
}
void _close() => Navigator.of(context).pop();
@override
Widget build(BuildContext context) {
// 弹层约束为全屏;用 Spacer 把面板可靠地压到屏幕底部,底部留出安全边距。
return SizedBox.expand(
child: Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Transform.translate(
offset: _dragOffset,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Spacer(),
Material(
color: Colors.transparent,
child: Container(
width: 760.w,
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: XfjDesignTokens.canvas,
borderRadius:
BorderRadius.circular(XfjDesignTokens.cardRadius),
border: Border.all(color: XfjDesignTokens.line),
boxShadow: XfjDesignTokens.surfaceShadow,
),
child: <Widget>[
_buildDragHandle(),
SizedBox(height: 10.h),
AppSoftKeyboardPanel(
controller: widget.controller,
mode: widget.mode,
allowPattern: widget.allowPattern,
maxLength: widget.maxLength,
semanticLabel: widget.semanticLabel,
onCloseRequested: _close,
contentWidth: AppSoftKeyboard.floatingContentWidth,
),
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
),
),
],
),
),
),
);
}
/// 拖动手柄行:右侧拖动指示,按住整行自由拖动键盘。
Widget _buildDragHandle() {
return GestureDetector(
onPanUpdate: _onDragUpdate,
child: <Widget>[
const Spacer(),
Icon(
Icons.drag_indicator_rounded,
color: XfjDesignTokens.muted,
size: 26.w,
semanticLabel: '拖动键盘',
),
].toRowSpace(space: 8.w).tight(height: 32.h).padding(horizontal: 4.w),
);
}
}
/// 单个键盘按键;固定宽度布局(弹层处于无界高度约束,禁用 Expanded),
/// 主操作键使用主题色底白字。
class _Key extends StatelessWidget {
const _Key({
required this.semanticLabel,
required this.onTap,
required this.width,
required this.child,
this.emphasized = false,
this.height,
});
final String semanticLabel;
final VoidCallback onTap;
/// 按键宽度;由各面板按列数与间距计算。
final double width;
/// 按键内容(数字文字或图标)。
final Widget? child;
/// 是否主操作键(确认/大写锁定态),使用主题色底与白字。
final bool emphasized;
/// 按键高度;默认 62。
final double? height;
@override
Widget build(BuildContext context) {
final keyHeight = height ?? 62.h;
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: (child ?? const SizedBox.shrink())
.center()
.tight(width: width, height: keyHeight)
.decorated(
color: emphasized
? XfjDesignTokens.primary
: XfjDesignTokens.surface,
border: Border.all(
color: emphasized
? XfjDesignTokens.primary
: XfjDesignTokens.line,
),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
),
),
);
}
}
@@ -86,8 +86,6 @@ Widget appWalletSelector(
);
}
// list.add(
// ServiceWallet(
// id: "1",
@@ -105,4 +103,4 @@ Widget appWalletSelector(
// walletTypeId: "2",
// balance: 200,
// ),
// );
// );
+27 -2
View File
@@ -114,9 +114,11 @@ class TerminalToolbarTextField extends StatelessWidget {
this.keyboardType,
this.onSubmit,
this.prefixBuilder,
this.softKeyboard,
this.softKeyboardAllowPattern,
this.softKeyboardMaxLength,
});
/// 输入框宽度;为空时由父级布局分配可用宽度。
final double? width;
/// 页面持有的文本控制器,公共组件不接管其生命周期。
@@ -140,9 +142,22 @@ class TerminalToolbarTextField extends StatelessWidget {
/// 输入框前缀构建器,用于页面已有的搜索图标等语义提示。
final ValueWidgetBuilder<FTextFieldStateStyle>? prefixBuilder;
/// 启用自研软键盘的初始面板;为空时不接管点击(保持现状)。
///
/// 启用后输入框抑制系统软键盘并改为弹出 [AppSoftKeyboard];实体键盘与
/// 扫码枪的物理按键输入不受影响。
final AppSoftKeyboardMode? softKeyboard;
/// 自研键盘的可插入字符白名单;为空时不限制。
final RegExp? softKeyboardAllowPattern;
/// 自研键盘的文本长度上限;为空时不限制。
final int? softKeyboardMaxLength;
/// 构建随按钮大小档位变化的单行输入框。
@override
Widget build(BuildContext context) {
final keyboardMode = softKeyboard;
return TerminalToolbarControl(
width: width,
child: FTextField(
@@ -150,10 +165,20 @@ class TerminalToolbarTextField extends StatelessWidget {
focusNode: focusNode,
hint: hint,
textInputAction: textInputAction,
keyboardType: keyboardType,
// 自研键盘接管时抑制系统软键盘,只保留物理按键输入通道。
keyboardType: keyboardMode != null ? TextInputType.none : keyboardType,
maxLines: 1,
onSubmit: onSubmit,
prefixBuilder: prefixBuilder,
onTap: keyboardMode == null
? null
: () => AppSoftKeyboard.show(
context,
controller: controller,
mode: keyboardMode,
allowPattern: softKeyboardAllowPattern,
maxLength: softKeyboardMaxLength,
),
),
);
}
@@ -139,6 +139,7 @@ class CashierWorkspace extends StatelessWidget {
final TextEditingController? searchController;
final FocusNode? searchFocusNode;
final String searchHint;
final VoidCallback? onSearch;
final VoidCallback? onReset;
final VoidCallback? onScan;
@@ -270,8 +271,10 @@ class CashierSearchPanel extends StatelessWidget {
child: TerminalToolbarTextField(
controller: controller!,
focusNode: focusNode,
// 触摸输入统一走自研软键盘(字母面板);实体键盘与
// 扫码枪的物理输入不受影响。
softKeyboard: AppSoftKeyboardMode.letters,
textInputAction: TextInputAction.none,
keyboardType: TextInputType.none,
hint: hint,
onSubmit: (_) => onSearch?.call(),
),
@@ -742,9 +745,10 @@ class CashierProductCard extends StatelessWidget {
fontWeight: FontWeight.w700,
),
).padding(horizontal: 6.w, vertical: 3.h).decorated(
color: XfjDesignTokens.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
color:
XfjDesignTokens.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
].toColumnSpace(
crossAxisAlignment: CrossAxisAlignment.start,
space: 4.h,
@@ -901,8 +905,9 @@ class _CashierProductDetailsButton extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.xs.copyWith(
color:
enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted,
color: enabled
? XfjDesignTokens.primary
: XfjDesignTokens.muted,
fontWeight: FontWeight.w800,
),
),
+1 -1
View File
@@ -11,9 +11,9 @@ export './app/hardware_input_history_selector.dart';
export './app/app_img.dart';
export './app/app_message_dialog.dart';
export './app/app_page.dart';
export './app/app_soft_keyboard.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';
@@ -130,7 +130,8 @@ class _TerminalAdminPasswordDialogState
hint: '请输入管理密码',
maxLines: 1,
obscureText: _obscureText,
keyboardType: TextInputType.number,
// 系统软键盘在定制 ROM 上不可用,触摸输入由下方内嵌键盘承担。
keyboardType: TextInputType.none,
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
@@ -162,7 +163,7 @@ class _TerminalAdminPasswordDialogState
FIcon(
FAssets.icons.circleAlert,
color: XfjDesignTokens.danger,
size: 18.sp,
size: 18.w,
),
SizedBox(width: 8.w),
Text(
@@ -175,6 +176,16 @@ class _TerminalAdminPasswordDialogState
],
),
],
SizedBox(height: 20.h),
// 弹框高度有限且需要输入,键盘直接内嵌在弹框内跟随展示;
// 管理密码仅数字且最长 12 位。
AppSoftKeyboardPanel(
controller: _controller,
mode: AppSoftKeyboardMode.digits,
allowPattern: RegExp('[0-9]'),
maxLength: 12,
semanticLabel: '管理密码数字键盘',
),
],
),
),
+3
View File
@@ -20,6 +20,9 @@ class CardActivePage extends GetView<CardActiveController> {
width: 360.w,
controller: pageController.carNumberC,
hint: '请输入饭卡卡号',
// 饭卡卡号为数字;触摸输入走自研数字键盘,读卡与实体键盘不受影响。
softKeyboard: AppSoftKeyboardMode.digits,
softKeyboardAllowPattern: RegExp('[0-9]'),
onSubmit: (_) => pageController.doPage(page: 1),
),
SizedBox(width: 12.w),
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
@@ -362,15 +361,14 @@ class ReservationMenuQueryController extends GetxController
}
}
/// 页面重新可见时释放其他页面残留的文本输入焦点并收起软键盘。
/// 页面重新可见时释放其他页面残留的文本输入焦点。
///
/// 终端主框架通过内嵌 Navigator 替换工作区,本页 Controller 常驻不重建;
/// 从开卡等带输入框的页面切回时,残留焦点会让软键盘跟随显示,并让快捷
/// 键盘误判为文本输入而让路,因此每次页面挂载都需要清理一次。
/// 从开卡等带输入框的页面切回时,残留焦点会让物理键盘字符落入旧输入框,
/// 并让快捷键盘误判为文本输入而让路,因此每次页面挂载都清理一次。
void releaseResidualTextInput() {
if (_closed) return;
FocusManager.instance.primaryFocus?.unfocus();
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
}
/// 更新预选餐段;等待硬件识别时仍允许调整,身份已获取并查询服务端后锁定本轮目标。
@@ -1,6 +1,5 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 单独按下的修饰键不作为快捷键来源,与设置页按键绑定保持一致的过滤口径。
@@ -79,12 +78,8 @@ class ReservationShortcutKeyboard {
bool _handleKeyEvent(KeyEvent event) {
if (!active) return false;
// 焦点落在文本编辑组件(如终端操作验证的密码框)时物理按键全部让路
// 给文本输入:既不拦截按键,也不隐藏软键盘。
// 给文本输入:既不拦截按键,也不影响任何输入通道。
if (_hasActiveTextInput()) return false;
// 键盘模式激活期间持续压制输入法窗口:部分系统开启“连接物理键盘时显示
// 虚拟键盘”后,物理按键会被转化为软键盘弹出;命中与否都先隐藏,保证
// 查询菜单只响应外接小键盘本身。
SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
if (event is! KeyDownEvent) return false;
if (_ignoredModifierKeys.contains(event.logicalKey)) return false;
final binding = MenuQueryShortcutKeyBinding.fromKeyEvent(event);
@@ -148,7 +148,8 @@ class OrderHistoryController extends GetxController {
/// 切换数据范围;全部数据依赖网络,离线时提示并保持本机数据。
Future<void> doScope(TerminalDataScope target) async {
if (scope.value == target) return;
if (target == TerminalDataScope.merchant && !AppNetworkTool.isAutoNetwork()) {
if (target == TerminalDataScope.merchant &&
!AppNetworkTool.isAutoNetwork()) {
Toast.error(msg: '当前没有网络,无法查询全部数据');
return;
}
+2 -6
View File
@@ -1,7 +1,6 @@
import 'package:common_utils/common_utils.dart';
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';
@@ -429,10 +428,8 @@ class _OrderDateRangeControl extends StatelessWidget {
fontWeight: FontWeight.w600,
),
onTap: () async {
// 先释放搜索框焦点并等待软键盘退场,避免日期弹窗打开后输入框仍显示聚焦态。
// 先释放搜索框焦点,避免日期弹窗打开后输入框仍显示聚焦态。
FocusManager.instance.primaryFocus?.unfocus();
await SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!context.mounted) return;
final result = await appShowDatePicker(
context,
@@ -509,8 +506,7 @@ class _StatusFilterButton extends StatelessWidget {
if (showCount) ...[
SizedBox(width: 8.w),
Container(
padding:
EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.2)
@@ -39,12 +39,30 @@ Future<void> _bindServiceDialog(BuildContext context, SysSetting sys) async {
maxWidth: context.width * 0.55,
onClose: () => Navigator.of(dialogContext).pop(),
semanticLabel: '绑定设备唯一编号弹框',
content: FTextField(
textInputAction: TextInputAction.none,
controller: uniqueCodeController,
hint: '请输入设备唯一编号',
maxLines: 1,
).width(double.infinity).marginSymmetric(vertical: 20.h),
content: SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设备唯一编号为纯数字;系统软键盘在定制 ROM 上不可用,弹框
// 高度有限,键盘直接内嵌跟随展示,实体键盘输入不受影响。
FTextField(
keyboardType: TextInputType.none,
controller: uniqueCodeController,
hint: '请输入设备唯一编号',
maxLines: 1,
).width(double.infinity),
SizedBox(height: 16.h),
AppSoftKeyboardPanel(
controller: uniqueCodeController,
mode: AppSoftKeyboardMode.digits,
allowPattern: RegExp('[0-9]'),
semanticLabel: '设备唯一编号数字键盘',
),
],
),
).marginSymmetric(vertical: 20.h),
actions: [
FButton(
style: FButtonStyle.outline,
@@ -37,9 +37,6 @@ const List<LogicalKeyboardKey> _shortcutIgnoredModifierKeys = [
LogicalKeyboardKey.metaRight,
];
/// 把输入法提交的文本反查为字母逻辑按键的逻辑已公共化到
/// [shortcutLogicalKeyFromImeText],设置页与查询菜单页共用同一套字符兜底规则。
/// 查询菜单外接快捷小键盘的按键绑定设置组件。
///
/// 操作流程:点击动作行进入监听(行高亮并提示),再按下外接小键盘的物理按键
@@ -268,39 +265,12 @@ class _SettingMenuQueryShortcutKeypadState
_testDialogShowing = false;
}
/// 输入法文本兜底:把被合成为文本提交的字母按键转换为逻辑键处理。
///
/// 模拟器以及输入法处于激活状态的真机上,物理字母键可能被输入法合成为
/// 文本注入而不产生按键事件;此时由隐形收集器持有焦点接收文本并反查按键,
/// 与按键事件通道互补,覆盖“字符型小键盘”的全部接入场景。
void _handleImeText(String text) {
final listeningAction = _listeningAction;
if (listeningAction == null && !_testing) return;
final key = shortcutLogicalKeyFromImeText(text);
// 无法确认到唯一字母按键的输入(中文候选、标点等)直接忽略,保持当前模式,
// 不提示错误也不关心操作员的输入法状态。
if (key == null) return;
// 文本输入只有逻辑键身份,物理键位缺失时绑定仍可运行期兜底匹配。
final binding = MenuQueryShortcutKeyBinding(logicalKeyId: key.keyId);
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 独立重建。
@override
Widget build(BuildContext context) {
return SettingGroup(
title: '快捷按钮',
child: <Widget>[
// 绑定监听或测试期间挂载隐形输入收集器,承接输入法合成的文本按键输入。
if (_listeningAction != null || _testing)
AppShortcutImeCollector(onCharacter: _handleImeText),
_buildKeyboardModeRow(),
_ShortcutBindingHeader(
onReset: widget.controller.resetMenuQueryShortcutKeyBindings,
@@ -219,6 +219,31 @@ class _SettingPrinterInfoState extends State<SettingPrinterInfo> {
}
}
/// 切换本机打印输出方式并立即持久化;保存失败时回滚显示,保证界面与实际配置一致。
Future<void> _switchOutputMode(String mode) async {
final setting = _setting;
if (setting == null || setting.printerOutputMode == mode) return;
final previous = setting.printerOutputMode;
setState(() => setting.printerOutputMode = mode);
try {
await AppSettingTool.setPrinterOutputMode(mode);
if (!mounted) return;
setState(() {
_operationMessage = mode == AppSettingTool.printerOutputModeCommand
? '已切换为指令打印,后续任务优先直写服务端下发的打印命令'
: '已切换为图片打印,后续任务按图片栅格指令输出';
});
} catch (error, stack) {
logger.e('|打印机信息|切换打印方式失败|mode:$mode|error:$error|stack:$stack|');
if (!mounted) return;
setState(() {
setting.printerOutputMode = previous;
_errorMessage = '打印方式保存失败,已恢复原设置';
});
Toast.error(msg: '打印方式保存失败');
}
}
/// 向选中设备输出一张诊断小票,并准确描述 USB 写入层能够确认的结果边界。
Future<void> _printTestPage() async {
final device = _selectedDevice;
@@ -305,6 +330,12 @@ class _SettingPrinterInfoState extends State<SettingPrinterInfo> {
child: _buildConfiguredDetails(setting),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: '本机打印方式',
dense: true,
child: _buildOutputModeSelector(context, setting),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: 'USB 打印机',
dense: true,
@@ -461,6 +492,37 @@ class _SettingPrinterInfoState extends State<SettingPrinterInfo> {
],
);
/// 构建打印输出方式切换:图片栅格为默认兼容模式,指令直写出纸更快。
Widget _buildOutputModeSelector(BuildContext context, SysSetting? setting) {
final theme = FTheme.of(context);
final mode =
setting?.printerOutputMode == AppSettingTool.printerOutputModeCommand
? AppSettingTool.printerOutputModeCommand
: AppSettingTool.printerOutputModeImage;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingSegmentedSelector<String>(
label: '凭证输出',
value: mode,
options: const {
AppSettingTool.printerOutputModeImage: '图片打印',
AppSettingTool.printerOutputModeCommand: '指令打印',
},
onChanged: _switchOutputMode,
showDivider: false,
),
Padding(
padding: EdgeInsets.only(left: 20.w, right: 20.w, bottom: 12.h),
child: Text(
'指令打印由服务端直接下发打印命令,出纸更快;后台未生成命令或历史任务会自动回退图片打印。测试打印跟随当前方式验证对应链路',
style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted),
),
),
],
);
}
/// 构建 USB 设备空状态或可选择设备行列表。
Widget _buildDeviceList() {
if (_refreshing && _devices.isEmpty) {
@@ -1,7 +1,6 @@
import 'package:common_utils/common_utils.dart';
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';
@@ -165,7 +164,6 @@ class _VerificationDateRangeControl extends StatelessWidget {
),
onTap: () async {
FocusManager.instance.primaryFocus?.unfocus();
await SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
if (!context.mounted) return;
onChanged(await appShowDatePicker(context, datetimeRange: dates));
},
@@ -0,0 +1 @@
+182 -182
View File
File diff suppressed because it is too large. Load diff
+162
View File
@@ -0,0 +1,162 @@
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 自研软键盘组件的渲染与输入联动测试。
void main() {
Widget wrapHost(Widget Function(BuildContext context) child) =>
ScreenUtilInit(
designSize: const Size(1920, 1080),
minTextAdapt: true,
splitScreenMode: true,
builder: (_, __) => MaterialApp(
home: FTheme(
data: FThemes.zinc.light,
child: Builder(builder: child),
),
),
);
testWidgets('弹出后渲染面板并可输入目标控制器', (tester) async {
final controller = TextEditingController();
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrapHost(
(context) => Scaffold(
body: Center(
child: FButton(
onPress: () => AppSoftKeyboard.show(
context,
controller: controller,
mode: AppSoftKeyboardMode.digits,
),
label: const Text('打开键盘'),
),
),
),
),
);
await tester.tap(find.text('打开键盘'));
await tester.pumpAndSettle();
// 面板、tab 与确认键都应渲染。
expect(find.text('确认'), findsOneWidget);
// 键盘应停靠在屏幕下半部(底部 16h + 面板高度内)。
final confirmRect = tester.getRect(find.text('确认'));
expect(confirmRect.bottom, lessThan(1080));
expect(confirmRect.bottom, greaterThan(1080 * 0.5));
// 点击数字 1 与 5 后字符进入目标控制器。
await tester.tap(find.text('1'));
await tester.pump();
await tester.tap(find.text('5'));
await tester.pump();
expect(controller.text, '15');
});
testWidgets('在 AppDialog 弹框内弹出键盘仍然渲染', (tester) async {
final passwordController = TextEditingController();
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrapHost(
(context) => Scaffold(
body: Center(
child: FButton(
onPress: () => showAppDialog<void>(
context: context,
barrierDismissible: false,
canPop: false,
builder: (dialogContext) => AppDialog(
title: const Text('终端操作验证'),
width: 680,
maxWidth: 680,
content: FTextField(
controller: passwordController,
keyboardType: TextInputType.none,
onTap: () => AppSoftKeyboard.show(
dialogContext,
controller: passwordController,
mode: AppSoftKeyboardMode.digits,
),
),
actions: [
FButton(
style: FButtonStyle.outline,
label: const Text('取消'),
onPress: () => Navigator.of(dialogContext).pop(),
),
],
),
),
label: const Text('打开验证弹框'),
),
),
),
),
);
await tester.tap(find.text('打开验证弹框'));
await tester.pumpAndSettle();
// 弹框内的输入框应可见。
expect(find.text('终端操作验证'), findsOneWidget);
await tester.tap(find.byType(TextField).first);
await tester.pumpAndSettle();
// 键盘面板在弹框之上仍应渲染。
expect(find.text('确认'), findsOneWidget);
});
testWidgets('内嵌面板在弹框内渲染并输入', (tester) async {
final controller = TextEditingController();
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrapHost(
(context) => Scaffold(
body: Center(
child: AppSoftKeyboardPanel(
controller: controller,
mode: AppSoftKeyboardMode.digits,
allowPattern: RegExp('[0-9]'),
maxLength: 12,
).tight(width: 624),
),
),
),
);
// 面板切换 tab 与数字键应渲染;内嵌模式没有确认键。
expect(find.text('123'), findsOneWidget);
expect(find.text('确认'), findsNothing);
await tester.tap(find.text('2'));
await tester.pump();
await tester.tap(find.text('5'));
await tester.pump();
await tester.tap(find.text('0'));
await tester.pump();
await tester.tap(find.text('3'));
await tester.pump();
expect(controller.text, '2503');
// 清空键一键清空。
await tester.tap(find.text('清空'));
await tester.pump();
expect(controller.text, '');
});
}