feat:取消系统自带软键盘设计,改为系统自用键盘组件-初步
feat:将打印分为图片打印和命令式打印-初步
This commit is contained in:
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` 本次不引入(协议向后兼容,旧终端忽略新字段);如需同步另开任务。
|
||||
- 指令模式版式为「按图片版式近似还原」而非像素级一致,字重/放大效果以后端拼装为准,真机验收时微调。
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
// ),
|
||||
// );
|
||||
// );
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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: '管理密码数字键盘',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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, '');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user