From a461b0fda3ae907251a5f5cbf988eb9681381ac2 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 9 Sep 2026 19:32:19 +0800 Subject: [PATCH] 111 --- lib/common/models/index.dart | 1 + .../page/cashier_checkout_snapshot.dart | 102 + lib/common/routers/names.dart | 4 +- lib/common/routers/pages.dart | 11 - .../utils/app/control/app_theme_control.dart | 36 + lib/common/utils/manage/mq_manager.dart | 4 + lib/common/widgets/app/product_type_card.dart | 76 + lib/common/widgets/app/terminal_shell.dart | 6 +- .../widgets/face/face_camera_preview.dart | 210 + .../face/face_live_camera_preview.dart | 194 + .../widgets/face/face_recognition_panel.dart | 486 +++ .../face/face_recognition_preview_dialog.dart | 138 + .../face/face_recognition_preview_layer.dart | 320 ++ lib/common/widgets/index.dart | 7 + .../widgets/order/select_pay_type_dialog.dart | 3439 ++++++++++++++--- lib/pages/canteen_order_total/view.dart | 5 +- lib/pages/canteen_product/controller.dart | 46 +- lib/pages/canteen_reserve/view.dart | 3 +- lib/pages/order_history/view.dart | 2 +- lib/pages/order_history_detail/view.dart | 3 +- lib/pages/setting/controller.dart | 395 +- lib/pages/setting/routes/navigation.dart | 14 +- lib/pages/setting/sync_logs/view.dart | 1 - lib/pages/setting/view.dart | 6 +- .../setting/widgets/setting_baseInfo.dart | 595 ++- lib/pages/setting/widgets/setting_pay.dart | 621 ++- lib/pages/supermarket_order/controller.dart | 31 +- lib/pages/take_food/view.dart | 5 +- lib/pages/terminal/view.dart | 11 + 29 files changed, 6012 insertions(+), 760 deletions(-) create mode 100644 lib/common/models/page/cashier_checkout_snapshot.dart create mode 100644 lib/common/widgets/app/product_type_card.dart create mode 100644 lib/common/widgets/face/face_camera_preview.dart create mode 100644 lib/common/widgets/face/face_live_camera_preview.dart create mode 100644 lib/common/widgets/face/face_recognition_panel.dart create mode 100644 lib/common/widgets/face/face_recognition_preview_dialog.dart create mode 100644 lib/common/widgets/face/face_recognition_preview_layer.dart diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index b519847..b8ed390 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -10,6 +10,7 @@ export './api/wallet_api_models.dart'; export './base/base_models.dart'; export './page/app_version_enum_models.dart'; +export './page/cashier_checkout_snapshot.dart'; export './page/order_page_enum_models.dart'; export './page/order_page_models.dart'; export './page/panel_page_models.dart'; diff --git a/lib/common/models/page/cashier_checkout_snapshot.dart b/lib/common/models/page/cashier_checkout_snapshot.dart new file mode 100644 index 0000000..988c9a7 --- /dev/null +++ b/lib/common/models/page/cashier_checkout_snapshot.dart @@ -0,0 +1,102 @@ +import 'package:pj_supermarket_cloud/common/models/database/database_enum_models.dart'; +import 'package:pj_supermarket_cloud/common/services/database/entity/shop_card_entity.dart'; + +/// 收银弹框打开时冻结的单个购物车条目,避免后续页面状态变化污染展示与提交上下文。 +class CashierCheckoutItemSnapshot { + /// 从现有购物车实体复制全部支付展示所需字段。 + CashierCheckoutItemSnapshot.fromEntity( + ShopCardEntity entity, { + this.stock, + }) : productServiceUniqueCode = entity.productServiceUniqueCode, + productName = entity.productName, + productPic = entity.productPic, + productPrice = entity.productPrice, + nature = entity.nature, + productNumber = entity.productNumber, + productWeight = entity.productWidget, + totalPrice = entity.totalPrice, + isPriceIncreased = entity.isPriceIncreased; + + /// 商品跨端唯一编码,仅用于区分购物车条目,不在弹框内修改。 + final String productServiceUniqueCode; + + /// 打开弹框时的商品名称快照。 + final String productName; + + /// 打开弹框时的商品图片地址;空字符串表示没有可展示图片。 + final String productPic; + + /// 商品单价,单位为分。 + final int productPrice; + + /// 商品计量性质,用于区分按件和称重展示。 + final ProductNature nature; + + /// 按件商品数量;称重商品通常为零。 + final int productNumber; + + /// 称重商品重量,单位沿用现有购物车与电子秤契约。 + final double productWeight; + + /// 当前条目合计金额,单位为分。 + final int totalPrice; + + /// 当前条目是否包含既有涨价规则结果。 + final bool isPriceIncreased; + + /// 商品本地库存快照;为空表示同步接口没有返回库存,仅供收银员参考而不限制加购。 + final int? stock; + + /// 收银汇总中计入的商品件数,称重条目按一个购物车条目计算。 + int get displayCount => productNumber > 0 ? productNumber : 1; +} + +/// 收银支付弹框打开瞬间的不可变交易快照。 +/// +/// 购物车实体会被转换为只读条目,金额继续使用项目既有的整数分契约。 +/// 适配说明:源自消费机项目的快照含食堂套餐组成(combo)支持,本超市项目 +/// 无套餐数据模型,套餐字段在移植时移除。 +class CashierCheckoutSnapshot { + /// 创建一份仅供弹框展示和支付上下文使用的不可变快照。 + CashierCheckoutSnapshot.capture({ + required List cartItems, + required this.originalAmount, + required this.discountAmount, + required this.surchargeAmount, + required this.payableAmount, + this.cartTitle = '当前购物车', + Map stockByProductCode = const {}, + }) : items = List.unmodifiable( + cartItems.map( + (entity) => CashierCheckoutItemSnapshot.fromEntity( + entity, + stock: stockByProductCode[entity.productServiceUniqueCode], + ), + ), + ); + + /// 购物车区域标题,食堂和超市入口可按场景覆盖。 + final String cartTitle; + + /// 打开弹框时复制的只读购物车条目。 + final List items; + + /// 商品原价合计,单位为分。 + final int originalAmount; + + /// 优惠合计,单位为分;零表示当前交易没有优惠。 + final int discountAmount; + + /// 附加费用合计,单位为分;可用于食堂餐费等既有费用。 + final int surchargeAmount; + + /// 弹框打开时的应付金额,单位为分。 + final int payableAmount; + + /// 当前快照包含的商品数量,称重商品按一个条目计算。 + int get productCount => + items.fold(0, (total, item) => total + item.displayCount); + + /// 当前快照是否没有任何购物车条目。 + bool get isEmpty => items.isEmpty; +} diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 7e889cb..cd4e196 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -35,8 +35,8 @@ class RouteNames { /// 设置(终端二级路由) static const setting = '$terminal/setting'; - /// 定时任务日志(根路由,全屏覆盖终端外壳) - static const settingSyncLogs = '/setting/sync-logs'; + /// 定时任务日志(终端二级路由,从设置页压栈打开) + static const settingSyncLogs = '$terminal/setting/sync-logs'; /// 饭卡(终端二级路由) static const cardActive = '$terminal/card_active'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index c4dc079..2f9b14d 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -31,16 +31,5 @@ class RoutePages { name: RouteNames.terminal, page: () => const TerminalPage(), ), - // 定时任务日志从设置页全屏打开,保留根路由与 Get.arguments 传参方式。 - GetPage( - name: RouteNames.settingSyncLogs, - page: () => const ScheduledTaskLogsPage(), - binding: BindingsBuilder(() { - Get.lazyPut( - () => ScheduledTaskLogsController(Get.arguments as ScheduledTaskType), - fenix: true, - ); - }), - ), ]; } diff --git a/lib/common/utils/app/control/app_theme_control.dart b/lib/common/utils/app/control/app_theme_control.dart index 0f833ad..9ae9972 100644 --- a/lib/common/utils/app/control/app_theme_control.dart +++ b/lib/common/utils/app/control/app_theme_control.dart @@ -10,6 +10,15 @@ class AppThemeControl extends GetxController { /// 字体大小 RxDouble sizeScalar = SettingGlobalFontSize.xl3.fontSize.obs; + /// 按钮圆角 + final buttonRadius = SettingButtonRadius.standard.obs; + + /// 卡片圆角 + final cardRadius = SettingCardRadius.standard.obs; + + /// 按钮尺寸档位 + final buttonSize = SettingButtonSize.standard.obs; + @override void onInit() async { super.onInit(); @@ -20,9 +29,36 @@ class AppThemeControl extends GetxController { if (appSetting.globalFontSize != null) { sizeScalar.value = appSetting.globalFontSize!.fontSize; } + buttonRadius.value = appSetting.buttonRadius; + cardRadius.value = appSetting.cardRadius; + buttonSize.value = appSetting.buttonSize; + _applyMetrics(); } void setSizeScalar(SettingGlobalFontSize fontSize) { sizeScalar.value = fontSize.fontSize; } + + void setButtonRadius(SettingButtonRadius value) { + buttonRadius.value = value; + _applyMetrics(); + } + + void setCardRadius(SettingCardRadius value) { + cardRadius.value = value; + _applyMetrics(); + } + + void setButtonSize(SettingButtonSize value) { + buttonSize.value = value; + _applyMetrics(); + } + + void _applyMetrics() { + XfjDesignTokens.applyThemeMetrics( + cardRadius: cardRadius.value.radius, + controlRadius: buttonRadius.value.radius, + buttonSizeScale: buttonSize.value.scale, + ); + } } diff --git a/lib/common/utils/manage/mq_manager.dart b/lib/common/utils/manage/mq_manager.dart index 4dda38e..71c0745 100644 --- a/lib/common/utils/manage/mq_manager.dart +++ b/lib/common/utils/manage/mq_manager.dart @@ -121,6 +121,10 @@ class MqManager { } } + /// 当前连接是否已建立;供基本信息页等界面展示实时连接状态。 + static bool get isConnected => + state == MqttConnectionState.connected && mqttClient != null; + static Future subscribeCurrentTopic() async { if (ObjectUtil.isEmpty(mqttClient)) return; final config = await validate(); diff --git a/lib/common/widgets/app/product_type_card.dart b/lib/common/widgets/app/product_type_card.dart new file mode 100644 index 0000000..36b7e1d --- /dev/null +++ b/lib/common/widgets/app/product_type_card.dart @@ -0,0 +1,76 @@ +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'; + +/// 商品类型标签:套餐为主色实底胶囊加图层图标,单品为浅灰描边胶囊加餐具图标。 +/// +/// 图标、底色和字色三重差异避免仅靠颜色传达类型,供查询菜单核销弹框与 +/// 收银结算弹框共用,保证两个场景的类型语义完全一致。 +class ProductTypeTag extends StatelessWidget { + /// 创建商品类型标签。 + const ProductTypeTag({super.key, this.isCombo = false}); + + /// 是否为组合套餐;false 表示单品。 + final bool isCombo; + + @override + Widget build(BuildContext context) { + return [ + FIcon( + isCombo ? FAssets.icons.layers : FAssets.icons.utensils, + color: isCombo ? Colors.white : XfjDesignTokens.muted, + size: 12.sp, + ), + Text( + isCombo ? '套餐' : '单品', + style: FTheme.of(context).typography.xs.copyWith( + color: isCombo ? Colors.white : XfjDesignTokens.muted, + fontWeight: FontWeight.w800, + ), + ), + ] + .toRowSpace(space: 3.w, mainAxisSize: MainAxisSize.min) + .padding(horizontal: 9.w, vertical: 3.h) + .decorated( + color: isCombo ? XfjDesignTokens.primary : XfjDesignTokens.control, + border: Border.all( + color: isCombo ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(20.w), + ); + } +} + +/// 商品类型卡片皮肤:套餐“蓝底蓝框”、单品“灰底灰框”。 +/// +/// 背景色与边框双重差异让操作员不读标签即可扫视分辨商品类型;内容边距 +/// 由调用方自行控制,皮肤只负责底色、边框和圆角。 +class ProductTypeCard extends StatelessWidget { + /// 创建按商品类型着色的卡片容器。 + const ProductTypeCard({ + super.key, + this.isCombo = false, + required this.child, + }); + + /// 是否为组合套餐;false 表示单品。 + final bool isCombo; + + /// 卡片内容,通常为主商品行及其可展开的套餐组成列表。 + final Widget child; + + @override + Widget build(BuildContext context) { + return child.decorated( + color: isCombo ? XfjDesignTokens.primarySoft : XfjDesignTokens.canvas, + border: Border.all( + color: isCombo + ? XfjDesignTokens.primary.withValues(alpha: 0.45) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index ea5d337..4dafba3 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -219,7 +219,8 @@ class TerminalBrand extends StatelessWidget { 'PJ', textAlign: TextAlign.center, style: theme.typography.xl6.copyWith( - color: const Color(0xFF1268E8), + // 与顶栏标题保持同一主题墨色,不使用单独的品牌蓝。 + color: XfjDesignTokens.ink, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, ), @@ -659,7 +660,8 @@ class TerminalSleepOverlay extends StatelessWidget { Text( 'PJ', style: theme.typography.xl2.copyWith( - color: XfjDesignTokens.primary, + // 与品牌行其余文字保持同一主题墨色,不再单独使用品牌蓝。 + color: XfjDesignTokens.ink, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, ), diff --git a/lib/common/widgets/face/face_camera_preview.dart b/lib/common/widgets/face/face_camera_preview.dart new file mode 100644 index 0000000..1bb00cc --- /dev/null +++ b/lib/common/widgets/face/face_camera_preview.dart @@ -0,0 +1,210 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/common/widgets/face/face_recognition_preview_layer.dart'; + +/// 人脸摄像头画面组件,只负责 Texture、人脸框、摄像头状态和空画面提示。 +class FaceCameraPreview extends StatelessWidget { + /// 创建一个不持有识别会话和插件资源的摄像头展示区域。 + const FaceCameraPreview({ + super.key, + required this.texture, + required this.event, + required this.status, + this.statusText, + this.statusColor, + this.scale = 1, + }); + + /// 原生创建的预览纹理;为空时展示摄像头准备状态。 + final FaceRecognitionPreviewTexture? texture; + + /// 最近一次原生识别事件,用于绘制人脸框和方向信息。 + final FaceRecognitionSessionEvent? event; + + /// 当前识别状态,用于展示文字和非颜色状态标识。 + final FaceRecognitionSessionStatus status; + + /// 业务页面覆盖的摄像头状态文案;为空时沿用原生会话状态文案。 + final String? statusText; + + /// 业务页面覆盖的摄像头状态颜色;为空时沿用原生会话状态颜色。 + final Color? statusColor; + + /// 宿主 Engine 提供的尺寸缩放系数,副屏不得依赖主屏 ScreenUtil 全局状态。 + final double scale; + + /// 构建固定比例的摄像头画面和状态浮层。 + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFF102635), + child: [ + if (texture != null) + FaceRecognitionPreviewLayer( + texture: texture!, + event: event, + labelScale: scale, + ) + else + _FaceCameraEmptyState(status: status, scale: scale), + _FaceCameraStatusPill( + status: status, + text: statusText, + color: statusColor, + scale: scale, + ).positioned( + left: 18 * scale, + top: 18 * scale, + ), + _FaceCameraResolutionLabel( + texture: texture, + scale: scale, + ).positioned( + right: 18 * scale, + bottom: 16 * scale, + ), + ].toStack(fit: StackFit.expand), + ); + } +} + +/// 摄像头 Texture 尚未创建时的明确等待或失败提示。 +class _FaceCameraEmptyState extends StatelessWidget { + /// 创建与当前识别状态匹配的空画面。 + const _FaceCameraEmptyState({required this.status, required this.scale}); + + /// 决定展示加载反馈还是设备异常提示的会话状态。 + final FaceRecognitionSessionStatus status; + + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + + /// 构建不会造成画面尺寸跳变的居中状态。 + @override + Widget build(BuildContext context) { + final failed = status == FaceRecognitionSessionStatus.failed; + return [ + if (failed) + Icon( + Icons.videocam_off_rounded, + color: Colors.white, + size: 42 * scale, + ) + else + SizedBox( + width: 36 * scale, + height: 36 * scale, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + SizedBox(height: 12 * scale), + Text( + failed ? '摄像头画面暂不可用' : '正在准备摄像头画面', + style: TextStyle( + color: Colors.white, + fontSize: 16 * scale, + fontWeight: FontWeight.w700, + ), + ), + ].toColumn(mainAxisSize: MainAxisSize.min).center(); + } +} + +/// 摄像头左上角的文字状态标识,确保状态不只依赖红绿颜色表达。 +class _FaceCameraStatusPill extends StatelessWidget { + /// 创建当前识别阶段的状态标识。 + const _FaceCameraStatusPill({ + required this.status, + required this.text, + required this.color, + required this.scale, + }); + + /// 当前原生识别会话状态。 + final FaceRecognitionSessionStatus status; + + /// 业务页面提供的可选状态文案。 + final String? text; + + /// 业务页面提供的可选语义颜色。 + final Color? color; + + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + + /// 构建包含状态点和文字的高对比标签。 + @override + Widget build(BuildContext context) { + final resolvedText = text ?? + switch (status) { + FaceRecognitionSessionStatus.initializing => '初始化中', + FaceRecognitionSessionStatus.success => '已识别', + FaceRecognitionSessionStatus.failed => '需要重试', + FaceRecognitionSessionStatus.cancelled => '已取消', + _ => '识别中', + }; + final resolvedColor = color ?? + switch (status) { + FaceRecognitionSessionStatus.failed || + FaceRecognitionSessionStatus.cancelled => + XfjDesignTokens.danger, + FaceRecognitionSessionStatus.initializing => XfjDesignTokens.warning, + _ => XfjDesignTokens.success, + }; + return [ + DecoratedBox( + decoration: BoxDecoration( + color: resolvedColor, + shape: BoxShape.circle, + ), + ).width(10 * scale).height(10 * scale), + SizedBox(width: 8 * scale), + Text( + resolvedText, + style: TextStyle( + color: Colors.white, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRow(mainAxisSize: MainAxisSize.min) + .padding(horizontal: 14 * scale, vertical: 9 * scale) + .decorated( + color: Colors.black.withValues(alpha: 0.62), + borderRadius: BorderRadius.circular(99), + ); + } +} + +/// 摄像头右下角的实际纹理分辨率标签。 +class _FaceCameraResolutionLabel extends StatelessWidget { + /// 创建分辨率说明;纹理未就绪时使用插件既有默认分辨率。 + const _FaceCameraResolutionLabel({ + required this.texture, + required this.scale, + }); + + /// 当前预览纹理元数据。 + final FaceRecognitionPreviewTexture? texture; + + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + + /// 构建不遮挡主要人脸区域的紧凑标签。 + @override + Widget build(BuildContext context) { + return Text( + '彩色摄像头 ${texture?.width ?? 1280} x ${texture?.height ?? 720}', + style: TextStyle(color: Colors.white, fontSize: 13 * scale), + ).padding(horizontal: 12 * scale, vertical: 8 * scale).decorated( + color: Colors.black.withValues(alpha: 0.62), + borderRadius: BorderRadius.circular(10), + ); + } +} diff --git a/lib/common/widgets/face/face_live_camera_preview.dart b/lib/common/widgets/face/face_live_camera_preview.dart new file mode 100644 index 0000000..154fe48 --- /dev/null +++ b/lib/common/widgets/face/face_live_camera_preview.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart'; +import 'package:pj_supermarket_cloud/common/widgets/face/face_camera_preview.dart'; + +/// 主副屏共用的常驻人脸摄像头预览。 +/// +/// 组件只持有当前 Flutter Engine 的 Texture 和原生状态订阅,不创建、结束或重试 +/// 业务识别 Session,因此查询结果展示的十秒内仍能保持实时画面。 +class FaceLiveCameraPreview extends StatefulWidget { + /// 创建跟随宿主页面生命周期的实时预览区域。 + const FaceLiveCameraPreview({ + super.key, + required this.statusText, + required this.statusColor, + this.scale = 1, + }); + + /// 查询状态对应的可见文案,避免原生空闲态被误解为仍在识别下一位。 + final String statusText; + + /// 查询状态对应的语义颜色,必须与文字同时表达状态。 + final Color statusColor; + + /// 宿主 Engine 自己计算的尺寸比例,默认按 1920 x 1080 设计尺寸一比一展示。 + final double scale; + + /// 创建当前 Flutter Engine 的 Texture 生命周期状态。 + @override + State createState() => _FaceLiveCameraPreviewState(); +} + +/// [FaceLiveCameraPreview] 的 Texture、原生事件和异常状态所有者。 +class _FaceLiveCameraPreviewState extends State { + /// 当前 Flutter Engine 持有的原生预览纹理。 + FaceRecognitionPreviewTexture? _texture; + + /// 最近一次原生人脸会话事件,用于方向、人脸框和设备状态渲染。 + FaceRecognitionSessionEvent? _event; + + /// 常驻原生会话事件订阅,页面销毁时必须取消。 + StreamSubscription? _eventSubscription; + + /// Texture 异步创建序号,用于释放页面销毁或重建后迟到的旧资源。 + int _textureRequestId = 0; + + /// 当前预览初始化失败信息;为空表示按 Texture 和原生状态正常展示。 + String? _previewError; + + /// 订阅原生状态并创建当前 Engine 独立的输出 Texture。 + @override + void initState() { + super.initState(); + _eventSubscription = FaceDiscernPlugs.sessionStream.listen( + _handleEvent, + onError: _handleEventError, + ); + unawaited(_loadInitialState()); + unawaited(_initializeTexture()); + } + + /// 读取挂载前已存在的热摄像头状态,避免页面刚进入时长期停留在初始化提示。 + Future _loadInitialState() async { + try { + final event = await FaceDiscernPlugs.getRecognitionState(); + // EventChannel 可能先送达真实检测帧;迟到的 MethodChannel 快照不含 faceBoxes, + // 只能用于尚未收到任何事件的首次占位,不能把已经显示的人脸框清空。 + if (mounted && _event == null) setState(() => _event = event); + } catch (_) { + // 后续 EventChannel 仍可恢复状态,首次快照失败不覆盖真实 Texture 画面。 + } + } + + /// 更新最近原生状态;致命失败后的冷重试需要替换已经失效的 Renderer 输出。 + void _handleEvent(FaceRecognitionSessionEvent event) { + if (!mounted || !_shouldAcceptEvent(event)) return; + final previousStatus = _event?.status; + final shouldReplace = + (previousStatus == FaceRecognitionSessionStatus.cold || + previousStatus == FaceRecognitionSessionStatus.failed) && + (event.status == FaceRecognitionSessionStatus.initializing || + event.status == FaceRecognitionSessionStatus.detecting); + setState(() { + _event = event; + _previewError = null; + }); + if (shouldReplace) unawaited(_initializeTexture(replace: true)); + } + + /// 按原生单调序号拒绝乱序快照,避免跨会话的旧空框覆盖较新的多人检测结果。 + bool _shouldAcceptEvent(FaceRecognitionSessionEvent event) { + final current = _event; + if (current == null) return true; + if (current.eventSequence > 0 && event.eventSequence > 0) { + if (event.eventSequence != current.eventSequence) { + return event.eventSequence > current.eventSequence; + } + // MethodChannel 快照不含框;允许相同序号的真实 EventChannel 事件补齐它。 + return current.faceBoxes.isEmpty && event.faceBoxes.isNotEmpty; + } + if (event.sessionId != current.sessionId) { + // 兼容旧版原生事件:活动识别期间不得让迟到的空闲资源快照清掉当前人脸框。 + return !(current.isActive && event.sessionId.isEmpty); + } + return event.revision >= current.revision; + } + + /// 将原生事件通道异常收敛为预览内提示,不中断主屏查询状态机。 + void _handleEventError(Object error) { + if (mounted) setState(() => _previewError = '摄像头状态连接异常,请检查设备'); + } + + /// 创建或替换预览 Texture,并释放异步迟到或已被替换的输出。 + Future _initializeTexture({bool replace = false}) async { + final requestId = ++_textureRequestId; + if (replace) { + final previous = _texture; + _texture = null; + if (previous != null) { + try { + await FaceDiscernPlugs.releasePreviewTexture(previous.textureId); + } catch (_) { + // 原生 Renderer 已冷释放时旧端点可能失效,仍要继续创建新 Texture 恢复画面。 + } + } + if (mounted) setState(() {}); + } + try { + final texture = await FaceDiscernPlugs.createPreviewTexture(); + if (!mounted || requestId != _textureRequestId) { + await FaceDiscernPlugs.releasePreviewTexture(texture.textureId); + return; + } + setState(() { + _texture = texture; + _previewError = null; + }); + } catch (_) { + // 已被新一轮 Texture 请求替代的旧异常不得覆盖新画面。 + if (mounted && requestId == _textureRequestId) { + setState(() => _previewError = '摄像头画面暂不可用,请检查设备'); + } + } + } + + /// 取消事件订阅并释放当前 Engine 的 Texture,不触碰业务识别 Session。 + @override + void dispose() { + _textureRequestId++; + unawaited(_eventSubscription?.cancel()); + final texture = _texture; + _texture = null; + if (texture != null) { + unawaited(FaceDiscernPlugs.releasePreviewTexture(texture.textureId)); + } + super.dispose(); + } + + /// 构建不会因 Session 暂停或结果展示而移除的实时画面。 + @override + Widget build(BuildContext context) { + final status = _previewError == null + ? _event?.status ?? FaceRecognitionSessionStatus.initializing + : FaceRecognitionSessionStatus.failed; + return Stack( + fit: StackFit.expand, + children: [ + FaceCameraPreview( + texture: _texture, + event: _event, + status: status, + statusText: widget.statusText, + statusColor: widget.statusColor, + scale: widget.scale, + ), + if (_previewError != null) + Center( + child: Text( + _previewError!, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16 * widget.scale, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} diff --git a/lib/common/widgets/face/face_recognition_panel.dart b/lib/common/widgets/face/face_recognition_panel.dart new file mode 100644 index 0000000..1163ff5 --- /dev/null +++ b/lib/common/widgets/face/face_recognition_panel.dart @@ -0,0 +1,486 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:pj_supermarket_cloud/common/models/plugs/face_discern_models.dart'; +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart'; +import 'package:pj_supermarket_cloud/common/widgets/face/face_camera_preview.dart'; + +/// 人脸识别面板的默认无人结果超时,超时后保留当前会话以允许原位重试。 +const Duration faceRecognitionPanelDefaultTimeout = Duration(seconds: 45); + +/// 人脸识别成功画面的默认冻结时长,沿用原业务成功展示语义。 +const Duration faceRecognitionPanelSuccessHold = Duration(seconds: 1); + +/// 宿主控制人脸面板取消动作的轻量句柄,不直接暴露 State 或原生插件。 +class FaceRecognitionPanelController { + /// 当前挂载面板提供的取消实现;面板销毁后会清空。 + Future Function()? _cancelCallback; + + /// 取消当前面板持有的识别会话;没有挂载面板时直接完成。 + Future cancel() async { + await _cancelCallback?.call(); + } + + /// 由面板挂载时绑定取消实现,禁止业务侧直接调用。 + void _attach(Future Function() callback) { + _cancelCallback = callback; + } + + /// 仅清除当前面板绑定,避免旧面板销毁时覆盖新面板句柄。 + void _detach(Future Function() callback) { + if (_cancelCallback == callback) _cancelCallback = null; + } +} + +/// 可嵌入独立弹框或收银工作区的人脸识别面板。 +/// +/// 面板持有事件订阅、Texture、超时和结果幂等;销毁时会停止仍属于自己的 +/// 会话并释放纹理,避免重复打开后累积监听器或继续消费旧识别结果。 +class FaceRecognitionPanel extends StatefulWidget { + /// 创建绑定到单个人脸会话句柄的识别面板。 + const FaceRecognitionPanel({ + super.key, + required this.service, + required this.handle, + required this.onRecognized, + this.controller, + this.onFailure, + this.onCancelled, + this.timeout = faceRecognitionPanelDefaultTimeout, + this.successHoldDuration = faceRecognitionPanelSuccessHold, + this.showCancelAction = true, + this.compact = false, + }); + + /// 应用级唯一人脸会话服务。 + final FaceRecognitionSessionService service; + + /// 当前面板独占处理的会话句柄。 + final FaceRecognitionSessionHandle handle; + + /// 成功冻结结束后的结果回调,每个面板生命周期最多调用一次。 + final ValueChanged onRecognized; + + /// 供宿主主动取消当前面板的可选控制器。 + final FaceRecognitionPanelController? controller; + + /// 插件、超时或识别失败回调;非终止失败仍允许用户原位重试。 + final ValueChanged? onFailure; + + /// 用户主动取消后的通知回调。 + final VoidCallback? onCancelled; + + /// 单轮识别超时时长;到期后显示可恢复提示但不自动关闭弹框。 + final Duration timeout; + + /// 成功结果在主屏保持冻结的时长。 + final Duration successHoldDuration; + + /// 是否在面板底部展示取消识别按钮。 + final bool showCancelAction; + + /// 是否使用收银工作区的紧凑间距。 + final bool compact; + + /// 创建识别订阅、Texture 和超时任务状态。 + @override + State createState() => _FaceRecognitionPanelState(); +} + +/// [FaceRecognitionPanel] 的资源和可见状态所有者。 +class _FaceRecognitionPanelState extends State { + /// 当前会话事件订阅,销毁面板时必须取消。 + StreamSubscription? _eventSubscription; + + /// 当前面板持有的原生预览纹理。 + FaceRecognitionPreviewTexture? _texture; + + /// 最近一次属于当前会话的识别事件。 + FaceRecognitionSessionEvent? _event; + + /// 当前可恢复或终止失败;为空表示按事件状态展示。 + FaceRecognitionFailure? _failure; + + /// 单轮超时计时器,重试时重新开始。 + Timer? _timeoutTimer; + + /// Texture 异步创建序号,用于释放迟到的旧纹理。 + int _textureRequestId = 0; + + /// 成功结果锁,防止 Future 和事件重复触发业务回调。 + bool _resultHandled = false; + + /// 主动取消锁,防止按钮、切换方式和销毁同时取消同一会话。 + bool _cancelling = false; + + /// 初始化事件监听、取消句柄、Texture 与超时任务。 + @override + void initState() { + super.initState(); + widget.controller?._attach(_cancel); + _eventSubscription = widget.handle.events.listen( + _handleEvent, + onError: _handleStreamError, + ); + _restartTimeout(); + unawaited(_initializeTexture()); + unawaited(_waitForResult()); + } + + /// 仅接收当前句柄事件,并把原生失败转换为面板内可恢复状态。 + void _handleEvent(FaceRecognitionSessionEvent event) { + if (!mounted || event.sessionId != widget.handle.sessionId) return; + final failure = event.status == FaceRecognitionSessionStatus.failed + ? FaceRecognitionFailure( + code: event.errorCode ?? 'RECOGNITION_FAILED', + message: event.message.isEmpty ? '人脸识别失败,请重试' : event.message, + ) + : null; + setState(() { + _event = event; + _failure = failure; + }); + if (failure != null) widget.onFailure?.call(failure); + } + + /// 将事件通道异常转为终止失败,宿主可据此结束当前业务流程。 + void _handleStreamError(Object error) { + _showFailure(const FaceRecognitionFailure( + code: 'SESSION_STREAM_ERROR', + message: '人脸识别服务连接异常,请重新发起识别', + )); + } + + /// 创建或替换预览 Texture,并释放异步迟到或已被替换的资源。 + Future _initializeTexture({bool replace = false}) async { + final requestId = ++_textureRequestId; + if (replace) { + final previous = _texture; + _texture = null; + if (previous != null) { + await FaceDiscernPlugs.releasePreviewTexture(previous.textureId); + } + if (mounted) setState(() {}); + } + try { + final texture = await FaceDiscernPlugs.createPreviewTexture(); + if (!mounted || requestId != _textureRequestId) { + await FaceDiscernPlugs.releasePreviewTexture(texture.textureId); + return; + } + setState(() => _texture = texture); + } catch (_) { + _showFailure(const FaceRecognitionFailure( + code: 'PREVIEW_TEXTURE_FAILED', + message: '无法显示摄像头画面,请检查设备后重试', + )); + } + } + + /// 等待服务层的幂等结果,冻结三秒后结束原生会话并通知宿主。 + Future _waitForResult() async { + try { + final result = await widget.handle.result; + if (!mounted || _resultHandled || _cancelling) return; + _resultHandled = true; + _timeoutTimer?.cancel(); + if (_event?.status != FaceRecognitionSessionStatus.success) { + setState(() => _failure = null); + } + await Future.delayed(widget.successHoldDuration); + if (!mounted || _cancelling) return; + if (widget.service.owns(widget.handle.sessionId)) { + await widget.service.finish(); + } + if (mounted) widget.onRecognized(result); + } on FaceRecognitionFailure catch (failure) { + if (!mounted || _cancelling) return; + if (failure.cancelled) { + widget.onCancelled?.call(); + return; + } + _showFailure(failure); + } catch (_) { + _showFailure(const FaceRecognitionFailure( + code: 'RECOGNITION_FAILED', + message: '人脸识别异常,请重新识别', + )); + } + } + + /// 重置单轮超时;超时不结束会话,使原生 retry 能继续复用热摄像头。 + void _restartTimeout() { + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(widget.timeout, () { + _showFailure(const FaceRecognitionFailure( + code: 'RECOGNITION_TIMEOUT', + message: '暂未识别到人员,请调整距离或角度后重试', + )); + }); + } + + /// 展示结构化失败并同步通知宿主,不因 Toast 或全局 Loading 遮挡工作区。 + void _showFailure(FaceRecognitionFailure failure) { + if (!mounted || _cancelling || _resultHandled) return; + _timeoutTimer?.cancel(); + setState(() => _failure = failure); + widget.onFailure?.call(failure); + } + + /// 原位重试当前会话并替换 Texture,避免旧预览资源继续占用。 + Future _retry() async { + if (_cancelling || _resultHandled) return; + await HapticFeedback.mediumImpact(); + setState(() => _failure = null); + try { + await widget.service.retry(); + await _initializeTexture(replace: true); + _restartTimeout(); + } on FaceRecognitionFailure catch (failure) { + _showFailure(failure); + } catch (_) { + _showFailure(const FaceRecognitionFailure( + code: 'RECOGNITION_RETRY_FAILED', + message: '重新识别失败,请检查摄像头后再试', + )); + } + } + + /// 主动取消当前会话,并在服务释放后通知宿主关闭或切换工作区。 + Future _cancel() async { + if (_cancelling || _resultHandled) return; + _cancelling = true; + _timeoutTimer?.cancel(); + await HapticFeedback.mediumImpact(); + if (widget.service.owns(widget.handle.sessionId)) { + await widget.service.cancel(); + } + if (mounted) widget.onCancelled?.call(); + } + + /// 取消监听、释放 Texture,并停止仍属于当前面板的会话。 + @override + void dispose() { + widget.controller?._detach(_cancel); + _timeoutTimer?.cancel(); + unawaited(_eventSubscription?.cancel()); + final texture = _texture; + _texture = null; + if (texture != null) { + unawaited(FaceDiscernPlugs.releasePreviewTexture(texture.textureId)); + } + if (widget.service.owns(widget.handle.sessionId)) { + unawaited(_resultHandled + ? widget.service.finish() + : widget.service.cancel(message: '识别界面已关闭')); + } + super.dispose(); + } + + /// 构建摄像头、状态、隐私说明和可恢复操作区。 + @override + Widget build(BuildContext context) { + final status = _failure == null + ? _event?.status ?? FaceRecognitionSessionStatus.initializing + : FaceRecognitionSessionStatus.failed; + final gap = widget.compact ? 12.h : 16.h; + return [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FaceCameraPreview( + texture: _texture, + event: _event, + status: status, + scale: 1.w, + ), + ), + ), + SizedBox(height: gap), + _FaceRecognitionStatusCard( + status: status, + message: _failure?.message ?? _event?.message, + compact: widget.compact, + ), + SizedBox(height: gap), + _FaceRecognitionPrivacyHint(compact: widget.compact), + if (_failure != null || widget.showCancelAction) ...[ + SizedBox(height: gap), + [ + if (widget.showCancelAction) + _FacePanelButton( + label: '取消识别', + onTap: _cancel, + outlined: true, + ).expanded(), + if (widget.showCancelAction && _failure != null) + SizedBox(width: 12.w), + if (_failure != null) + _FacePanelButton(label: '重新识别', onTap: _retry).expanded(), + ].toRow(), + ], + ].toColumn(mainAxisSize: MainAxisSize.min); + } +} + +/// 人脸面板的状态说明卡,使用图标与文字共同表达成功和失败。 +class _FaceRecognitionStatusCard extends StatelessWidget { + /// 创建当前识别阶段的说明卡。 + const _FaceRecognitionStatusCard({ + required this.status, + required this.message, + required this.compact, + }); + + /// 当前面板识别状态。 + final FaceRecognitionSessionStatus status; + + /// 原生或本地超时生成的可排查说明。 + final String? message; + + /// 是否使用收银工作区紧凑间距。 + final bool compact; + + /// 构建带有恢复方向的状态反馈。 + @override + Widget build(BuildContext context) { + final failed = status == FaceRecognitionSessionStatus.failed; + final success = status == FaceRecognitionSessionStatus.success; + final color = failed + ? XfjDesignTokens.danger + : success + ? XfjDesignTokens.success + : XfjDesignTokens.primary; + final title = failed + ? '人脸识别未完成' + : success + ? '识别成功' + : status == FaceRecognitionSessionStatus.initializing + ? '正在启动摄像头' + : '请顾客正对摄像头'; + final icon = failed + ? Icons.error_outline_rounded + : success + ? Icons.check_circle_rounded + : Icons.face_retouching_natural_rounded; + return [ + Icon(icon, color: color, size: compact ? 30.w : 36.w), + SizedBox(width: 14.w), + [ + Text( + title, + style: TextStyle( + color: color, + fontSize: compact ? 17.sp : 19.sp, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 3.h), + Text( + message?.trim().isNotEmpty == true + ? message!.trim() + : '保持面部完整出现在识别框内', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(all: compact ? 14.w : 18.w) + .decorated( + color: color.withValues(alpha: 0.08), + border: Border.all(color: color.withValues(alpha: 0.26)), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 人脸画面使用范围和识别距离提示。 +class _FaceRecognitionPrivacyHint extends StatelessWidget { + /// 创建紧凑或常规密度的隐私提示。 + const _FaceRecognitionPrivacyHint({required this.compact}); + + /// 是否使用收银工作区紧凑间距。 + final bool compact; + + /// 构建不会与主状态争夺层级的次要说明。 + @override + Widget build(BuildContext context) { + return [ + Icon( + Icons.face_retouching_natural_rounded, + color: XfjDesignTokens.primary, + size: 22.w, + ), + SizedBox(width: 10.w), + Text( + '建议距离 50-80cm,保持面部完整可见', + style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp), + ).expanded(), + Text( + '画面仅用于本次识别', + style: TextStyle(color: XfjDesignTokens.muted, fontSize: 13.sp), + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 14.w, vertical: compact ? 10.h : 12.h) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 人脸面板底部的固定高度操作按钮。 +class _FacePanelButton extends StatelessWidget { + /// 创建主操作或描边操作按钮。 + const _FacePanelButton({ + required this.label, + required this.onTap, + this.outlined = false, + }); + + /// 按钮文字。 + final String label; + + /// 按钮异步动作;执行期间由上层幂等锁避免重复操作。 + final Future Function() onTap; + + /// 是否使用取消操作的描边样式。 + final bool outlined; + + /// 构建至少 56dp 高的触控区域。 + @override + Widget build(BuildContext context) { + return Material( + color: outlined ? XfjDesignTokens.surface : XfjDesignTokens.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + side: BorderSide( + color: outlined ? XfjDesignTokens.danger : XfjDesignTokens.primary, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: outlined ? XfjDesignTokens.danger : Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w700, + ), + ).center().height(56.h), + ), + ); + } +} diff --git a/lib/common/widgets/face/face_recognition_preview_dialog.dart b/lib/common/widgets/face/face_recognition_preview_dialog.dart new file mode 100644 index 0000000..66c6287 --- /dev/null +++ b/lib/common/widgets/face/face_recognition_preview_dialog.dart @@ -0,0 +1,138 @@ +import 'dart:math' as math; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 使用全局 [AppDialog] 展示可复用人脸识别面板。 +/// +/// 成功画面由面板保持三秒后返回,预订核验等独立场景继续沿用原返回值。 +Future showFaceRecognitionPreviewDialog({ + required FaceRecognitionSessionService service, + required FaceRecognitionSessionHandle handle, +}) { + final context = Get.context!; + return showAppDialog( + context: context, + barrierDismissible: false, + canPop: false, + barrierLabel: '人脸识别进行中', + builder: (_) => FaceRecognitionPreviewDialog( + service: service, + handle: handle, + ), + ); +} + +/// 独立人脸识别弹框,只负责用 [AppDialog] 包装 [FaceRecognitionPanel]。 +class FaceRecognitionPreviewDialog extends StatefulWidget { + /// 创建绑定到当前识别会话的独立预览弹框。 + const FaceRecognitionPreviewDialog({ + super.key, + required this.service, + required this.handle, + }); + + /// 应用级人脸识别会话服务。 + final FaceRecognitionSessionService service; + + /// 当前独立弹框处理的会话句柄。 + final FaceRecognitionSessionHandle handle; + + /// 创建弹框关闭和面板控制状态。 + @override + State createState() => + _FaceRecognitionPreviewDialogState(); +} + +/// [FaceRecognitionPreviewDialog] 的关闭幂等状态。 +class _FaceRecognitionPreviewDialogState + extends State { + /// 用于标题栏关闭按钮取消当前面板会话。 + final FaceRecognitionPanelController _panelController = + FaceRecognitionPanelController(); + + /// 防止成功、取消和终止失败重复关闭同一路由。 + bool _closing = false; + + /// 取消当前识别并关闭独立弹框。 + Future _cancel() async { + if (_closing) return; + _closing = true; + await _panelController.cancel(); + if (mounted) Navigator.of(context).pop(); + } + + /// 返回唯一识别结果;面板已经完成成功冻结和会话释放。 + void _recognized(FaceStream result) { + if (_closing || !mounted) return; + _closing = true; + Navigator.of(context).pop(result); + } + + /// 用户在面板内取消时关闭当前路由。 + void _cancelled() { + if (_closing || !mounted) return; + _closing = true; + Navigator.of(context).pop(); + } + + /// 终止型错误无法原位重试时关闭弹框,由调用方展示业务级恢复提示。 + void _failed(FaceRecognitionFailure failure) { + if (!failure.terminatesSession || _closing || !mounted) return; + _closing = true; + Navigator.of(context).pop(); + } + + /// 构建接近原预览尺寸、受当前 SafeArea 高度约束的全局弹框。 + @override + Widget build(BuildContext context) { + final viewport = MediaQuery.sizeOf(context); + final width = math.min(760.w, viewport.width - 32); + final height = math.min(900.h, viewport.height - 32); + return AppDialog( + title: const Text('人脸识别'), + subtitle: const Text('主屏实时预览'), + leading: DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.videocam_rounded, + color: XfjDesignTokens.primary, + size: 26.w, + ).center().width(46.w).height(46.h), + ), + headerTrailing: Text( + '副屏同步引导中', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 12.w, vertical: 8.h).decorated( + color: const Color(0xFFECFBF3), + borderRadius: BorderRadius.circular(99), + ), + width: width, + maxWidth: width, + height: height, + draggable: true, + onClose: _cancel, + semanticLabel: '人脸识别实时预览弹框', + content: SingleChildScrollView( + child: FaceRecognitionPanel( + service: widget.service, + handle: widget.handle, + controller: _panelController, + onRecognized: _recognized, + onFailure: _failed, + onCancelled: _cancelled, + ), + ), + ); + } +} diff --git a/lib/common/widgets/face/face_recognition_preview_layer.dart b/lib/common/widgets/face/face_recognition_preview_layer.dart new file mode 100644 index 0000000..17e3810 --- /dev/null +++ b/lib/common/widgets/face/face_recognition_preview_layer.dart @@ -0,0 +1,320 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; + +/// 主副屏共用的人脸 Texture 与跟踪框图层。 +/// +/// 原生只上报 NV21 原始坐标;本组件对画面和人脸框应用完全相同的镜像、旋转, +/// 避免设备方向补偿后出现画面正常但框位置相反的问题。 +class FaceRecognitionPreviewLayer extends StatelessWidget { + /// 创建与原生 Texture 共用方向参数的人脸预览图层。 + const FaceRecognitionPreviewLayer({ + super.key, + required this.texture, + required this.event, + this.labelScale = 1, + }); + + /// 当前 Flutter Engine 持有的原生预览纹理。 + final FaceRecognitionPreviewTexture texture; + + /// 最近一帧会话状态;为空时只展示 Texture,不绘制人脸框。 + final FaceRecognitionSessionEvent? event; + + /// 主副屏标签缩放系数,不改变人脸框的坐标与面积判断。 + final double labelScale; + + /// 叠加方向一致的纹理和人脸框画布。 + @override + Widget build(BuildContext context) { + final rotation = event?.rotation ?? texture.rotation; + final mirror = event?.mirror ?? texture.mirror; + return Stack( + fit: StackFit.expand, + children: [ + _OrientedTexture( + textureId: texture.textureId, + rotation: rotation, + mirror: mirror, + ), + CustomPaint( + painter: FaceRecognitionBoxPainter( + boxes: event?.faceBoxes ?? const [], + rotation: rotation, + mirror: mirror, + livenessEnabled: event?.livenessEnabled ?? false, + keepMaxFace: event?.keepMaxFace ?? false, + similarityThreshold: event?.similarityThreshold ?? 0.8, + labelScale: labelScale, + ), + ), + ], + ); + } +} + +/// 对原生 Texture 应用与人脸框一致的旋转和镜像。 +class _OrientedTexture extends StatelessWidget { + /// 创建只负责方向变换的纹理组件。 + const _OrientedTexture({ + required this.textureId, + required this.rotation, + required this.mirror, + }); + + /// 当前 Flutter Engine 内的 Texture 编号。 + final int textureId; + + /// 纹理顺时针旋转角度,单位为度。 + final int rotation; + + /// 是否水平镜像纹理。 + final bool mirror; + + /// 构建镜像后再按四分之一圈旋转的预览纹理。 + @override + Widget build(BuildContext context) { + Widget child = Texture(textureId: textureId); + if (mirror) { + child = Transform( + alignment: Alignment.center, + transform: Matrix4.diagonal3Values(-1, 1, 1), + child: child, + ); + } + return RotatedBox(quarterTurns: _quarterTurns(rotation), child: child); + } +} + +/// 绘制真实相似度、画面面积占比和当前识别策略。 +/// +/// 面积占比来自检测框归一化面积,不是相似度;仅识别最大脸时,最大面积框 +/// 使用绿色并显示“识别目标”,其他框使用红色并显示“非识别目标”,避免只靠颜色传达状态。 +class FaceRecognitionBoxPainter extends CustomPainter { + /// 创建人脸框绘制器。 + const FaceRecognitionBoxPainter({ + required this.boxes, + required this.rotation, + required this.mirror, + required this.livenessEnabled, + required this.keepMaxFace, + required this.similarityThreshold, + this.labelScale = 1, + }); + + /// 当前帧检测到的全部归一化人脸框。 + final List boxes; + + /// 预览画面顺时针旋转角度,单位为度。 + final int rotation; + + /// 预览画面是否水平镜像。 + final bool mirror; + + /// 当前识别配置是否启用活体检测。 + final bool livenessEnabled; + + /// 当前识别配置是否只允许面积最大的人脸参与识别。 + final bool keepMaxFace; + + /// 人脸比对成功阈值,范围为 0 到 1。 + final double similarityThreshold; + + /// 标签和描边缩放系数。 + final double labelScale; + + /// 当前帧面积最大的人脸下标;没有人脸时为空,面积相同时保持原生列表中的第一张。 + int? get primaryFaceIndex { + if (boxes.isEmpty) return null; + var largestIndex = 0; + var largestArea = boxes.first.normalizedArea; + for (var index = 1; index < boxes.length; index++) { + final area = boxes[index].normalizedArea; + if (area > largestArea) { + largestIndex = index; + largestArea = area; + } + } + return largestIndex; + } + + /// 返回指定人脸框的语义颜色,供绘制与定向测试共用同一判断。 + /// + /// [index] 必须是 [boxes] 中的有效下标。 + Color colorFor(int index) { + final box = boxes[index]; + if (keepMaxFace) { + return index == primaryFaceIndex + ? XfjDesignTokens.success + : XfjDesignTokens.danger; + } + final passed = box.similarity != null && + box.similarity! >= similarityThreshold && + (!livenessEnabled || box.livenessStatus == 'alive'); + final failed = + box.recognizeStatus == 'failed' || box.livenessStatus == 'notAlive'; + return failed + ? XfjDesignTokens.danger + : passed + ? XfjDesignTokens.success + : XfjDesignTokens.primary; + } + + /// 返回指定人脸框的可见标签,明确区分面积占比与人脸相似度。 + /// + /// [index] 必须是 [boxes] 中的有效下标。 + String labelFor(int index) { + final box = boxes[index]; + final mode = livenessEnabled ? '人脸识别 + 活体' : '人脸识别'; + final similarity = box.similarity == null + ? _recognizeText(box.recognizeStatus) + : '相似度 ${(box.similarity! * 100).toStringAsFixed(1)}%'; + final liveness = + livenessEnabled ? ' · ${_livenessText(box.livenessStatus)}' : ''; + if (!keepMaxFace) { + return '$mode $similarity$liveness'; + } + final screenShare = (box.normalizedArea * 100).toStringAsFixed(1); + if (index == primaryFaceIndex) { + return '识别目标 · 占画面 $screenShare%\n$mode $similarity$liveness'; + } + final recentSimilarity = box.similarity == null ? '' : ' · 最近$similarity'; + return '非识别目标 · 占画面 $screenShare%\n不参与识别$recentSimilarity'; + } + + /// 绘制所有检测框及高对比文字标签。 + @override + void paint(Canvas canvas, Size size) { + final strokeWidth = 4 * labelScale; + for (var index = 0; index < boxes.length; index++) { + final box = boxes[index]; + final rect = _transformRect(box, size, rotation, mirror); + final color = colorFor(index); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..color = color; + canvas.drawRRect( + RRect.fromRectAndRadius(rect, Radius.circular(18 * labelScale)), + paint, + ); + _paintLabel(canvas, size, rect, labelFor(index), color); + } + } + + /// 在人脸框上方或下方绘制最多两行标签,并限制标签不超出画布。 + void _paintLabel( + Canvas canvas, + Size size, + Rect rect, + String label, + Color color, + ) { + final textPainter = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + color: Colors.white, + fontSize: 14 * labelScale, + fontWeight: FontWeight.w700, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 2, + )..layout(maxWidth: math.max(80, size.width - 24 * labelScale)); + final horizontalPadding = 10 * labelScale; + final verticalPadding = 6 * labelScale; + final labelSize = Size( + textPainter.width + horizontalPadding * 2, + textPainter.height + verticalPadding * 2, + ); + final left = rect.left + .clamp( + 0.0, + math.max(0.0, size.width - labelSize.width), + ) + .toDouble(); + final preferredTop = rect.top - labelSize.height - 6 * labelScale; + final top = preferredTop >= 0 + ? preferredTop + : math.min( + size.height - labelSize.height, rect.bottom + 6 * labelScale); + final labelRect = + Rect.fromLTWH(left, top, labelSize.width, labelSize.height); + canvas.drawRRect( + RRect.fromRectAndRadius(labelRect, Radius.circular(8 * labelScale)), + Paint() + ..color = Color.alphaBlend(Colors.black.withValues(alpha: 0.36), color), + ); + textPainter.paint( + canvas, + Offset(left + horizontalPadding, top + verticalPadding), + ); + } + + /// 当人脸数据、方向或识别策略变化时请求重绘。 + @override + bool shouldRepaint(covariant FaceRecognitionBoxPainter oldDelegate) { + return oldDelegate.boxes != boxes || + oldDelegate.rotation != rotation || + oldDelegate.mirror != mirror || + oldDelegate.livenessEnabled != livenessEnabled || + oldDelegate.keepMaxFace != keepMaxFace || + oldDelegate.similarityThreshold != similarityThreshold || + oldDelegate.labelScale != labelScale; + } +} + +/// 将原始归一化人脸框按 Texture 同样的旋转和镜像规则映射到画布。 +Rect _transformRect( + FaceRecognitionFaceBox box, + Size size, + int rotation, + bool mirror, +) { + final points = [ + Offset(box.left, box.top), + Offset(box.right, box.top), + Offset(box.right, box.bottom), + Offset(box.left, box.bottom), + ].map((point) => _transformPoint(point, rotation, mirror)).toList(); + final left = points.map((point) => point.dx).reduce(math.min) * size.width; + final right = points.map((point) => point.dx).reduce(math.max) * size.width; + final top = points.map((point) => point.dy).reduce(math.min) * size.height; + final bottom = points.map((point) => point.dy).reduce(math.max) * size.height; + return Rect.fromLTRB(left, top, right, bottom); +} + +/// 转换单个归一化坐标点,旋转和镜像均保持面积不变。 +Offset _transformPoint(Offset point, int rotation, bool mirror) { + final x = mirror ? 1 - point.dx : point.dx; + final y = point.dy; + return switch (_quarterTurns(rotation)) { + 1 => Offset(1 - y, x), + 2 => Offset(1 - x, 1 - y), + 3 => Offset(y, 1 - x), + _ => Offset(x, y), + }; +} + +/// 将任意角度归一为 Flutter [RotatedBox] 使用的四分之一圈数量。 +int _quarterTurns(int rotation) => ((rotation % 360) / 90).round() % 4; + +/// 将原生特征提取状态转换为摄像头框内的短文案。 +String _recognizeText(String status) => switch (status) { + 'comparing' => '比对中', + 'success' => '已通过', + 'failed' => '未通过', + _ => '检测中', + }; + +/// 将原生活体状态转换为摄像头框内的短文案。 +String _livenessText(String status) => switch (status) { + 'analyzing' => '活体检测中', + 'alive' => '活体通过', + 'notAlive' => '非活体', + _ => '等待活体', + }; diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index d94e593..060d715 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -21,9 +21,16 @@ export './app/app_wallet_selector.dart'; export './app/app_dialog.dart'; export './app/terminal_toolbar.dart'; export './app/terminal_shell.dart'; +export './app/product_type_card.dart'; export './card/card_common.dart'; +export './face/face_recognition_preview_layer.dart'; +export './face/face_camera_preview.dart'; +export './face/face_live_camera_preview.dart'; +export './face/face_recognition_panel.dart'; +export './face/face_recognition_preview_dialog.dart'; + export './order/mixin/order_mixin.dart'; export './order/select_pay_type_dialog.dart'; export './order/steelyard_container.dart'; diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 0460395..ba81ab5 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; @@ -12,7 +13,9 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 收银支付弹框控制器,统一组织支付方式、硬件识别、人员钱包和提交状态。 class SelectPayTypeDialogController extends GetxController { + /// 创建支付弹框控制器,具体业务回调由食堂或超市页面绑定。 SelectPayTypeDialogController(); /// **************** start 回调函数 start **************** @@ -20,23 +23,24 @@ class SelectPayTypeDialogController extends GetxController { // |开始识别|的回调 VoidCallback? onRecognition; - // |确定|的回调 - Future Function( + /// 确认支付回调,由当前收银业务完成本地订单和远程下单收尾,并返回是否应关闭弹框。 + Future Function( ServiceUser, OrderType, OrderPayType, OrderPayDisplayType, ServiceQRCode?, + FaceStream?, )? onSubmit; - // |"二维码扫一扫支付"点击"开始识别"| + /// 钱包二维码支付开始回调,由当前收银业务先创建待支付订单。 Future Function( Future Function( ServiceHardCreateHardQrCodeScanOrder orderResult, ) onOrderCreate, )? onQrCodeScanRecognition; - // |"二维码扫一扫支付" "支付成功"| + /// 钱包二维码支付服务端确认成功后的回调,携带支付用户。 Future Function(ServiceUser? payUser)? onQrCodeScanPaySuccess; // |取消|的回调 @@ -64,11 +68,36 @@ class SelectPayTypeDialogController extends GetxController { RxList usePayDisplayTypeList = [].obs; + /// 当前允许重新识别支付人员的支付方式,来源于本机收银页面设置。 + final reidentifiablePayTypes = {}.obs; + OrderType orderType = OrderType.BELOW; RxBool isRecognition = RxBool(false); - StreamSubscription? faceDiscernListen; + /// 当前识别是否由“重新识别”按钮发起,用于保留旧人员并正确释放对应识别设备。 + final isReidentifying = false.obs; + + /// 支付提交正在执行时为 true,用于禁用重复提交和弹框关闭。 + final isSubmitting = false.obs; + + /// 当前嵌入收银工作区的人脸会话;为空时不渲染摄像头面板。 + final faceSessionHandle = Rxn(); + + /// 当前弹框最近一次收银场景人脸成功结果;提交订单时传给订单队列绑定。 + final faceRecognitionResult = Rxn(); + + /// 人脸启动、识别或人员查询失败时在支付工作区展示的可恢复说明。 + final faceWorkspaceMessage = ''.obs; + + /// 人脸结果已返回、正在查询人员和钱包时为 true。 + final faceUserLoading = false.obs; + + /// 用户关闭进行中的识别时显示行内确认区域,避免叠加第二层确认弹框。 + final closeRecognitionConfirmationVisible = false.obs; + + /// 识别过程中待切换的支付方式;为空表示没有待确认切换。 + final pendingPayDisplayType = Rxn(); // 钱包类标 RxList userWalletList = RxList([]); @@ -82,86 +111,158 @@ class SelectPayTypeDialogController extends GetxController { // 人脸识别回调锁 bool _faceCallbackLock = false; + /// 人脸准备请求序号;关闭、切换或重新打开弹框时递增,使迟到的异步结果立即失效。 + int _faceDispatchRequestId = 0; + + /// 饭卡读取请求序号;取消、关闭或再次发起后,旧异步回调不得替换当前人员。 + int _walletCardDispatchRequestId = 0; + + int? _payableAmount; + int? _totalAmount; + int? _discountAmount; + + /// 当前弹框最近一次确认的购物车与金额快照;每个快照自身仍保持不可变。 + late CashierCheckoutSnapshot checkoutSnapshot; + + /// 结算快照替换序号;供未直接订阅快照对象的支付按钮建立响应式刷新依赖。 + final checkoutSnapshotRevision = 0.obs; + + /// 加载系统支付配置与重新识别开关,并注册支付方式选择监听。 @override void onInit() async { super.onInit(); + final reidentifiablePayTypesFuture = + AppSettingTool.getCashierReidentifiablePayTypes(); appSetting = await AppSettingTool.getSysSetting(); - _listenAndroidFaceBack(); + reidentifiablePayTypes.assignAll(await reidentifiablePayTypesFuture); // 监听支付方式变化 payTypeRadioC.addValueListener((v) { + if (v.isEmpty) return; final payType = OrderPayDisplayType.values.fromCode( v.first.code, ); if (payType == null || payType == orderPayDisplayType.value) { return; } - final isOffline = OrderPayDisplayType.isOffline(payType); - if (isOffline) { - payUser.value = AppUserTool.generateExternalUser(); - if (payType == OrderPayDisplayType.cash) { - orderPayType.value = OrderPayType.CASH; - } else if (payType == OrderPayDisplayType.zyy) { - orderPayType.value = OrderPayType.ZYY; - } else if (payType == OrderPayDisplayType.rmyy) { - orderPayType.value = OrderPayType.RMYY; - } else if (payType == OrderPayDisplayType.csxfk) { - orderPayType.value = OrderPayType.CSXFK; - } - } else if (payType == OrderPayDisplayType.qrCodeScan) { - orderPayType.value = OrderPayType.WALLET_QR_CODE_SCAN; - } else if (payUser.value != null) { - if (AppUserTool.isGenerateExternalUser(payUser.value!)) { - payUser.value = null; - } - } - - orderPayDisplayType.value = payType; - orderPayDisplayType.refresh(); + _applyPayDisplayType(payType); }); } + /// 支付卡片由自定义 InkWell 绘制,点击必须直接更新业务 Rx,不能只依赖未挂载的 + /// Forui 单选组件刷新,否则 Controller 已收到选择但弹框仍显示旧选中态。 + void selectPayDisplayType(OrderPayDisplayType payType) { + if (orderPayDisplayType.value == payType || isSubmitting.value) return; + if (isRecognition.value) { + pendingPayDisplayType.value = payType; + closeRecognitionConfirmationVisible.value = false; + return; + } + _applyPayDisplayType(payType); + payTypeRadioC.value = {payType}; + } + + /// 应用已确认的支付方式,并清理上一方式遗留的局部提示。 + void _applyPayDisplayType(OrderPayDisplayType payType) { + if (payType != OrderPayDisplayType.face) { + unawaited(_cancelPendingEvidence()); + } + final isOffline = OrderPayDisplayType.isOffline(payType); + orderPayType.value = _defaultOrderPayType(payType); + globalQrCode = null; + if (isOffline) { + payUser.value = AppUserTool.generateExternalUser(); + } else if (payUser.value != null && + AppUserTool.isGenerateExternalUser(payUser.value!)) { + payUser.value = null; + } + orderPayDisplayType.value = payType; + faceWorkspaceMessage.value = ''; + } + + /// 将界面支付方式映射为默认订单支付类型;二维码真实扫码结果仍可在识别后覆盖该默认值。 + OrderPayType _defaultOrderPayType(OrderPayDisplayType payType) { + return switch (payType) { + OrderPayDisplayType.qrCodeScan => OrderPayType.WALLET_QR_CODE_SCAN, + OrderPayDisplayType.qrCode => OrderPayType.WALLET_QR_CODE, + OrderPayDisplayType.walletCard => OrderPayType.WALLET_CARD, + OrderPayDisplayType.face => OrderPayType.FACE, + OrderPayDisplayType.cash => OrderPayType.CASH, + OrderPayDisplayType.zyy => OrderPayType.ZYY, + OrderPayDisplayType.rmyy => OrderPayType.RMYY, + OrderPayDisplayType.csxfk => OrderPayType.CSXFK, + }; + } + + /// 释放支付方式选择器与可能仍在运行的人脸会话。 @override void onClose() { + unawaited(_cancelPendingEvidence()); + unawaited(_closeFaceSession(cancel: true)); super.onClose(); payTypeRadioC.dispose(); } - void initPayType() { + /// 使用弹框打开瞬间的交易快照初始化金额和支付方式。 + void initPayType({required CashierCheckoutSnapshot snapshot}) { _closeStatus(); - if (appSetting.defaultPayDisplayType != null) { - payTypeRadioC.value = {appSetting.defaultPayDisplayType!}; + updateCheckoutSnapshot(snapshot); + final defaultPayType = appSetting.defaultPayDisplayType; + if (defaultPayType != null) { + _applyPayDisplayType(defaultPayType); + payTypeRadioC.value = {defaultPayType}; } if (!ObjectUtil.isEmptyList(appSetting.usePayDisplayType)) { final isNetwork = AppNetworkTool.isAutoNetwork(); // 过滤一下不支持离线的支付方式 if (isNetwork) { - usePayDisplayTypeList.value = appSetting.usePayDisplayType!.toList() - ..sort((a, b) => a.order.compareTo(b.order)); + usePayDisplayTypeList.value = appSetting.usePayDisplayType!.toList(); } else { final supportPayTypeList = appSetting.usePayDisplayType! .where((i) => i.supportOffLine) .toList(); - usePayDisplayTypeList.value = supportPayTypeList.toList() - ..sort((a, b) => a.order.compareTo(b.order)); + usePayDisplayTypeList.value = supportPayTypeList; } } } - _closeStatus() { + /// 替换当前结算快照,并同步人脸会话创建和支付按钮使用的整数分金额。 + /// + /// 该方法只更新本次弹框上下文,不修改购物车;调用方必须先由业务 Controller 完成 + /// 购物车、附加费用和副屏更新,再传入对应的新快照。 + void updateCheckoutSnapshot(CashierCheckoutSnapshot snapshot) { + checkoutSnapshot = snapshot; + _payableAmount = snapshot.payableAmount; + _totalAmount = snapshot.originalAmount; + _discountAmount = snapshot.discountAmount; + checkoutSnapshotRevision.value++; + } + + /// 清理当前交易的人员、钱包、识别会话和支付凭据,供弹框关闭与再次打开共用。 + void _closeStatus() { + _faceDispatchRequestId++; + _walletCardDispatchRequestId++; payUser.value = null; userWalletList.value = []; currentWallet.value = null; isRecognition.value = false; + isReidentifying.value = false; globalQrCode = null; _faceCallbackLock = false; + isSubmitting.value = false; + faceSessionHandle.value = null; + faceRecognitionResult.value = null; + faceWorkspaceMessage.value = ''; + faceUserLoading.value = false; + closeRecognitionConfirmationVisible.value = false; + pendingPayDisplayType.value = null; // 设备 if (appSetting.sysType == SysType.CANTEEN) { BarcodeScanManage.instance.dispose(); } + app.serialCardManage.callback = null; app.serialCardManage.close(); - // 取消人脸监听 - faceDiscernListen?.cancel(); + unawaited(_closeFaceSession(cancel: true)); // 关闭加载提示 EasyLoading.dismiss(); @@ -187,47 +288,116 @@ class SelectPayTypeDialogController extends GetxController { onRecognition?.call(); } - // |确定|点击 - doSubmit() async { - if (payUser.value == null) { + /// 防重复提交当前支付信息,并仅在业务回调确认成功后关闭本次结算弹框。 + Future doSubmit() async { + if (payUser.value == null || isSubmitting.value) return; + if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { + await doRecognition(); return; } - // 判断是否为线下支付 - final isOffline = OrderPayDisplayType.isOffline(orderPayDisplayType.value!); - if (isOffline) { - orderType = OrderType.BELOW; - onDiscernUser?.call(payUser.value); + isSubmitting.value = true; + try { + // 判断是否为线下支付 + final isOffline = + OrderPayDisplayType.isOffline(orderPayDisplayType.value!); + if (isOffline) { + orderType = OrderType.BELOW; + onDiscernUser?.call(payUser.value); + } + final submitted = await onSubmit?.call( + payUser.value!, + orderType, + orderPayType.value!, + orderPayDisplayType.value!, + globalQrCode, + faceRecognitionResult.value, + ); + if (submitted != true) return; + _closeStatus(); + if (Get.context != null) Navigator.of(Get.context!).pop(); + } finally { + isSubmitting.value = false; } - await onSubmit?.call( - payUser.value!, - orderType, - orderPayType.value!, - orderPayDisplayType.value!, - globalQrCode, - ); - _closeStatus(); - Navigator.of(Get.context!).pop(); } - // |取消|点击 - doCancel() async { - if (isRecognition.value) { - final flag = await appMessageDialog(Get.context!, "是否取消识别"); - if (!flag) return; - isRecognition.value = false; - Navigator.of(Get.context!).pop(); - if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { - SubScreenPlugin.sendMsgToViceScreen( - SubScreenAction.qrCodeScanCancel.name, - ); - } + /// 请求关闭支付弹框;识别进行中时改为展示行内确认区域。 + Future doCancel() async { + if (isSubmitting.value) { + await AppRuntimeTool.vibrate(durationMs: 60); return; } + if (isRecognition.value) { + pendingPayDisplayType.value = null; + closeRecognitionConfirmationVisible.value = true; + return; + } + await _cancelPendingEvidence(); _closeStatus(); onCancel?.call(); Navigator.of(Get.context!).pop(); } + /// 关闭支付弹框前取消尚未绑定订单的本次人脸证据,避免把身份核验图片留在本地队列。 + Future _cancelPendingEvidence() async { + final sessionId = faceRecognitionResult.value?.sessionId; + if (sessionId == null || sessionId.isEmpty) return; + await OrderEvidenceUploadTool.cancelSession(sessionId); + faceRecognitionResult.value = null; + } + + /// 取消关闭或切换请求,继续当前识别会话。 + void continueCurrentRecognition() { + closeRecognitionConfirmationVisible.value = false; + pendingPayDisplayType.value = null; + } + + /// 确认取消当前识别并关闭整个支付弹框。 + Future confirmRecognitionAndClose() async { + if (isSubmitting.value) return; + await _stopCurrentRecognition(); + _closeStatus(); + onCancel?.call(); + if (Get.context != null) Navigator.of(Get.context!).pop(); + } + + /// 确认取消当前识别并切换到用户刚选择的支付方式。 + Future confirmRecognitionAndSwitch() async { + final pending = pendingPayDisplayType.value; + if (pending == null || isSubmitting.value) return; + await _stopCurrentRecognition(); + closeRecognitionConfirmationVisible.value = false; + pendingPayDisplayType.value = null; + _applyPayDisplayType(pending); + payTypeRadioC.value = {pending}; + } + + /// 根据当前支付方式停止硬件、轮询或人脸会话,供切换和关闭流程共用。 + Future _stopCurrentRecognition() async { + final currentType = orderPayDisplayType.value; + final wasReidentifying = isReidentifying.value; + final activeRecognitionType = + wasReidentifying ? currentReidentificationMethod : currentType; + isRecognition.value = false; + isReidentifying.value = false; + if (activeRecognitionType == OrderPayDisplayType.walletCard) { + _walletCardDispatchRequestId++; + app.serialCardManage.callback = null; + app.serialCardManage.close(); + return; + } + if (activeRecognitionType == OrderPayDisplayType.face) { + _faceDispatchRequestId++; + await _cancelPendingEvidence(); + await _closeFaceSession(cancel: true); + } else if (activeRecognitionType == OrderPayDisplayType.qrCode) { + BarcodeScanManage.instance.dispose(); + } else if (activeRecognitionType == OrderPayDisplayType.qrCodeScan) { + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.qrCodeScanCancel.name, + ); + } + } + // ************************************************************************** // 二维码识别 start // ************************************************************************** @@ -301,77 +471,170 @@ class SelectPayTypeDialogController extends GetxController { // ************************************************************************** // 饭卡识别 start // ************************************************************************** - Future _dispatchWalletCard() async { + /// 当前支付方式是否允许重新识别已确认人员;未识别人员时继续使用各支付方式原有首次识别入口。 + bool get canReidentifyCurrentPayType { + final payType = orderPayDisplayType.value; + final user = payUser.value; + return payType != null && + user != null && + !AppUserTool.isGenerateExternalUser(user) && + reidentifiablePayTypes.contains(payType); + } + + /// 当前重新识别使用的识别方式;人脸支付沿用摄像头,其余渠道使用饭卡补充人员身份。 + OrderPayDisplayType? get currentReidentificationMethod { + final payType = orderPayDisplayType.value; + if (payType == null) return null; + return payType == OrderPayDisplayType.face + ? OrderPayDisplayType.face + : OrderPayDisplayType.walletCard; + } + + /// 按当前支付方式发起人员重新识别,成功后保留所选支付方式并替换人员与钱包。 + Future reidentifyCurrentUser() async { + if (!canReidentifyCurrentPayType || + isSubmitting.value || + isRecognition.value) { + return; + } + isReidentifying.value = true; + if (currentReidentificationMethod == OrderPayDisplayType.face) { + await _dispatchFace(); + return; + } + await _dispatchWalletCard(preservePaymentMethod: true); + } + + /// 检查异步饭卡结果是否仍属于当前未取消的读取请求。 + bool _ownsWalletCardDispatch(int requestId) { + return requestId == _walletCardDispatchRequestId && isRecognition.value; + } + + /// 启动单次饭卡读取;重新识别模式只替换人员,普通模式仍按饭卡支付处理。 + Future _dispatchWalletCard({bool preservePaymentMethod = false}) async { if (isRecognition.value) { Toast.error(msg: "正在识别中,请勿重复点击"); return; } + final requestId = ++_walletCardDispatchRequestId; + isRecognition.value = true; + isReidentifying.value = preservePaymentMethod; TtsManage.speak("请在合适区域放置饭卡"); try { await app.serialCardManage.connect(); + if (!_ownsWalletCardDispatch(requestId)) return; if (!app.serialCardManage.isConnect) { logger.e("|$_tag|饭卡识别,错误未打开设备"); Toast.error(msg: "读卡器错误"); + _finishWalletCardDispatch(requestId); return; } app.serialCardManage.callback = (type, status, card) async { + if (!_ownsWalletCardDispatch(requestId)) return; switch (status) { case SerialCardResStatus.success: - if (ObjectUtil.isEmpty(card)) return; - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (isNetwork) { - final pa = {"card": card}; - final data = await HardApi.getUserByCard(pa); - if (data == null) { - logger.e("|$_tag|饭卡识别,无效的饭卡cardId:|$card|"); - isRecognition.value = false; - app.serialCardManage.close(); - Toast.error(msg: "查询不到此用户"); - return; - } - payUser.value = data; - if (payUser.value != null) { - await SyncUserTool.storageUser(user: payUser.value!); - } - TtsManage.speak("识别饭卡成功"); - } else { - final user = await SyncUserTool.storageUserByCarNumber(card); - if (user == null || ObjectUtil.isEmpty(user.walletCardNumber)) { - logger.e("|$_tag|,饭卡识别,离线获取本地用户失败"); - Toast.error(msg: "离线模式下查询不到此用户"); - TtsManage.speak("离线模式下查询不到此用户"); - app.serialCardManage.close(); - isRecognition.value = false; - return; - } - payUser.value = SyncUserTool.parsePayUser(user: user!); - logger.i("|$_tag|,饭卡识别,离线获取本地用户成功:|${payUser.value!.toJson()}|"); - } + final cardNumber = card is String ? card.trim() : ''; + if (cardNumber.isEmpty) return; + app.serialCardManage.callback = null; app.serialCardManage.close(); - isRecognition.value = false; - orderPayType.value = OrderPayType.WALLET_CARD; - orderType = OrderType.BELOW; - await _getWalletList(); - onDiscernUser?.call(payUser.value); + try { + final nextUser = await _resolveWalletCardUser(cardNumber); + var nextWallets = []; + try { + nextWallets = await _loadWalletListForUser( + nextUser, + forceWalletLookup: preservePaymentMethod, + ); + } catch (error, stack) { + logger.e( + "|$_tag|饭卡识别后读取钱包失败error:|$error|stack:|$stack|", + ); + // 重新识别必须连同钱包信息一起成功,普通饭卡支付继续兼容原有人员内嵌钱包。 + if (preservePaymentMethod) rethrow; + } + if (!_ownsWalletCardDispatch(requestId)) return; + final nextWallet = _defaultWalletForUser(nextUser, nextWallets); + if (nextWallet != null) { + _applyWalletToUser(nextUser, nextWallet); + } + // 同一事件循环内先准备钱包再替换人员,避免界面短暂混用新旧用户数据。 + userWalletList.assignAll(nextWallets); + currentWallet.value = nextWallet; + payUser.value = nextUser; + orderType = OrderType.BELOW; + if (preservePaymentMethod) { + final currentPayType = orderPayDisplayType.value!; + orderPayType.value = _defaultOrderPayType(currentPayType); + globalQrCode = currentPayType == OrderPayDisplayType.qrCode + ? ServiceQRCode( + id: nextUser.walletId, + userId: nextUser.userId, + orderType: OrderType.BELOW, + payType: OrderPayType.WALLET_QR_CODE, + exceedTime: -1, + ) + : null; + } else { + orderPayType.value = OrderPayType.WALLET_CARD; + globalQrCode = null; + } + onDiscernUser?.call(nextUser); + TtsManage.speak("识别饭卡成功"); + } catch (error, stack) { + final message = error + .toString() + .replaceFirst('Bad state: ', '') + .replaceFirst('Exception: ', '') + .trim(); + logger.e("|$_tag|饭卡识别后读取人员失败error:|$error|stack:|$stack|"); + Toast.error(msg: message.isEmpty ? "查询不到此用户" : message); + TtsManage.speak("识别饭卡失败"); + } finally { + _finishWalletCardDispatch(requestId); + } break; case SerialCardResStatus.warning: break; case SerialCardResStatus.error: - Toast.error(msg: "请在合适区域放置饭卡"); - isRecognition.value = false; - app.serialCardManage.close(); - logger.i("|$_tag|饭卡识别,连续一段时间未识别到饭卡"); + final message = card is String && card.trim().isNotEmpty + ? card.trim() + : "请在合适区域放置饭卡"; + Toast.error(msg: message); + logger.i("|$_tag|饭卡识别失败reason:|$message|"); + _finishWalletCardDispatch(requestId); break; } }; app.serialCardManage.sendRead(); - isRecognition.value = true; - } catch (e) { - isRecognition.value = false; - app.serialCardManage.close(); - logger.e("|$_tag|饭卡识别,失败error:|$e|"); + } catch (error, stack) { + _finishWalletCardDispatch(requestId); + logger.e("|$_tag|饭卡识别,失败error:|$error|stack:|$stack|"); } } + + /// 根据饭卡号读取线上或本地人员;失败抛出可直接展示给收银员的原因。 + Future _resolveWalletCardUser(String cardNumber) async { + if (AppNetworkTool.isAutoNetwork()) { + final data = await HardApi.getUserByCard({"card": cardNumber}); + if (data == null) throw StateError("查询不到此用户"); + await SyncUserTool.storageUser(user: data); + return data; + } + final user = await SyncUserTool.storageUserByCarNumber(cardNumber); + if (user == null || ObjectUtil.isEmpty(user.walletCardNumber)) { + throw StateError("离线模式下查询不到此用户"); + } + return SyncUserTool.parsePayUser(user: user); + } + + /// 在当前请求仍有效时结束饭卡会话,并恢复按钮、切换和关闭能力。 + void _finishWalletCardDispatch(int requestId) { + if (requestId != _walletCardDispatchRequestId) return; + app.serialCardManage.callback = null; + app.serialCardManage.close(); + isRecognition.value = false; + isReidentifying.value = false; + } // ************************************************************************** // 饭卡识别 end // ************************************************************************** @@ -379,111 +642,226 @@ class SelectPayTypeDialogController extends GetxController { // ************************************************************************** // 人脸支付 start // ************************************************************************** + /// 检查当前异步准备结果是否仍属于未被关闭或切换的本轮人脸请求。 + bool _ownsFaceDispatch(int requestId) { + return requestId == _faceDispatchRequestId && isRecognition.value; + } + + /// 检查数据库与 SDK 状态后创建唯一人脸会话;准备期间同样锁定重复点击和关闭流程。 Future _dispatchFace() async { + if (isRecognition.value) { + Toast.error(msg: "正在识别中,请勿重复点击"); + return; + } + final requestId = ++_faceDispatchRequestId; + isRecognition.value = true; try { - EasyLoading.show( - status: '人脸识别准备中...', - maskType: EasyLoadingMaskType.black, - ); + final service = Get.find(); + faceWorkspaceMessage.value = '正在检查人脸库与摄像头状态'; final faceDao = app.dataBase.getFaceDao; final databaseFaceCount = await faceDao.findCount("-1"); + if (!_ownsFaceDispatch(requestId)) return; if (databaseFaceCount == null || databaseFaceCount == 0) { String msg = "人脸数据库为空,请前往'设置'->'人脸管理'点击同步人脸"; - Toast.error(msg: msg); throw Exception(msg); } - final isActive = await AppFaceTool.isActiveSdk(); - if (!isActive) { - String msg = "请检查激人脸sdk激活码"; - Toast.error(msg: msg); + final status = await AppFaceTool.getStatus(); + if (!_ownsFaceDispatch(requestId)) return; + if (status?.registrationInProgress == true) { + String msg = "人脸库正在注册,请稍候再发起人脸支付"; throw Exception(msg); } - final faceCount = await FaceDiscernPlugs.getFaceCount(); - if (faceCount == 0) { - await SyncFaceTool.delEmptyFace(); - await AppFaceTool.faceRegister(); - } - final isJump = await AppFaceTool.jumpFacePage(); - if (!isJump) { - String msg = "跳转到人脸检测页面失败"; - Toast.error(msg: msg); + if (status?.activated != true) { + String msg = "人脸插件未激活,请前往'设置'->'人脸设置'处理"; throw Exception(msg); } - faceDiscernListen = FaceDiscernPlugs.faceStream.listen((event) { - if (event == null) return; - if (_faceCallbackLock) return; - _faceCallbackLock = true; - final userId = event.userId; - if (userId == null) { - Toast.error(msg: "人脸识别失败,请重试"); - return; - } - _faceDiscernAfter(event); - SubScreenPlugin.doubleScreenShow(); - }); - } catch (e) { - logger.e("|$_tag|跳转人脸识别页面,意外的错误error:|$e|"); - EasyLoading.dismiss(); + if (status!.memoryCount != databaseFaceCount || !status.engineReady) { + String msg = "人脸库尚未就绪,请前往'设置'->'人脸设置'重新注册"; + throw Exception(msg); + } + final handle = await service.start( + scene: FaceRecognitionScene.cashier, + payableAmount: _payableAmount, + totalAmount: _totalAmount, + discountAmount: _discountAmount, + ); + if (!_ownsFaceDispatch(requestId)) { + if (service.owns(handle.sessionId)) await service.cancel(); + return; + } + faceSessionHandle.value = handle; + faceWorkspaceMessage.value = ''; + } on FaceRecognitionFailure catch (error) { + if (!_ownsFaceDispatch(requestId)) return; + isRecognition.value = false; + isReidentifying.value = false; + faceWorkspaceMessage.value = error.message; + logger.e("|$_tag|人脸识别会话失败code:${error.code}|message:${error.message}|"); + } catch (e, stack) { + if (!_ownsFaceDispatch(requestId)) return; + logger.e("|$_tag|启动人脸识别,意外的错误error:|$e|stack:|$stack|"); + isRecognition.value = false; + isReidentifying.value = false; + final message = e.toString().replaceFirst('Exception: ', '').trim(); + faceWorkspaceMessage.value = + message.isEmpty ? '无法启动人脸识别,请检查设备后重试' : message; + } + } + + /// 处理嵌入式面板返回的唯一人脸结果,并在原位查询人员与钱包。 + Future handleFaceRecognized( + FaceStream event, + String sessionId, + ) async { + if (_faceCallbackLock || faceSessionHandle.value?.sessionId != sessionId) { + return; + } + _faceCallbackLock = true; + faceSessionHandle.value = null; + faceWorkspaceMessage.value = '识别成功,正在读取人员与钱包信息'; + faceUserLoading.value = true; + try { + await _faceDiscernAfter(event); + } finally { + faceUserLoading.value = false; + isRecognition.value = false; + isReidentifying.value = false; + _faceCallbackLock = false; + } + } + + /// 将面板内识别失败同步到收银工作区;终止失败同时释放会话显示。 + void handleFaceFailure(FaceRecognitionFailure failure) { + faceWorkspaceMessage.value = failure.message; + if (!failure.terminatesSession) return; + faceSessionHandle.value = null; + isRecognition.value = false; + isReidentifying.value = false; + } + + /// 处理面板或切换流程触发的取消结果。 + void handleFaceCancelled() { + faceSessionHandle.value = null; + isRecognition.value = false; + isReidentifying.value = false; + if (!closeRecognitionConfirmationVisible.value && + pendingPayDisplayType.value == null) { + faceWorkspaceMessage.value = '已取消人脸识别,可重新发起'; } } // android人脸识别成功回调 Future _faceDiscernAfter(FaceStream event) async { + final reidentifying = isReidentifying.value; + String? createdEvidenceSessionId; try { - EasyLoading.show( - status: '获取用户信息中...', - maskType: EasyLoadingMaskType.black, - ); final isNetwork = AppNetworkTool.isAutoNetwork(); - final appSetting = await AppSettingTool.getSysSetting(); - if (isNetwork) { - final pa = { - "id": event.userId, - // 现阶段都使用饭卡钱包 - "type": "CANTEEN", - }; - final data = await HardApi.getUserInfoById(pa); - if (data == null) throw Error(); - payUser.value = data; - logger.i("|_tag|人脸识别|有网络|,用户信息:|${payUser.toJson()}|"); - await SyncUserTool.storageUser(user: payUser.value!); - await _getWalletList(); - } else { - // 没有网络去本地数据库查询 - final user = await SyncUserTool.storageUserByUserId(event.userId!); - if (user == null) { - logger.e("|_tag|,人脸识别,离线获取本地用户失败"); - Toast.error(msg: "查询不到此用户"); - throw Error(); - } - payUser.value = SyncUserTool.parsePayUser(user: user); - payUser.refresh(); - logger.i("|_tag|人脸识别|无网络|,用户信息:|${payUser.toJson()}|"); + final nextUser = await AppFaceTool.resolveRecognizedUser( + recognition: event, + sysType: appSetting.sysType, + ); + if (nextUser == null) { + throw StateError('该人脸样本对应人员已失效,请重新同步或重新录入人脸'); } + final sessionId = event.sessionId?.trim(); + if (sessionId == null || sessionId.isEmpty) { + throw StateError('识别结果缺少会话编号,请重新识别人脸'); + } + final sampleId = event.sampleId?.toString(); + if (sampleId == null || sampleId.isEmpty) { + throw StateError('识别结果缺少人脸样本编号,请重新同步人脸'); + } + final face = await app.dataBase.getFaceDao.findBySampleId(sampleId); + if (face == null || face.userId != nextUser.userId) { + throw StateError('本地人脸样本与识别人员不一致,请重新同步人脸'); + } + // 样本归属确认后立即落本地队列,钱包网络请求失败或进程随后退出也不会丢失已采集图片。 + createdEvidenceSessionId = sessionId; + await OrderEvidenceUploadTool.createFaceRecognitionTasks( + recognition: event, + face: face, + ); + + var nextWallets = []; + if (isNetwork) { + try { + nextWallets = await _loadWalletListForUser( + nextUser, + forceWalletLookup: reidentifying, + ); + } catch (error, stack) { + logger.e("|$_tag|人脸识别后读取钱包失败error:|$error|stack:|$stack|"); + // 重新识别只有在人员和钱包都准备完成后才替换旧状态,普通识别继续兼容原有容错。 + if (reidentifying) rethrow; + } + logger.i("|_tag|人脸识别|有网络|,用户信息:|${nextUser.toJson()}|"); + } else { + logger.i("|_tag|人脸识别|无网络|,用户信息:|${nextUser.toJson()}|"); + } + final nextWallet = _defaultWalletForUser(nextUser, nextWallets); + if (nextWallet != null) { + _applyWalletToUser(nextUser, nextWallet); + } + final previousSessionId = faceRecognitionResult.value?.sessionId; + if (previousSessionId != null && previousSessionId != sessionId) { + try { + await OrderEvidenceUploadTool.cancelSession( + previousSessionId, + reason: '重新识别人脸后取消旧证据任务', + ); + } catch (error) { + // 旧任务清理失败不应否定本次已经校验成功的人脸,过期维护仍会继续收敛该记录。 + logger.w('|$_tag|旧人脸证据任务取消失败|errorType:${error.runtimeType}|'); + } + } + // 人员和钱包使用同一批识别结果一次替换,避免界面短暂混用新旧人员数据。 + userWalletList.assignAll(nextWallets); + currentWallet.value = nextWallet; + payUser.value = nextUser; orderPayType.value = OrderPayType.FACE; orderType = OrderType.BELOW; - onDiscernUser?.call(payUser.value); + faceRecognitionResult.value = event; + createdEvidenceSessionId = null; + onDiscernUser?.call(nextUser); + faceWorkspaceMessage.value = ''; } catch (e, task) { + if (createdEvidenceSessionId != null) { + try { + await OrderEvidenceUploadTool.cancelSession( + createdEvidenceSessionId, + reason: '人脸识别后的人员或钱包校验未完成', + ); + } catch (cleanupError) { + logger.w( + '|$_tag|本次人脸证据任务回收失败|errorType:${cleanupError.runtimeType}|', + ); + } + } logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); - Toast.error(msg: "识别人脸错误,请重试"); - } finally { - EasyLoading.dismiss(); - _faceCallbackLock = false; + // 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。 + if (!reidentifying) payUser.value = null; + final message = e.toString().replaceFirst('Bad state: ', '').trim(); + faceWorkspaceMessage.value = + message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message; } } - // android 点击返回回调 - _listenAndroidFaceBack() { - final getChannel = FaceDiscernPlugs.getChannel; - getChannel.setMethodCallHandler((call) async { - if (call.method == "faceDiscernBack") { - SubScreenPlugin.doubleScreenShow(); - EasyLoading.dismiss(); - } - if (call.method == "faceDiscernPageInit") { - EasyLoading.dismiss(); - } - }); + Future _closeFaceSession({required bool cancel}) async { + if (!Get.isRegistered()) { + faceSessionHandle.value = null; + return; + } + final service = Get.find(); + if (!service.isActive) { + faceSessionHandle.value = null; + return; + } + if (cancel) { + await service.cancel(); + } else { + await service.finish(); + } + faceSessionHandle.value = null; } // ************************************************************************** @@ -493,6 +871,8 @@ class SelectPayTypeDialogController extends GetxController { // ************************************************************************** // 二维码扫一扫 start // ************************************************************************** + /// 创建待支付订单并展示钱包二维码,轮询到支付成功后通知收银业务收尾。 + /// 适配说明:消费机项目已升级为聚合支付码,本项目保留既有钱包二维码流程。 Future _dispatchQrCodeScan() async { try { // 先创建订单 @@ -502,7 +882,7 @@ class SelectPayTypeDialogController extends GetxController { "|$_tag|二维码扫一扫,创建本地id成功的回调,sqlOrderId|${orderRes.toJson()}|", ); // 发送消息给副屏展示二维码弹框 - SubScreenQrCodeScanEvent qrCodeScanEvent = SubScreenQrCodeScanEvent( + final qrCodeScanEvent = SubScreenQrCodeScanEvent( action: "hardQrCodeScan", orderId: orderRes.orderId, qrCodeData: orderRes.qrCodeData, @@ -544,21 +924,27 @@ class SelectPayTypeDialogController extends GetxController { String serviceOrderId, ) async { int errorCount = 0; - int maxErrorCount = 5; + const maxErrorCount = 5; while (true) { if (!isRecognition.value) { - logger.e("|$_tag|轮训查询二维码订单状态,已取消订单,停止轮训,sqlOrderId|$serviceOrderId|"); + logger.e( + "|$_tag|轮训查询二维码订单状态,已取消订单,停止轮训,sqlOrderId|$serviceOrderId|", + ); return null; } if (errorCount > maxErrorCount) { - logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); + logger.e( + "|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|", + ); Toast.error(msg: "订单不存在,请根据系统检查订单"); return null; } await Future.delayed(const Duration(seconds: 3)); final order = await HardApi.getOrder({"id": serviceOrderId}); if (order == null) { - logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); + logger.e( + "|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|", + ); errorCount++; continue; } @@ -597,11 +983,7 @@ class SelectPayTypeDialogController extends GetxController { if (payUser.value == null) return; final currentPayUser = ServiceUser.fromJson(payUser.value!.toJson()); - currentPayUser.walletId = wallet.id; - currentPayUser.walletTypeId = wallet.walletTypeId; - currentPayUser.walletBalance = wallet.balance; - currentPayUser.walletCardNumber = wallet.cardNumber; - currentPayUser.walletIsFreeze = wallet.isFreeze; + _applyWalletToUser(currentPayUser, wallet); // 检查钱包ID是否真的发生了变化 if (payUser.value!.walletId == wallet.id) { @@ -616,282 +998,2433 @@ class SelectPayTypeDialogController extends GetxController { }); } - /// 获取用户钱包列表 - Future _getWalletList() async { - // 如果选择了需要选择钱包的支付方式 - if (appSetting.sysType != SysType.SUPERMARKET) { - return; + /// 将选中钱包写入人员支付快照,保证界面展示与最终下单使用同一钱包。 + void _applyWalletToUser(ServiceUser user, ServiceWallet wallet) { + user.walletId = wallet.id; + user.walletTypeId = wallet.walletTypeId; + user.walletBalance = wallet.balance; + user.walletCardNumber = wallet.cardNumber; + user.walletIsFreeze = wallet.isFreeze; + } + + /// 从钱包列表中优先匹配人员默认钱包,未匹配时回退第一项。 + ServiceWallet? _defaultWalletForUser( + ServiceUser user, + List wallets, + ) { + if (wallets.isEmpty) return null; + for (final wallet in wallets) { + if (wallet.id == user.walletId) return wallet; } - final isSelectWallet = OrderPayDisplayType.isSelectWalletPayType( - orderPayDisplayType.value!, - ); - if (!isSelectWallet) return; + return wallets.first; + } + + /// 按当前支付方式读取指定人员的钱包列表;重新识别会强制刷新钱包以完整替换旧人员信息。 + Future> _loadWalletListForUser( + ServiceUser user, { + bool forceWalletLookup = false, + }) async { + if (appSetting.sysType != SysType.SUPERMARKET) return []; + final payType = orderPayDisplayType.value; + if (payType == null || + (!forceWalletLookup && + !OrderPayDisplayType.isSelectWalletPayType(payType)) || + AppUserTool.isGenerateExternalUser(user)) { + return []; + } + final userId = user.userId; + if (userId == null || userId.trim().isEmpty) { + throw StateError('用户信息缺少标识,无法读取钱包'); + } + final data = await WalletApi.getWalletList({"userId": userId}); + if (data == null) throw StateError('钱包信息读取失败,请重试'); + return data; + } + + /// 获取当前人员钱包列表;识别主流程兼容旧行为,接口异常只记录并保留人员结果。 + Future _getWalletList() async { try { - if (payUser.value == null) return; - if (AppUserTool.isGenerateExternalUser(payUser.value!)) return; - - final userId = payUser.value?.userId; - if (userId == null) return; - - final data = await WalletApi.getWalletList({"userId": userId}); - if (data == null) throw Error(); - userWalletList.value = data; + final user = payUser.value; + if (user == null) return; + final data = await _loadWalletListForUser(user); + userWalletList.assignAll(data); + currentWallet.value = _defaultWalletForUser(user, data); } catch (error, stack) { logger.e("|$_tag|获取用户钱包列表,意外的错误error:|$error|stack:|$stack|"); } } } -Future selectPayTypeDialog() async { +/// 结算弹框商品数量变更回调;业务层完成真实购物车更新后返回新的不可变快照。 +/// +/// 返回空值表示业务更新失败,弹框保留原快照并恢复操作;库存只是本地参考值,回调不得 +/// 仅因数量超过该快照而拒绝修改,最终库存结果仍由服务端下单接口确认。 +typedef CashierCheckoutQuantityChanged = Future + Function(String productServiceUniqueCode, int quantity); + +/// 打开收银结算弹框;空快照不会创建路由,避免无商品时进入支付方式选择。 +Future selectPayTypeDialog({ + bool paymentEnabled = true, + String? disabledMessage, + required CashierCheckoutSnapshot snapshot, + CashierCheckoutQuantityChanged? onQuantityChanged, +}) async { + if (snapshot.isEmpty) return; final payTypeC = Get.find(); final context = Get.context!; - payTypeC.initPayType(); - final theme = FTheme.of(Get.context!); - final width = Get.context!.width; - final height = Get.context!.height; - final dialogWidth = width * 0.55; - final dialogHeight = height * 0.7; - return showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext _) { - return FDialog( - title: - Text("选择支付方式", style: theme.typography.lg).marginOnly(bottom: 20.h), - style: theme.dialogStyle.copyWith( - insetPadding: EdgeInsets.zero, - maxWidth: dialogWidth, - minWidth: dialogWidth, - horizontalStyle: theme.dialogStyle.horizontalStyle.copyWith( - padding: EdgeInsets.all(20.w), + payTypeC.initPayType(snapshot: snapshot); + final snapshotNotifier = ValueNotifier(snapshot); + final viewport = MediaQuery.sizeOf(context); + final dialogWidth = viewport.width * 0.92; + final dialogHeight = viewport.height * 0.90; + try { + await showAppDialog( + context: context, + barrierDismissible: false, + canPop: false, + barrierLabel: '收银支付弹框已打开,请先完成或取消当前交易', + builder: (_) => AppDialog( + title: const Text('收银结算'), + subtitle: const Text('核对购物车、人员和支付方式'), + leading: DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12), + ), + child: FIcon( + FAssets.icons.badgeDollarSign, + size: 26.w, + color: XfjDesignTokens.primary, + ).center().width(46.w).height(46.h), + ), + headerTrailing: ValueListenableBuilder( + valueListenable: snapshotNotifier, + builder: (_, currentSnapshot, __) => Obx( + () => _CheckoutHeaderStatus( + submitting: payTypeC.isSubmitting.value, + productCount: currentSnapshot.productCount, + ), ), ), - body: [ - Obx( - () => AbsorbPointer( - absorbing: payTypeC.isRecognition.value, - child: FSelectGroup( - enabled: payTypeC.payUser.value == null, - controller: payTypeC.payTypeRadioC, - style: theme.selectGroupStyle.copyWith( - labelLayoutStyle: - theme.selectGroupStyle.labelLayoutStyle.copyWith( - childPadding: EdgeInsets.symmetric( - vertical: 10.h, - horizontal: 10.w, - ), - ), - radioStyle: theme.selectGroupStyle.radioStyle.copyWith( - padding: EdgeInsets.symmetric( - vertical: 10.h, - ), - ), - ), - items: payTypeC.usePayDisplayTypeList - .map>( - (v) => FSelectGroupItem.radio( - value: v, - label: Text( - v.title, - style: theme.typography.xl, - ), - ), - ) - .toList(), + width: dialogWidth, + maxWidth: dialogWidth, + height: dialogHeight, + contentPadding: EdgeInsets.zero, + draggable: true, + closeButtonKey: const ValueKey('cashier-checkout-close'), + onClose: payTypeC.doCancel, + semanticLabel: '收银结算与支付方式弹框', + content: _CashierCheckoutDialogBody( + controller: payTypeC, + initialSnapshot: snapshot, + paymentEnabled: paymentEnabled, + disabledMessage: disabledMessage, + onQuantityChanged: onQuantityChanged, + onSnapshotChanged: (value) => snapshotNotifier.value = value, + ), + ), + ); + } finally { + snapshotNotifier.dispose(); + } +} + +/// 收银弹框标题栏状态,支付提交时给出明确的进行中反馈。 +class _CheckoutHeaderStatus extends StatelessWidget { + /// 创建商品数量或提交状态标签。 + const _CheckoutHeaderStatus({ + required this.submitting, + required this.productCount, + }); + + /// 当前是否正在提交支付。 + final bool submitting; + + /// 快照中的商品数量。 + final int productCount; + + /// 构建不依赖颜色单独表达状态的紧凑标签。 + @override + Widget build(BuildContext context) { + return [ + if (submitting) + SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + else + FIcon( + FAssets.icons.shoppingCart, + size: 17.w, + color: XfjDesignTokens.primary, + ), + SizedBox(width: 7.w), + Text( + submitting ? '正在提交支付' : '$productCount 件商品', + style: TextStyle( + color: submitting ? XfjDesignTokens.warning : XfjDesignTokens.primary, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRow(mainAxisSize: MainAxisSize.min) + .padding(horizontal: 12.w, vertical: 8.h) + .decorated( + color: submitting + ? const Color(0xFFFFF9E8) + : XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(99), + ); + } +} + +/// 收银弹框主体,根据可用宽度在 42:58 双栏和单列滚动之间切换。 +class _CashierCheckoutDialogBody extends StatefulWidget { + /// 创建可由业务回调替换结算快照的弹框主体。 + const _CashierCheckoutDialogBody({ + required this.controller, + required this.initialSnapshot, + required this.paymentEnabled, + required this.disabledMessage, + required this.onQuantityChanged, + required this.onSnapshotChanged, + }); + + /// 现有支付业务控制器。 + final SelectPayTypeDialogController controller; + + /// 弹框打开瞬间冻结的购物车与金额,后续只能用业务回调返回的新快照整体替换。 + final CashierCheckoutSnapshot initialSnapshot; + + /// 当前是否具备发起支付的商品前置条件。 + final bool paymentEnabled; + + /// 支付不可用时的场景化提示。 + final String? disabledMessage; + + /// 食堂结算数量更新入口;为空时保持超市等既有场景的只读购物车。 + final CashierCheckoutQuantityChanged? onQuantityChanged; + + /// 快照更新通知,用于同步刷新位于弹框主体之外的标题件数。 + final ValueChanged onSnapshotChanged; + + /// 创建持有最新快照与数量更新互斥状态的局部状态。 + @override + State<_CashierCheckoutDialogBody> createState() => + _CashierCheckoutDialogBodyState(); +} + +/// 管理结算弹框最新快照和单次数量操作,业务数据仍由对应收银 Controller 持有。 +class _CashierCheckoutDialogBodyState + extends State<_CashierCheckoutDialogBody> { + /// 当前已经由业务层确认的结算快照。 + late CashierCheckoutSnapshot _snapshot; + + /// 正在等待真实购物车、金额和结算快照更新时为 true,用于阻止重复点击和支付竞态。 + bool _quantityUpdating = false; + + /// 当前正在修改的商品编码,用于只在对应步进器中展示加载状态。 + String? _updatingProductCode; + + /// 当前商品请求中的目标数量;用于在业务快照返回前保持操作员可见的即时反馈。 + int? _pendingQuantity; + + /// 使用弹框入口快照初始化局部显示状态。 + @override + void initState() { + super.initState(); + _snapshot = widget.initialSnapshot; + } + + /// 请求业务层修改真实购物车,并只接受业务确认返回的完整新快照。 + Future _changeQuantity( + CashierCheckoutItemSnapshot item, + int quantity, + ) async { + final callback = widget.onQuantityChanged; + if (callback == null || _quantityUpdating) return; + setState(() { + _quantityUpdating = true; + _updatingProductCode = item.productServiceUniqueCode; + _pendingQuantity = quantity.clamp(0, 100); + }); + CashierCheckoutSnapshot? updatedSnapshot; + try { + updatedSnapshot = await callback( + item.productServiceUniqueCode, + quantity.clamp(0, 100), + ); + if (updatedSnapshot == null) { + Toast.error(msg: '调整商品数量失败,请重试'); + } + } catch (error, stack) { + logger.e( + '|收银结算弹框|调整商品数量失败productCode:|${item.productServiceUniqueCode}|error:|$error|stack:|$stack|', + ); + Toast.error(msg: '调整商品数量失败,请重试'); + } + if (!mounted) return; + if (updatedSnapshot != null) { + widget.controller.updateCheckoutSnapshot(updatedSnapshot); + widget.onSnapshotChanged(updatedSnapshot); + } + setState(() { + if (updatedSnapshot != null) _snapshot = updatedSnapshot; + _quantityUpdating = false; + _updatingProductCode = null; + _pendingQuantity = null; + }); + } + + /// 构建宽屏双栏或窄屏单列布局,避免横向溢出。 + @override + Widget build(BuildContext context) { + final currentPaymentEnabled = + widget.paymentEnabled && !_snapshot.isEmpty && !_quantityUpdating; + final currentDisabledMessage = + _snapshot.isEmpty ? '购物车为空,请返回添加商品' : widget.disabledMessage; + return LayoutBuilder( + key: const ValueKey('cashier-checkout-layout'), + builder: (context, constraints) { + final wide = constraints.maxWidth >= 1050; + if (!wide) { + return SingleChildScrollView( + padding: EdgeInsets.all(16.w), + child: [ + _CheckoutCartPane( + controller: widget.controller, + snapshot: _snapshot, + bounded: false, + onQuantityChanged: + widget.onQuantityChanged == null ? null : _changeQuantity, + updatingProductCode: _updatingProductCode, + pendingQuantity: _pendingQuantity, ), + SizedBox(height: 16.h), + const Divider(height: 1, color: XfjDesignTokens.line), + SizedBox(height: 16.h), + _CheckoutPaymentPane( + controller: widget.controller, + snapshot: _snapshot, + paymentEnabled: currentPaymentEnabled, + disabledMessage: currentDisabledMessage, + bounded: false, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + } + return [ + _CheckoutCartPane( + controller: widget.controller, + snapshot: _snapshot, + bounded: true, + onQuantityChanged: + widget.onQuantityChanged == null ? null : _changeQuantity, + updatingProductCode: _updatingProductCode, + pendingQuantity: _pendingQuantity, + ).expanded(flex: 42), + const VerticalDivider(width: 1, color: XfjDesignTokens.line), + _CheckoutPaymentPane( + controller: widget.controller, + snapshot: _snapshot, + paymentEnabled: currentPaymentEnabled, + disabledMessage: currentDisabledMessage, + bounded: true, + ).expanded(flex: 58), + ].toRow(crossAxisAlignment: CrossAxisAlignment.stretch); + }, + ); + } +} + +/// 当前交易购物车与金额汇总面板。 +class _CheckoutCartPane extends StatelessWidget { + /// 创建可在双栏中填满高度、或在单列中限制列表高度的购物车面板。 + const _CheckoutCartPane({ + required this.controller, + required this.snapshot, + required this.bounded, + required this.onQuantityChanged, + required this.updatingProductCode, + required this.pendingQuantity, + }); + + /// 支付业务控制器,用于在识别或提交期间锁定数量操作。 + final SelectPayTypeDialogController controller; + + /// 当前交易不可变快照。 + final CashierCheckoutSnapshot snapshot; + + /// 是否处于拥有固定高度的双栏布局。 + final bool bounded; + + /// 真实购物车数量更新入口;为空时当前场景保持只读。 + final Future Function(CashierCheckoutItemSnapshot item, int quantity)? + onQuantityChanged; + + /// 正在等待业务层完成更新的商品编码;为空表示没有数量请求执行中。 + final String? updatingProductCode; + + /// 当前商品请求中的目标数量;仅用于即时展示,不作为结算数据来源。 + final int? pendingQuantity; + + /// 构建购物车标题、滚动条目和固定金额汇总。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final header = [ + [ + Text( + snapshot.cartTitle, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 3.h), + Text( + onQuantityChanged == null + ? '共 ${snapshot.productCount} 件,支付期间购物车保持锁定' + : '共 ${snapshot.productCount} 件,可在支付前调整数量', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + FIcon( + FAssets.icons.shoppingCart, + size: 26.w, + color: XfjDesignTokens.primary, + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + final list = snapshot.isEmpty + ? const _PaymentEmptyMessage(message: '购物车为空,请返回添加商品') + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: snapshot.items.length, + // 卡片化行之间使用 8px 间距代替分割线,同时满足相邻触控行的最小间隔要求。 + separatorBuilder: (_, __) => SizedBox(height: 8.h), + itemBuilder: (_, index) { + final item = snapshot.items[index]; + return _CheckoutCartItem( + key: ValueKey( + 'checkout-cart-item-${item.productServiceUniqueCode}', + ), + controller: controller, + item: item, + onQuantityChanged: onQuantityChanged, + updating: updatingProductCode == item.productServiceUniqueCode, + pendingQuantity: + updatingProductCode == item.productServiceUniqueCode + ? pendingQuantity + : null, + ); + }, + ); + final content = [ + header, + SizedBox(height: 14.h), + const Divider(height: 1, color: XfjDesignTokens.line), + SizedBox(height: 8.h), + if (bounded) list.expanded() else list.height(250.h), + SizedBox(height: 10.h), + _CheckoutAmountSummary(snapshot: snapshot), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + return ColoredBox( + key: const ValueKey('cashier-checkout-cart-pane'), + // 白底承托灰底单品卡和蓝底套餐卡,与查询菜单核销弹框的类型皮肤保持一致。 + color: XfjDesignTokens.surface, + child: content.padding(all: 18.w), + ); + } +} + +/// 购物车商品行,按快照类型选择普通行或默认展开的套餐折叠组。 +class _CheckoutCartItem extends StatelessWidget { + /// 创建单个快照条目;食堂传入数量回调时允许调整,其他场景保持只读。 + const _CheckoutCartItem({ + required this.controller, + required this.item, + required this.onQuantityChanged, + required this.updating, + this.pendingQuantity, + super.key, + }); + + /// 支付业务控制器,用于读取识别和提交锁定状态。 + final SelectPayTypeDialogController controller; + + /// 当前商品快照。 + final CashierCheckoutItemSnapshot item; + + /// 真实购物车数量更新入口;为空时不显示步进器和库存参考信息。 + final Future Function(CashierCheckoutItemSnapshot item, int quantity)? + onQuantityChanged; + + /// 当前商品的数量请求是否仍在业务层处理中。 + final bool updating; + + /// 当前商品请求中的目标数量;为空时使用快照数量。 + final int? pendingQuantity; + + /// 构建商品名称、计量信息和条目合计, + /// 并按商品类型套用共享的“蓝底蓝框/灰底灰框”卡片皮肤。 + @override + Widget build(BuildContext context) { + return ProductTypeCard( + child: _CheckoutCartItemSummary( + controller: controller, + item: item, + onQuantityChanged: onQuantityChanged, + updating: updating, + pendingQuantity: pendingQuantity, + ), + ); + } +} + +/// 购物车主商品摘要,复用稳定的名称与金额布局。 +class _CheckoutCartItemSummary extends StatelessWidget { + /// 创建普通商品摘要。 + const _CheckoutCartItemSummary({ + required this.controller, + required this.item, + required this.onQuantityChanged, + required this.updating, + this.pendingQuantity, + }); + + /// 支付业务控制器,用于在识别和提交期间为步进器提供真实禁用状态。 + final SelectPayTypeDialogController controller; + + /// 当前主商品快照。 + final CashierCheckoutItemSnapshot item; + + /// 当前商品数量更新入口;为空时保持原有只读数量展示。 + final Future Function(CashierCheckoutItemSnapshot item, int quantity)? + onQuantityChanged; + + /// 当前商品的数量请求是否执行中。 + final bool updating; + + /// 当前商品请求中的目标数量;只用于数量控件的即时显示。 + final int? pendingQuantity; + + /// 构建固定图片、商品信息和右对齐小计金额。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final editable = + onQuantityChanged != null && item.nature != ProductNature.WEIGHT; + final quantity = item.nature == ProductNature.WEIGHT + ? '${item.productWeight.toStringAsFixed(2)} kg' + : 'x${item.productNumber}'; + final stockText = item.stock == null ? '库存未知' : '库存 ${item.stock}'; + final stockUnavailable = item.stock == 0; + final productDetails = [ + _CheckoutProductImage( + key: ValueKey( + 'checkout-product-image-${item.productServiceUniqueCode}', + ), + source: item.productPic, + label: item.productName, + width: 64.w, + height: 64.h, + ), + [ + [ + Text( + item.productName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + height: 1.3, ), ).expanded(), + ].toRowSpace(space: 8.w), + SizedBox(height: 7.h), + [ + Text( + '单价 ${_paymentMoney(item.productPrice)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (!editable) + Text( + quantity, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (editable) + Text( + stockText, + key: ValueKey( + 'checkout-stock-${item.productServiceUniqueCode}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: stockUnavailable + ? XfjDesignTokens.danger + : XfjDesignTokens.muted, + fontWeight: + stockUnavailable ? FontWeight.w700 : FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toWrap(spacing: 12.w, runSpacing: 4.h), + ] + .toColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ) + .expanded(), + ].toRowSpace( + space: 12.w, + crossAxisAlignment: CrossAxisAlignment.center, + ); + return [ + productDetails.expanded(), + if (editable) + [ + _CheckoutQuantityStepper( + controller: controller, + item: item, + updating: updating, + onChanged: (value) => onQuantityChanged!(item, value), + pendingQuantity: pendingQuantity, + ), [ - Obx( - () => appWalletSelector( - context, - list: payTypeC.userWalletList, - selector: payTypeC.currentWallet.value, - onChange: (wallet) => payTypeC.appSelectorChange(wallet), - ).width(double.infinity).height(200.h).offstage( - offstage: - !(payTypeC.appSetting.sysType == SysType.SUPERMARKET && - payTypeC.userWalletList.isNotEmpty && - OrderPayDisplayType.isSelectWalletPayType( - payTypeC.orderPayDisplayType.value!)), + Text( + _paymentMoney(item.totalPrice), + key: ValueKey( + 'checkout-item-total-${item.productServiceUniqueCode}', + ), + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '商品小计', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.end).width( + 88.w < 88 ? 88 : 88.w, + ), + ].toRowSpace(space: 8.w).width(240.w < 240 ? 240 : 240.w) + else + [ + Text( + _paymentMoney(item.totalPrice), + key: ValueKey( + 'checkout-item-total-${item.productServiceUniqueCode}', + ), + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '商品小计', + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.end).width(104.w), + ] + .toRowSpace(space: 12.w, crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 8.w, vertical: 12.h); + } +} + +/// 结算商品数量步进器,使用与主购物车一致的连续分段外观。 +class _CheckoutQuantityStepper extends StatelessWidget { + /// 创建一组根据支付状态自动锁定的加减按钮。 + const _CheckoutQuantityStepper({ + required this.controller, + required this.item, + required this.updating, + required this.onChanged, + this.pendingQuantity, + }); + + /// 支付业务控制器,用于读取识别和提交状态。 + final SelectPayTypeDialogController controller; + + /// 当前商品快照,提供稳定编码和当前数量。 + final CashierCheckoutItemSnapshot item; + + /// 当前商品数量是否正在等待业务层更新。 + final bool updating; + + /// 当前商品请求中的目标数量;请求期间优先展示它,避免旧数量被 Loading 遮住。 + final int? pendingQuantity; + + /// 请求业务层更新到指定数量的回调。 + final ValueChanged onChanged; + + /// 构建视觉紧凑但按钮热区不小于 48dp 的连续分段控件。 + @override + Widget build(BuildContext context) { + return Obx(() { + final locked = controller.isSubmitting.value || + controller.isRecognition.value || + updating; + final quantity = pendingQuantity ?? item.productNumber; + final decreaseEnabled = !locked && quantity > 0; + final increaseEnabled = !locked && quantity < 100; + final touchSize = 48.w < 48 ? 48.0 : 48.w; + final visualHeight = 38.h < 38 ? 38.0 : 38.h; + final quantityWidth = 44.w < 44 ? 44.0 : 44.w; + final controlWidth = touchSize * 2 + quantityWidth; + final radius = 8.w; + final theme = FTheme.of(context); + return SizedBox( + key: ValueKey( + 'checkout-quantity-stepper-${item.productServiceUniqueCode}', + ), + width: controlWidth, + height: touchSize, + child: Stack( + alignment: Alignment.center, + children: [ + [ + SizedBox(width: touchSize), + const VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: XfjDesignTokens.line, + ), + SizedBox(width: quantityWidth - 2), + const VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: XfjDesignTokens.line, + ), + SizedBox(width: touchSize), + ] + .toRow(mainAxisSize: MainAxisSize.min) + .height(visualHeight) + .decorated( + color: locked + ? XfjDesignTokens.control.withValues(alpha: 0.7) + : XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(radius), + ), + [ + _CheckoutQuantityButton( + key: ValueKey( + 'checkout-quantity-minus-${item.productServiceUniqueCode}', + ), + icon: FAssets.icons.minus, + semanticLabel: '减少${item.productName}数量', + enabled: decreaseEnabled, + size: touchSize, + borderRadius: BorderRadius.horizontal( + left: Radius.circular(radius), + ), + onTap: decreaseEnabled ? () => onChanged(quantity - 1) : null, + ), + SizedBox( + key: ValueKey( + 'checkout-quantity-value-${item.productServiceUniqueCode}', + ), + width: quantityWidth, + height: touchSize, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + '$quantity', + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: updating + ? XfjDesignTokens.primary + : XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).center(), + if (updating) + Positioned( + key: ValueKey( + 'checkout-quantity-loading-${item.productServiceUniqueCode}', + ), + right: 3.w, + top: 3.h, + child: SizedBox( + width: 11.w, + height: 11.w, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: XfjDesignTokens.primary, + ), + ), + ), + ], + ), + ), + _CheckoutQuantityButton( + key: ValueKey( + 'checkout-quantity-plus-${item.productServiceUniqueCode}', + ), + icon: FAssets.icons.plus, + semanticLabel: '增加${item.productName}数量', + enabled: increaseEnabled, + size: touchSize, + borderRadius: BorderRadius.horizontal( + right: Radius.circular(radius), + ), + onTap: increaseEnabled ? () => onChanged(quantity + 1) : null, + ), + ].toRow(mainAxisSize: MainAxisSize.min), + ], + ), + ); + }); + } +} + +/// 连续数量步进器的图标按钮,视觉表面由父级统一绘制。 +class _CheckoutQuantityButton extends StatelessWidget { + /// 创建拥有独立语义和按压反馈的加号或减号按钮。 + const _CheckoutQuantityButton({ + required this.icon, + required this.semanticLabel, + required this.enabled, + required this.size, + required this.borderRadius, + required this.onTap, + super.key, + }); + + /// Forui 图标资源。 + final SvgAsset icon; + + /// 读屏使用的完整商品数量操作说明。 + final String semanticLabel; + + /// 当前按钮是否允许点击。 + final bool enabled; + + /// 按钮的方形触控尺寸,不小于 48dp。 + final double size; + + /// 按压状态的裁剪圆角,与分段控件外轮廓一致。 + final BorderRadius borderRadius; + + /// 启用状态下的点击回调;禁用时为空。 + final VoidCallback? onTap; + + /// 构建不改变布局的涟漪反馈,禁用态同时降低图标对比度。 + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: enabled, + label: semanticLabel, + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: borderRadius, + child: FIcon( + icon, + size: 16.w, + color: enabled ? XfjDesignTokens.ink : XfjDesignTokens.muted, + ).center().width(size).height(size), + ), + ).opacity(enabled ? 1 : 0.45), + ); + } +} + +/// 收银购物车固定尺寸图片;网络加载、失败和空地址都不会改变行高。 +class _CheckoutProductImage extends StatelessWidget { + /// 创建商品图片。 + const _CheckoutProductImage({ + required this.source, + required this.label, + required this.width, + required this.height, + super.key, + }); + + /// 服务端返回的完整 URL 或 OSS 相对地址;为空时显示名称首字。 + final String? source; + + /// 商品名称,用于无图片占位和无障碍说明。 + final String label; + + /// 图片固定宽度,单位为逻辑像素适配值。 + final double width; + + /// 图片固定高度,单位为逻辑像素适配值。 + final double height; + + /// 构建不会因图片请求状态发生布局跳动的缩略图。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final value = source?.trim(); + final trimmedLabel = label.trim(); + final shortLabel = + trimmedLabel.isEmpty ? '商' : trimmedLabel.substring(0, 1); + final placeholder = Text( + shortLabel, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w800, + ), + ).center().decorated(color: XfjDesignTokens.control); + return Semantics( + image: true, + label: '$label商品图片', + child: ClipRRect( + borderRadius: BorderRadius.circular(10.w), + child: SizedBox( + width: width, + height: height, + child: value?.isNotEmpty == true + ? CachedNetworkImage( + imageUrl: _checkoutImageUrl(value!), + fit: BoxFit.cover, + placeholder: (_, __) => placeholder, + errorWidget: (_, __, ___) => placeholder, + ) + : placeholder, + ), + ), + ); + } +} + +/// 将完整网络地址或 OSS 相对路径统一转换为可加载的结算图片地址。 +String _checkoutImageUrl(String source) { + return RegExp(r'^https?://', caseSensitive: false).hasMatch(source) + ? source + : AppNetworkTool.ossImage(source); +} + +/// 原价、优惠、附加费用和应付金额汇总。 +class _CheckoutAmountSummary extends StatelessWidget { + /// 创建当前交易金额汇总。 + const _CheckoutAmountSummary({required this.snapshot}); + + /// 当前交易不可变金额快照。 + final CashierCheckoutSnapshot snapshot; + + /// 构建使用等宽数字的金额层级。 + @override + Widget build(BuildContext context) { + return [ + _CheckoutAmountRow(label: '商品原价', amount: snapshot.originalAmount), + SizedBox(height: 8.h), + _CheckoutAmountRow( + label: '优惠', + amount: -snapshot.discountAmount.abs(), + color: XfjDesignTokens.success, + ), + SizedBox(height: 8.h), + _CheckoutAmountRow(label: '附加费用', amount: snapshot.surchargeAmount), + SizedBox(height: 12.h), + const Divider(height: 1, color: XfjDesignTokens.line), + SizedBox(height: 12.h), + _CheckoutAmountRow( + label: '应付金额', + amount: snapshot.payableAmount, + emphasized: true, + color: XfjDesignTokens.primary, + ), + ].toColumn().padding(all: 16.w).decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 金额汇总中的单行标签与数值。 +class _CheckoutAmountRow extends StatelessWidget { + /// 创建普通或强调金额行。 + const _CheckoutAmountRow({ + required this.label, + required this.amount, + this.color, + this.emphasized = false, + }); + + /// 金额业务名称。 + final String label; + + /// 金额值,单位为分;负数用于展示优惠抵扣。 + final int amount; + + /// 可选语义颜色。 + final Color? color; + + /// 是否作为应付金额使用更强字号与字重。 + final bool emphasized; + + /// 构建稳定对齐的金额行。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final style = + (emphasized ? theme.typography.lg : theme.typography.sm).copyWith( + color: + color ?? (emphasized ? XfjDesignTokens.ink : XfjDesignTokens.muted), + fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final value = + amount < 0 ? '-${_paymentMoney(amount.abs())}' : _paymentMoney(amount); + return [ + Text(label, style: style), + const Spacer(), + Text(value, style: style), + ].toRow(); + } +} + +/// 支付方式、当前工作区和底部操作区。 +class _CheckoutPaymentPane extends StatelessWidget { + /// 创建双栏固定高度或单列自适应高度的支付面板。 + const _CheckoutPaymentPane({ + required this.controller, + required this.snapshot, + required this.paymentEnabled, + required this.disabledMessage, + required this.bounded, + }); + + /// 现有支付业务控制器。 + final SelectPayTypeDialogController controller; + + /// 用于余额提示和应付金额展示的交易快照。 + final CashierCheckoutSnapshot snapshot; + + /// 当前是否允许发起支付。 + final bool paymentEnabled; + + /// 支付不可用时的场景化说明。 + final String? disabledMessage; + + /// 是否拥有固定可用高度。 + final bool bounded; + + /// 构建支付方式、工作区和固定操作按钮。 + @override + Widget build(BuildContext context) { + final body = [ + Text( + '选择支付方式', + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 12.h), + _PaymentMethodGrid(controller: controller), + _PaymentReidentificationBar(controller: controller), + SizedBox(height: 14.h), + if (bounded) + _PaymentWorkspace( + controller: controller, + snapshot: snapshot, + paymentEnabled: paymentEnabled, + disabledMessage: disabledMessage, + ).expanded() + else + _PaymentWorkspace( + controller: controller, + snapshot: snapshot, + paymentEnabled: paymentEnabled, + disabledMessage: disabledMessage, + ).height(720.h), + SizedBox(height: 14.h), + _CheckoutPaymentActions( + controller: controller, + paymentEnabled: paymentEnabled, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + return KeyedSubtree( + key: const ValueKey('cashier-checkout-payment-pane'), + child: body.padding(all: 18.w), + ); + } +} + +/// 当前支付方式对应的识别、人员、钱包和行内确认工作区。 +class _PaymentWorkspace extends StatelessWidget { + /// 创建只重建支付工作区的响应式区域。 + const _PaymentWorkspace({ + required this.controller, + required this.snapshot, + required this.paymentEnabled, + required this.disabledMessage, + }); + + /// 支付业务控制器。 + final SelectPayTypeDialogController controller; + + /// 当前交易金额快照。 + final CashierCheckoutSnapshot snapshot; + + /// 当前是否允许支付。 + final bool paymentEnabled; + + /// 空购物车等不可支付原因。 + final String? disabledMessage; + + /// 按当前局部 Rx 状态切换工作区,不重建购物车区域。 + @override + Widget build(BuildContext context) { + return Obx(() { + final pending = controller.pendingPayDisplayType.value; + final closeConfirmation = + controller.closeRecognitionConfirmationVisible.value; + if (pending != null || closeConfirmation) { + return _PaymentInlineConfirmation( + controller: controller, + pendingPayType: pending, + closingDialog: closeConfirmation, + ); + } + + final type = controller.orderPayDisplayType.value; + final user = controller.payUser.value; + final handle = controller.faceSessionHandle.value; + final faceLoading = controller.faceUserLoading.value; + final faceMessage = controller.faceWorkspaceMessage.value; + final walletList = controller.userWalletList.toList(growable: false); + final currentWallet = controller.currentWallet.value; + + if (type == OrderPayDisplayType.face && handle != null) { + final service = Get.find(); + return SingleChildScrollView( + child: FaceRecognitionPanel( + key: ValueKey(handle.sessionId), + service: service, + handle: handle, + compact: true, + showCancelAction: false, + onRecognized: (event) { + unawaited(controller.handleFaceRecognized( + event, + handle.sessionId, + )); + }, + onFailure: controller.handleFaceFailure, + onCancelled: controller.handleFaceCancelled, + ), + ); + } + if (faceLoading) { + return const _PaymentWorkspaceState( + icon: Icons.person_search_rounded, + title: '正在读取人员信息', + message: '识别已完成,正在查询人员和钱包状态', + loading: true, + ); + } + final hasRecognizedPerson = + user != null && !AppUserTool.isGenerateExternalUser(user); + if (hasRecognizedPerson) { + return SingleChildScrollView( + child: _RecognizedUserWorkspace( + controller: controller, + user: user, + wallets: walletList, + selectedWalletId: currentWallet?.id ?? user.walletId, + payableAmount: snapshot.payableAmount, + allowWalletSelection: + controller.appSetting.sysType == SysType.SUPERMARKET && + type != null && + OrderPayDisplayType.isSelectWalletPayType(type), + ), + ); + } + return SingleChildScrollView( + child: [ + _PaymentRecognitionCard(controller: controller), + if (faceMessage.isNotEmpty) ...[ + SizedBox(height: 12.h), + _PaymentWorkspaceMessage(message: faceMessage), + ], + if (!paymentEnabled) ...[ + SizedBox(height: 12.h), + _PaymentWorkspaceMessage( + message: disabledMessage ?? '请先添加商品后再继续支付', + warning: true, + ), + ], + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + }); + } +} + +/// 人脸或硬件识别成功后的人员和钱包工作区,敏感字段仅展示脱敏结果。 +class _RecognizedUserWorkspace extends StatelessWidget { + /// 创建人员信息与钱包信息并列、窄屏自动纵向排列的工作区。 + const _RecognizedUserWorkspace({ + required this.controller, + required this.user, + required this.wallets, + required this.selectedWalletId, + required this.payableAmount, + required this.allowWalletSelection, + }); + + /// 支付业务控制器,仅用于沿用已有钱包切换逻辑。 + final SelectPayTypeDialogController controller; + + /// 当前支付人员。 + final ServiceUser user; + + /// 超市接口返回的钱包列表;食堂或无列表时为空并回退到人员内嵌钱包。 + final List wallets; + + /// 当前选择的钱包 ID;为空时以人员对象中的钱包作为默认账户。 + final String? selectedWalletId; + + /// 交易应付金额,单位为分。 + final int payableAmount; + + /// 是否允许现场切换钱包;食堂和无需选钱包的支付方式固定为 false。 + final bool allowWalletSelection; + + /// 构建宽屏双区、窄屏单列的人员与钱包信息层级。 + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final personnel = _PersonnelInformationSection(user: user); + final wallet = _PersonWalletSection( + controller: controller, + user: user, + wallets: wallets, + selectedWalletId: selectedWalletId, + payableAmount: payableAmount, + allowSelection: allowWalletSelection, + ); + if (constraints.maxWidth < 720) { + return [ + personnel, + SizedBox(height: 12.h), + wallet, + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } + return [ + personnel.expanded(flex: 39), + SizedBox(width: 12.w), + wallet.expanded(flex: 61), + ].toRow(crossAxisAlignment: CrossAxisAlignment.start); + }, + ); + } +} + +/// 已识别人员的身份区域,负责头像、姓名、手机号和匹配结果。 +class _PersonnelInformationSection extends StatelessWidget { + /// 创建人员身份信息区。 + const _PersonnelInformationSection({required this.user}); + + /// 当前已核验的支付人员。 + final ServiceUser user; + + /// 构建不依赖钱包状态的人员信息,避免余额异常掩盖识别结果。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return [ + const _CheckoutSectionHeading( + icon: Icons.badge_outlined, + title: '人员信息', + trailing: '识别成功', + trailingColor: XfjDesignTokens.success, + ), + SizedBox(height: 16.h), + [ + _RecognizedUserAvatar(user: user), + SizedBox(width: 14.w), + [ + Text( + user.username?.trim().isNotEmpty == true + ? user.username!.trim() + : '已识别人员', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 5.h), + Text( + _maskMobile(user.userMobile), + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + SizedBox(height: 16.h), + [ + Icon( + Icons.verified_user_outlined, + size: 18.w, + color: XfjDesignTokens.success, + ), + SizedBox(width: 8.w), + Text( + '已匹配本地人员,可继续核对钱包', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.start) + .padding(all: 16.w) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 已识别人员头像,图片缺失或加载失败时使用稳定的人员图标占位。 +class _RecognizedUserAvatar extends StatelessWidget { + /// 创建固定尺寸的人员头像。 + const _RecognizedUserAvatar({required this.user}); + + /// 提供头像地址与无障碍名称的人员对象。 + final ServiceUser user; + + /// 构建不会因网络图片状态改变尺寸的圆形头像。 + @override + Widget build(BuildContext context) { + final source = user.userAvatar?.trim(); + final placeholder = FIcon( + FAssets.icons.user, + size: 34.w, + color: XfjDesignTokens.primary, + ).center().decorated(color: XfjDesignTokens.primarySoft); + return Semantics( + image: true, + label: '${user.username ?? '已识别人员'}头像', + child: ClipOval( + child: SizedBox( + width: 64.w, + height: 64.w, + child: source?.isNotEmpty == true + ? CachedNetworkImage( + imageUrl: _checkoutImageUrl(source!), + fit: BoxFit.cover, + placeholder: (_, __) => placeholder, + errorWidget: (_, __, ___) => placeholder, + ) + : placeholder, + ), + ), + ); + } +} + +/// 人员钱包区域,兼容食堂内嵌钱包和超市多钱包选择两种既有数据来源。 +class _PersonWalletSection extends StatelessWidget { + /// 创建钱包余额、卡号、状态和可选账户列表。 + const _PersonWalletSection({ + required this.controller, + required this.user, + required this.wallets, + required this.selectedWalletId, + required this.payableAmount, + required this.allowSelection, + }); + + /// 支付业务控制器,仅用于调用现有钱包选择方法。 + final SelectPayTypeDialogController controller; + + /// 当前人员及其内嵌默认钱包数据。 + final ServiceUser user; + + /// 超市钱包接口返回的账户;为空时展示人员内嵌钱包。 + final List wallets; + + /// 当前钱包 ID,用于多钱包列表的选中态。 + final String? selectedWalletId; + + /// 当前订单应付金额,单位为分。 + final int payableAmount; + + /// 是否允许点击账户切换当前支付钱包。 + final bool allowSelection; + + /// 在钱包列表中查找当前账户,找不到时回退到第一项或人员内嵌钱包。 + ServiceWallet? get _activeWallet { + if (wallets.isEmpty) return null; + for (final wallet in wallets) { + if (wallet.id == selectedWalletId) return wallet; + } + return wallets.first; + } + + /// 构建钱包摘要和账户列表,冻结及余额不足都使用文字与颜色双重提示。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final activeWallet = _activeWallet; + final balance = activeWallet?.balance ?? user.walletBalance; + final frozen = activeWallet?.isFreeze ?? user.walletIsFreeze; + final cardNumber = activeWallet?.cardNumber ?? user.walletCardNumber; + final insufficient = balance != null && balance < payableAmount; + final unavailable = frozen == true || insufficient; + final statusColor = unavailable + ? XfjDesignTokens.danger + : frozen == null + ? XfjDesignTokens.muted + : XfjDesignTokens.success; + final statusText = frozen == true + ? '钱包已冻结' + : insufficient + ? '余额不足' + : frozen == null + ? '状态未返回' + : '钱包可用'; + final walletRows = wallets.isEmpty + ? [ + _PersonWalletRow( + key: const ValueKey('recognized-user-embedded-wallet'), + balance: user.walletBalance, + cardNumber: user.walletCardNumber, + frozen: user.walletIsFreeze, + selected: true, + onTap: null, + ), + ] + : wallets + .map( + (wallet) => _PersonWalletRow( + key: ValueKey('recognized-user-wallet-${wallet.id}'), + balance: wallet.balance, + cardNumber: wallet.cardNumber, + frozen: wallet.isFreeze, + selected: wallet.id == (activeWallet?.id ?? selectedWalletId), + onTap: allowSelection + ? () => controller.appSelectorChange(wallet) + : null, + ), + ) + .toList(growable: false); + return [ + _CheckoutSectionHeading( + icon: Icons.account_balance_wallet_outlined, + title: '人员钱包', + trailing: statusText, + trailingColor: statusColor, + ), + SizedBox(height: 14.h), + [ + [ + Text( + '可用余额', + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 4.h), + Text( + balance == null ? '未返回' : _paymentMoney(balance), + key: const ValueKey('recognized-user-wallet-balance'), + style: theme.typography.xl.copyWith( + color: unavailable ? XfjDesignTokens.danger : XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + [ + Text( + '本次应付', + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 4.h), + Text( + _paymentMoney(payableAmount), + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.end), + ].toRow(crossAxisAlignment: CrossAxisAlignment.end), + SizedBox(height: 8.h), + Text( + '饭卡 ${_maskCardNumber(cardNumber)}', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 12.h), + const Divider(height: 1, color: XfjDesignTokens.line), + SizedBox(height: 8.h), + if (wallets.length > 1) + Text( + allowSelection ? '选择支付账户' : '人员账户', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).padding(bottom: 4.h), + ...walletRows, + if (unavailable) ...[ + SizedBox(height: 8.h), + _PaymentWorkspaceMessage( + message: frozen == true + ? '当前钱包已冻结,不能提交支付,请更换人员或支付方式' + : '当前钱包余额不足,请充值或更换支付方式', + warning: true, + ), + ], + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(all: 16.w) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 人员钱包列表中的单个账户行,选中状态不改变整体尺寸。 +class _PersonWalletRow extends StatelessWidget { + /// 创建一个只读或可切换的钱包账户行。 + const _PersonWalletRow({ + required this.balance, + required this.cardNumber, + required this.frozen, + required this.selected, + required this.onTap, + super.key, + }); + + /// 钱包余额,单位为分;为空表示接口未返回。 + final int? balance; + + /// 饭卡号;展示时仅保留末四位。 + final String? cardNumber; + + /// 冻结状态;为空表示当前数据源没有返回状态。 + final bool? frozen; + + /// 是否为当前提交支付使用的钱包。 + final bool selected; + + /// 钱包切换回调;为空时该行仅供核对。 + final VoidCallback? onTap; + + /// 构建包含选中、余额、卡号及冻结状态的稳定账户行。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final stateText = frozen == true + ? '已冻结' + : frozen == false + ? '正常' + : '状态未知'; + final stateColor = frozen == true + ? XfjDesignTokens.danger + : frozen == false + ? XfjDesignTokens.success + : XfjDesignTokens.muted; + final row = [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + size: 19.w, + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + ), + SizedBox(width: 9.w), + [ + Text( + _maskCardNumber(cardNumber), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + Text( + stateText, + style: theme.typography.xs.copyWith(color: stateColor), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + Text( + balance == null ? '未返回' : _paymentMoney(balance!), + style: theme.typography.sm.copyWith( + color: frozen == true ? XfjDesignTokens.danger : XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 10.w, vertical: 8.h) + .decorated( + color: + selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(8.w), + ); + return Padding( + padding: EdgeInsets.only(top: 4.h), + child: onTap == null + ? row + : Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8.w), + clipBehavior: Clip.antiAlias, + child: InkWell(onTap: onTap, child: row), + ), + ); + } +} + +/// 收银工作区的分区标题,统一图标、标题和状态提示的对齐方式。 +class _CheckoutSectionHeading extends StatelessWidget { + /// 创建带可选右侧状态的分区标题。 + const _CheckoutSectionHeading({ + required this.icon, + required this.title, + required this.trailing, + required this.trailingColor, + }); + + /// 分区语义图标。 + final IconData icon; + + /// 分区标题。 + final String title; + + /// 右侧状态文案。 + final String trailing; + + /// 右侧状态颜色。 + final Color trailingColor; + + /// 构建固定单行的分区标题与状态。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return [ + Icon(icon, size: 20.w, color: XfjDesignTokens.primary), + SizedBox(width: 8.w), + Text( + title, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ).expanded(), + Text( + trailing, + style: theme.typography.xs.copyWith( + color: trailingColor, + fontWeight: FontWeight.w800, + ), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } +} + +/// 识别关闭或支付方式切换的行内确认区域。 +class _PaymentInlineConfirmation extends StatelessWidget { + /// 创建关闭弹框或切换方式确认区域。 + const _PaymentInlineConfirmation({ + required this.controller, + required this.pendingPayType, + required this.closingDialog, + }); + + /// 支付业务控制器。 + final SelectPayTypeDialogController controller; + + /// 等待切换的支付方式;关闭弹框时为空。 + final OrderPayDisplayType? pendingPayType; + + /// 是否在确认关闭整个支付弹框。 + final bool closingDialog; + + /// 构建不会叠加新路由的确认说明和两个明确操作。 + @override + Widget build(BuildContext context) { + final target = pendingPayType == null + ? '' + : '“${_paymentMethodLabel(pendingPayType!)}”'; + return [ + Icon( + Icons.warning_amber_rounded, + color: XfjDesignTokens.warning, + size: 44.w, + ), + SizedBox(height: 14.h), + Text( + closingDialog ? '正在识别,确定取消并关闭?' : '正在识别,确定取消并切换到$target?', + textAlign: TextAlign.center, + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + Text( + '取消当前识别会释放摄像头会话,已识别但未提交的人员信息不会用于支付。', + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 22.h), + [ + _PaymentDialogButton( + label: '继续识别', + onTap: controller.continueCurrentRecognition, + soft: true, + ).expanded(), + SizedBox(width: 12.w), + _PaymentDialogButton( + label: closingDialog ? '取消识别并关闭' : '取消识别并切换', + onTap: closingDialog + ? controller.confirmRecognitionAndClose + : controller.confirmRecognitionAndSwitch, + ).expanded(), + ].toRow(), + ] + .toColumn(mainAxisAlignment: MainAxisAlignment.center) + .padding(all: 24.w) + .decorated( + color: const Color(0xFFFFFBEB), + border: Border.all( + color: XfjDesignTokens.warning.withValues(alpha: 0.4), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 支付工作区的加载或空状态。 +class _PaymentWorkspaceState extends StatelessWidget { + /// 创建带图标、标题和说明的工作区状态。 + const _PaymentWorkspaceState({ + required this.icon, + required this.title, + required this.message, + this.loading = false, + }); + + /// 状态图标。 + final IconData icon; + + /// 状态标题。 + final String title; + + /// 状态恢复或等待说明。 + final String message; + + /// 是否展示持续进度指示器。 + final bool loading; + + /// 构建垂直居中的状态反馈。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return [ + if (loading) + SizedBox( + width: 42.w, + height: 42.w, + child: const CircularProgressIndicator(strokeWidth: 3), + ) + else + Icon(icon, color: XfjDesignTokens.primary, size: 46.w), + SizedBox(height: 16.h), + Text( + title, + textAlign: TextAlign.center, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + Text( + message, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ] + .toColumn(mainAxisAlignment: MainAxisAlignment.center) + .padding(all: 24.w) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 支付工作区中的错误或禁用说明。 +class _PaymentWorkspaceMessage extends StatelessWidget { + /// 创建普通错误或黄色前置条件提示。 + const _PaymentWorkspaceMessage({required this.message, this.warning = false}); + + /// 面向收银员的原因和恢复方向。 + final String message; + + /// 是否使用警告语义而不是错误语义。 + final bool warning; + + /// 构建图标和文字并列的提示区域。 + @override + Widget build(BuildContext context) { + final color = warning ? XfjDesignTokens.warning : XfjDesignTokens.danger; + return [ + Icon( + warning ? Icons.warning_amber_rounded : Icons.error_outline_rounded, + color: color, + size: 22.w, + ), + SizedBox(width: 10.w), + Text( + message, + style: FTheme.of(context).typography.sm.copyWith( + color: warning ? const Color(0xFF8C6A00) : color, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding( + horizontal: 14.w, + vertical: 12.h, + ) + .decorated( + color: warning + ? const Color(0xFFFFF9E8) + : XfjDesignTokens.danger.withValues(alpha: 0.06), + border: Border.all(color: color.withValues(alpha: 0.35)), + borderRadius: BorderRadius.circular(10), + ); + } +} + +/// 支付弹框固定底部的取消和主操作按钮。 +class _CheckoutPaymentActions extends StatelessWidget { + /// 创建读取最小 Rx 集合的底部操作区。 + const _CheckoutPaymentActions({ + required this.controller, + required this.paymentEnabled, + }); + + /// 支付业务控制器。 + final SelectPayTypeDialogController controller; + + /// 当前是否允许发起支付。 + final bool paymentEnabled; + + /// 按人员、识别和提交状态切换按钮文案及可用性。 + @override + Widget build(BuildContext context) { + return Obx(() { + final snapshotRevision = controller.checkoutSnapshotRevision.value; + final submitting = controller.isSubmitting.value; + final recognizing = controller.isRecognition.value; + final hasUser = controller.payUser.value != null; + final selectedType = controller.orderPayDisplayType.value; + final qrCodeScanSelected = selectedType == OrderPayDisplayType.qrCodeScan; + final label = submitting + ? '支付提交中...' + : recognizing + ? '识别进行中' + : qrCodeScanSelected + ? '发起扫码支付' + : hasUser + ? '确认支付 ${_paymentMoney(controller.checkoutSnapshot.payableAmount)}' + : '开始识别'; + return KeyedSubtree( + key: ValueKey('checkout-payment-actions-$snapshotRevision'), + child: [ + _PaymentDialogButton( + label: '取消', + onTap: submitting ? null : controller.doCancel, + soft: true, + ).expanded(), + SizedBox(width: 12.w), + _PaymentDialogButton( + label: label, + onTap: !paymentEnabled || recognizing || submitting + ? null + : qrCodeScanSelected + ? controller.doRecognition + : hasUser + ? controller.doSubmit + : controller.doRecognition, + ).expanded(flex: 2), + ].toRow(), + ); + }); + } +} + +/// 脱敏手机号;不足七位时不展示原值。 +String _maskMobile(String? value) { + final mobile = value?.trim() ?? ''; + if (mobile.length < 7) return mobile.isEmpty ? '未绑定手机号' : '手机号已隐藏'; + return '${mobile.substring(0, 3)}****${mobile.substring(mobile.length - 4)}'; +} + +/// 脱敏饭卡号,仅保留末四位用于现场核对。 +String _maskCardNumber(String? value) { + final card = value?.trim() ?? ''; + if (card.isEmpty) return '未绑定'; + if (card.length <= 4) return '****'; + return '**** ${card.substring(card.length - 4)}'; +} + +/// 支付方式固定四列网格,每个选项在同一行内保持完全等宽。 +class _PaymentMethodGrid extends StatelessWidget { + /// 创建仅读取支付方式与识别状态的局部响应式网格。 + const _PaymentMethodGrid({required this.controller}); + + /// 提供可用支付方式、当前选择和识别锁定状态的控制器。 + final SelectPayTypeDialogController controller; + + /// 使用四列和统一高度构建支付方式,最后一行不足四项时保留空余列宽。 + @override + Widget build(BuildContext context) { + // 在真正读取 Rx 的网格子树内监听,避免外层 Obx 只创建子组件而触发 GetX 误用检查。 + return Obx( + () { + final methods = controller.usePayDisplayTypeList.toList( + growable: false, + ); + final selectedType = controller.orderPayDisplayType.value; + final recognition = controller.isRecognition.value; + if (methods.isEmpty) { + return const _PaymentEmptyMessage( + message: '暂无可用支付方式,请前往设置检查支付配置', + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: methods.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + // 紧凑视觉高度仍保留 Android 终端至少 48dp 的真实触控区域。 + mainAxisExtent: 58.h < 48 ? 48 : 58.h, + ), + itemBuilder: (context, index) { + final method = methods[index]; + return _PaymentMethodButton( + type: method, + selected: selectedType == method, + enabled: !recognition, + onTap: () => controller.selectPayDisplayType(method), + ); + }, + ); + }, + ); + } +} + +/// 支付方式下方的独立重新识别操作条,避免次级操作挤占人员信息标题空间。 +class _PaymentReidentificationBar extends StatelessWidget { + /// 创建跟随当前支付方式切换识别设备和文案的操作条。 + const _PaymentReidentificationBar({required this.controller}); + + /// 提供当前支付方式、配置开关及识别状态的支付控制器。 + final SelectPayTypeDialogController controller; + + /// 构建仅在当前支付方式允许重新识别时出现的自适应操作条。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Obx(() { + final payType = controller.orderPayDisplayType.value; + final method = controller.currentReidentificationMethod; + if (payType == null || + method == null || + !controller.canReidentifyCurrentPayType) { + return const SizedBox.shrink(); + } + final recognition = controller.isRecognition.value; + final reidentifying = controller.isReidentifying.value; + final submitting = controller.isSubmitting.value; + final usesFace = method == OrderPayDisplayType.face; + final actionLabel = usesFace ? '重新人脸识别' : '重新刷饭卡'; + final detail = reidentifying + ? usesFace + ? '请正视摄像头,完成后将替换当前人员' + : '请将饭卡放到读卡器,完成后将替换当前人员' + : usesFace + ? '当前支付方式将使用人脸重新确认人员' + : '当前支付方式将使用饭卡重新确认人员'; + final information = [ + FIcon( + _paymentMethodIcon(method), + size: 22.w, + color: XfjDesignTokens.primary, + ).center().width(44.w).height(44.w).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + SizedBox(width: 12.w), + [ + Text( + '重新识别当前人员', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 3.h), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + final action = _ReidentifyButton( + label: actionLabel, + running: reidentifying, + enabled: !recognition && !submitting, + onTap: controller.reidentifyCurrentUser, + ); + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + final content = compact + ? [ + information, + SizedBox(height: 10.h), + action.width(double.infinity), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + : [ + information.expanded(), + SizedBox(width: 16.w), + action, + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + return KeyedSubtree( + key: const ValueKey('cashier-reidentification-bar'), + child: content.padding(horizontal: 14.w, vertical: 10.h).decorated( + color: XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + }, + ).padding(top: 10.h); + }); + } +} + +/// 固定网格中的单个支付方式按钮。 +class _PaymentMethodButton extends StatelessWidget { + /// 创建带选择态和禁用态的等宽支付方式按钮。 + const _PaymentMethodButton({ + required this.type, + required this.selected, + required this.enabled, + required this.onTap, + }); + + /// 既有支付方式枚举及其跨端业务编码。 + final OrderPayDisplayType type; + + /// 当前按钮是否是提交支付使用的选中方式。 + final bool selected; + + /// 识别会话未进行时为 true,允许用户切换方式。 + final bool enabled; + + /// 用户选择当前支付方式时调用的控制器回调。 + final VoidCallback onTap; + + /// 构建不改变宽高的默认、选中和禁用视觉状态。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final animationDuration = + reduceMotion ? Duration.zero : const Duration(milliseconds: 220); + final foreground = selected + ? XfjDesignTokens.primary + : enabled + ? XfjDesignTokens.ink + : XfjDesignTokens.muted; + final borderRadius = BorderRadius.circular(XfjDesignTokens.controlRadius); + return Semantics( + button: true, + selected: selected, + enabled: enabled, + label: '${_paymentMethodLabel(type)}支付方式', + child: AnimatedContainer( + key: ValueKey('payment-method-${type.name}'), + duration: animationDuration, + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: + selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + width: selected ? 1.5 : 1, + ), + borderRadius: borderRadius, + ), + child: Material( + color: Colors.transparent, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: borderRadius, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (!enabled) return Colors.transparent; + if (states.contains(WidgetState.pressed)) { + return XfjDesignTokens.primary.withValues(alpha: 0.12); + } + return null; + }), + child: [ + FIcon( + _paymentMethodIcon(type), + size: 22.w, + color: foreground, + ), + SizedBox(width: 8.w), + Text( + _paymentMethodLabel(type), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ).expanded(), + AnimatedSwitcher( + duration: animationDuration, + child: selected + ? Container( + key: const ValueKey('selected'), + width: 18.w, + height: 18.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.primary, + shape: BoxShape.circle, + ), + child: FIcon( + FAssets.icons.check, + size: 11.w, + color: Colors.white, + ), + ) + : SizedBox( + key: const ValueKey('unselected'), + width: 18.w, + height: 18.w, + ), + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 12.w), + ), + ), + ), + ); + } +} + +/// 尚未识别到人员时的支付方式操作提示。 +class _PaymentRecognitionCard extends StatelessWidget { + /// 创建当前支付方式对应的提示区。 + const _PaymentRecognitionCard({required this.controller}); + + /// 提供支付方式、识别状态和人员结果的控制器。 + final SelectPayTypeDialogController controller; + + /// 构建支付提示;线下外部用户不作为真实人员显示。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Obx( + () { + final payUser = controller.payUser.value; + final user = + payUser != null && !AppUserTool.isGenerateExternalUser(payUser) + ? payUser + : null; + final type = controller.orderPayDisplayType.value; + final recognition = controller.isRecognition.value; + return Container( + constraints: BoxConstraints(minHeight: 96.h), + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user == null + ? _paymentPromptTitle(type) + : user.username ?? '已识别用户', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6.h), + Text( + user == null + ? _paymentPromptDescription(type) + : '余额 ${_paymentMoney(user.walletBalance ?? 0)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + SizedBox(width: 12.w), + recognition + ? const SizedBox( + width: 38, + height: 38, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : FIcon( + _paymentMethodIcon(type), + size: 44.w, + color: XfjDesignTokens.muted, + ), + ], + ), + ); + }, + ); + } +} + +/// 收银工作区统一的重新识别次级按钮,固定触控尺寸且不会与主支付操作争夺层级。 +class _ReidentifyButton extends StatelessWidget { + /// 创建带识别中反馈和禁用语义的重新识别按钮。 + const _ReidentifyButton({ + required this.label, + required this.running, + required this.enabled, + required this.onTap, + }); + + /// 当前支付方式对应的重新识别动作文案。 + final String label; + + /// 是否正在执行重新识别,用于切换文案和进度图标。 + final bool running; + + /// 当前是否允许点击;任一识别或支付提交进行中时为 false。 + final bool enabled; + + /// 发起当前支付方式对应重新识别的控制器回调。 + final VoidCallback onTap; + + /// 构建至少 48dp 高的 Forui 轮廓按钮,并通过文字说明当前状态。 + @override + Widget build(BuildContext context) { + final minimumHeight = 48.h < 48 ? 48.0 : 48.h; + return Semantics( + button: true, + enabled: enabled, + label: running ? '正在$label' : '$label当前支付人员', + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minimumHeight), + child: FButton( + key: const ValueKey('cashier-reidentify-user'), + style: FButtonStyle.outline, + prefix: running + ? SizedBox( + width: 18.w, + height: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon( + FAssets.icons.refreshCw, + size: 18.w, + color: XfjDesignTokens.primary, + ), + label: Text(running ? '识别中' : label), + onPress: enabled ? onTap : null, + ), + ), + ); + } +} + +class _PaymentDialogButton extends StatelessWidget { + const _PaymentDialogButton({ + required this.label, + required this.onTap, + this.soft = false, + }); + + final String label; + final VoidCallback? onTap; + final bool soft; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final foreground = !enabled + ? XfjDesignTokens.muted + : soft + ? XfjDesignTokens.ink + : Colors.white; + return Material( + color: !enabled + ? XfjDesignTokens.control + : soft + ? XfjDesignTokens.control + : XfjDesignTokens.primary, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 56.h, + child: Center( + child: Text( + label, + style: FTheme.of(context).typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w700, ), ), - Obx( - () => Visibility( - visible: payTypeC.payUser.value == null, - replacement: selectPayTypeUser( - payTypeC.payUser.value, - payTypeC.orderPayType.value, - ), - child: selectPayTypeHint( - context, - type: payTypeC.orderPayDisplayType.value, - isLoading: payTypeC.isRecognition.value, - ), - ), - ).flexible(flex: 1), - ] - .toColumn() - .paddingAll(5.w) - .decorated( - border: Border.all( - color: theme.colorScheme.primary, - width: 1, - ), - borderRadius: theme.style.borderRadius / 3, - ) - .expanded(), - ].toRow().width(double.infinity).height(dialogHeight), - actions: [ - [ - Obx( - () => FButton( - label: Text( - payTypeC.payUser.value == null ? "开始识别" : "确定支付", - style: theme.typography.xl.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: payTypeC.isRecognition.value - ? null - : () { - if (payTypeC.payUser.value == null) { - payTypeC.doRecognition(); - } else { - payTypeC.doSubmit(); - } - }, - ), - ).flexible(flex: 1), - SizedBox(width: 20.w), - FButton( - style: FButtonStyle.destructive, - label: Text( - "取消", - style: theme.typography.xl.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () { - payTypeC.doCancel(); - }, - ).flexible(flex: 1), - ].toRow(), - ], - ); - }, - ); + ), + ), + ), + ); + } } -Widget selectPayTypeUser(ServiceUser? user, OrderPayType? payType) { - final theme = FTheme.of(Get.context!); - if (user == null) return const SizedBox.shrink(); - final isFree = user.walletIsFreeze ?? true; - final textStyle = theme.typography.xl; - final titleTheme = theme.tileGroupStyle.tileStyle.copyWith( - border: Border.all( - color: Colors.transparent, - ), - ); - // 判断是否为`跨单位二维码支付`或者`外来人员二维码支付` - payType ??= OrderPayType.FACE; - final isStrideOrForeign = OrderPayType.isStrideOrForeign(payType); - final widgets = [ - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.user), - title: Text('姓名', style: textStyle), - details: Text("${user.username}", style: textStyle), - ), - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.smartphone), - title: Text('手机号', style: textStyle), - details: Text("${user.userMobile}", style: textStyle), - ), - ]; - if (isStrideOrForeign) { - widgets.addAll([ - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.stretchHorizontal), - title: Text('支付类型', style: textStyle), - details: Text(payType.title, style: textStyle), +class _PaymentEmptyMessage extends StatelessWidget { + const _PaymentEmptyMessage({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(12), ), - ]); - } else { - widgets.addAll([ - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.badgeDollarSign), - title: Text('余额', style: textStyle), - details: appPrice(Get.context!, user.walletBalance), + child: Text( + message, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), ), - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.idCard), - title: Text('卡号', style: textStyle), - details: Text("${user.walletCardNumber}", style: textStyle), - ).offstage(offstage: ObjectUtil.isEmpty(user.walletCardNumber)), - FTile( - style: titleTheme, - prefixIcon: FIcon(FAssets.icons.ban), - title: Text('是否冻结', style: textStyle), - details: Text(isFree ? "是" : "否", style: textStyle), - ).offstage(offstage: user.walletIsFreeze == null), - ]); + ); } - return widgets.toColumn(mainAxisAlignment: MainAxisAlignment.start); } -Widget selectPayTypeHint( - BuildContext context, { - OrderPayDisplayType? type, - bool isLoading = false, -}) { - if (type == null) return const SizedBox.shrink(); - const baseMsg = "请点击开始识别按钮"; - String msg = ""; - SvgAsset icon = FAssets.icons.bird; - switch (type) { - case OrderPayDisplayType.qrCode: - msg = "$baseMsg,并让用户出示二维码"; - icon = FAssets.icons.qrCode; - break; - case OrderPayDisplayType.walletCard: - msg = "$baseMsg,并让用户刷饭卡"; - icon = FAssets.icons.idCard; - break; - case OrderPayDisplayType.face: - msg = "$baseMsg,并让用户进行刷脸"; - icon = FAssets.icons.scanFace; - break; - case OrderPayDisplayType.cash: - msg = "请打开钱箱,放入现金"; - icon = FAssets.icons.circleDollarSign; - break; - case OrderPayDisplayType.zyy: - msg = "中医院支付,请点击支付按钮"; - icon = FAssets.icons.circleDollarSign; - break; - case OrderPayDisplayType.rmyy: - msg = "人民医院支付,请点击支付按钮"; - icon = FAssets.icons.circleDollarSign; - break; - case OrderPayDisplayType.csxfk: - msg = "超市消费卡支付,请点击支付按钮"; - icon = FAssets.icons.circleDollarSign; - break; - case OrderPayDisplayType.qrCodeScan: - msg = '二维码扫一扫支付,请让用户打开"诗画浦江"或"浙政钉"扫一扫'; - icon = FAssets.icons.scanLine; - break; - } - return appEmpty( - context, - loading: isLoading, - loadingText: "识别中", - emptyText: msg, - emptyIcon: icon, - ).height(double.infinity); +SvgAsset _paymentMethodIcon(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => FAssets.icons.idCard, + OrderPayDisplayType.qrCode => FAssets.icons.qrCode, + OrderPayDisplayType.qrCodeScan => FAssets.icons.scanLine, + OrderPayDisplayType.face => FAssets.icons.scanFace, + _ => FAssets.icons.circleDollarSign, + }; } + +String _paymentMethodLabel(OrderPayDisplayType type) { + return switch (type) { + OrderPayDisplayType.walletCard => '饭卡', + OrderPayDisplayType.qrCode => '付款码', + OrderPayDisplayType.qrCodeScan => '扫一扫', + OrderPayDisplayType.face => '人脸', + OrderPayDisplayType.cash => '现金', + _ => type.title, + }; +} + +String _paymentPromptTitle(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => '请刷饭卡', + OrderPayDisplayType.qrCode => '请出示付款码', + OrderPayDisplayType.qrCodeScan => '请发起扫码支付', + OrderPayDisplayType.face => '请进行人脸识别', + OrderPayDisplayType.cash => '请收取现金', + _ => '请按提示完成支付', + }; +} + +String _paymentPromptDescription(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => '请在读卡器上刷卡', + OrderPayDisplayType.qrCode => '请使用扫码枪读取用户付款码', + OrderPayDisplayType.qrCodeScan => '请让用户使用钱包 App 扫描副屏二维码完成支付', + OrderPayDisplayType.face => '请让用户正视摄像头', + OrderPayDisplayType.cash => '确认收款后提交支付', + _ => '选择支付方式后继续', + }; +} + +String _paymentMoney(int amount) => '¥ ${(amount / 100).toStringAsFixed(2)}'; + diff --git a/lib/pages/canteen_order_total/view.dart b/lib/pages/canteen_order_total/view.dart index a151d37..27cb914 100644 --- a/lib/pages/canteen_order_total/view.dart +++ b/lib/pages/canteen_order_total/view.dart @@ -157,9 +157,8 @@ class CanteenOrderTotalPage extends GetView { id: "canteen_order_total", builder: (_) { return FScaffold( - // 标题与返回入口由终端外壳顶栏统一提供。 - header: FHeader(title: SizedBox.shrink()), - content: _buildView(context).marginOnly(top: 20.h), + // 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。 + content: _buildView(context), resizeToAvoidBottomInset: false, ); }, diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index e3a5def..8f28c6c 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -129,9 +129,40 @@ class CanteenProductController extends GetxController with OrderMixin { Future doPay() async { final validShopCar = _validateShopCar(); if (!validShopCar) return; + final snapshot = _createCheckoutSnapshot(); // 创建本地订单 await createSqlOrder(); - selectPayTypeDialog(); + await selectPayTypeDialog( + snapshot: snapshot, + onQuantityChanged: _changeCheckoutQuantity, + ); + } + + /// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额的展示快照。 + /// + /// 弹框只负责触发操作,购物车、餐费和副屏仍由当前页面 Controller 统一维护。 + Future _changeCheckoutQuantity( + String productServiceUniqueCode, + int quantity, + ) async { + final index = shopCardList.indexWhere( + (item) => item.productServiceUniqueCode == productServiceUniqueCode, + ); + if (index < 0) return _createCheckoutSnapshot(); + await doComputedInputChange(quantity, index); + return _createCheckoutSnapshot(); + } + + /// 在打开支付弹框前冻结菜品和整数分金额,避免背景状态影响本次结算展示。 + CashierCheckoutSnapshot _createCheckoutSnapshot() { + return CashierCheckoutSnapshot.capture( + cartTitle: '当前已点菜品', + cartItems: shopCardList.toList(growable: false), + originalAmount: totalPrice.value, + discountAmount: 0, + surchargeAmount: amountMealPrice.value, + payableAmount: totalPrice.value + amountMealPrice.value, + ); } // |下单|点击取消下单 @@ -141,7 +172,7 @@ class CanteenProductController extends GetxController with OrderMixin { } // |+|或者|-|点击更改商品数量 - void doComputedInputChange(int number, int index) async { + Future doComputedInputChange(int number, int index) async { final current = shopCardList[index]; current.productNumber = number; current.totalPrice = _computedProductTotalPrice( @@ -211,6 +242,7 @@ class CanteenProductController extends GetxController with OrderMixin { orderPayType, orderPayDisplayType, qrCode, + faceRecognition, ) async { final log = "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; @@ -221,9 +253,9 @@ class CanteenProductController extends GetxController with OrderMixin { this.globalQrCode = qrCode; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return; + if (!validPayUser) return false; final validShopCar = _validateShopCar(); - if (!validShopCar) return; + if (!validShopCar) return false; EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black, @@ -231,10 +263,14 @@ class CanteenProductController extends GetxController with OrderMixin { // 最后计算一次额外的价格 await _computedAmountMealPrice(); await updateSqlOrder(); - await createApiOrder(); + final isNetwork = AppNetworkTool.isAutoNetwork(); + final orderResult = await createApiOrder(); putOffSendSubScreen(); _clearShopCard(); EasyLoading.dismiss(); + // 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。 + if (isNetwork && orderResult == null) return false; + return true; }; // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; diff --git a/lib/pages/canteen_reserve/view.dart b/lib/pages/canteen_reserve/view.dart index 8caf58e..2a06706 100644 --- a/lib/pages/canteen_reserve/view.dart +++ b/lib/pages/canteen_reserve/view.dart @@ -227,8 +227,7 @@ class CanteenReservePage extends GetView { id: "canteen_reserve", builder: (_) { return FScaffold( - // 标题与返回入口由终端外壳顶栏统一提供。 - header: FHeader(title: SizedBox.shrink()), + // 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。 content: _buildView(context), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/order_history/view.dart b/lib/pages/order_history/view.dart index 1f1c1d2..93a0a4d 100644 --- a/lib/pages/order_history/view.dart +++ b/lib/pages/order_history/view.dart @@ -261,7 +261,7 @@ class OrderHistoryPage extends GetView { id: "order_history", builder: (_) { return FScaffold( - header: FHeader(title: SizedBox.shrink()), + // 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。 content: _buildView(context), contentPad: true, resizeToAvoidBottomInset: false, diff --git a/lib/pages/order_history_detail/view.dart b/lib/pages/order_history_detail/view.dart index 644a8b5..e3d5d1c 100644 --- a/lib/pages/order_history_detail/view.dart +++ b/lib/pages/order_history_detail/view.dart @@ -216,7 +216,6 @@ class OrderHistoryDetailPage extends GetView { @override Widget build(BuildContext context) { final theme = FTheme.of(context); - final height = context.height; // 内层路由的参数挂在 RouteSettings 上,首次构建时交给 Controller 拉取详情。 final routeArguments = ModalRoute.of(context)?.settings.arguments; if (routeArguments is Map) { @@ -240,7 +239,7 @@ class OrderHistoryDetailPage extends GetView { size: theme.typography.xl2.fontSize, ), ), - ).height(height * 0.1), + ), content: _buildView(context), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index e31966e..a8c2047 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -16,18 +16,43 @@ class SettingController extends GetxController { Rx userSetting = Rx(null); + /// 是否使用人工输入代替扫码、读卡和人脸等真实硬件输入。 + final manualHardwareInput = false.obs; + + /// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。 + final kioskStatus = AppRuntimeTool.kioskStatus.obs; + + /// Root 手动刷新是否正在执行,用于禁用重复点击并展示局部进度。 + final rootRefreshing = false.obs; + + /// Root 一键配置 Device Owner 是否正在执行,命令期间禁止重复提交。 + final kioskProvisioning = false.obs; + + /// Kiosk 开关是否正在同步原生 Lock Task,防止连续点击形成状态反转。 + final kioskSwitching = false.obs; + + /// 最近一次 Root 配置 Kiosk 的失败处理建议;成功或重新加载设置后清空。 + final kioskProvisionMessage = ''.obs; + // 全局文字大小 var globalFontSizeC = FRadioSelectGroupController( value: SettingGlobalFontSize.xl3.code, ); - // 付款方式 - var globalPayTableC = FMultiSelectGroupController( - values: {}, - ); + /// 支付设置的界面排序,初始顺序来自枚举并由本地持久化覆盖。 + final payTypeOrder = [ + ...OrderPayDisplayType.values, + ].obs; - // 默认付款方式 - var defaultGlobalPayTableC = FRadioSelectGroupController(); + /// 允许在收银弹框内重新识别人员的支付方式集合;初始化采用业务默认值,配置加载后以持久化结果为准。 + final cashierReidentifiablePayTypes = + AppSettingTool.defaultCashierReidentifiablePayTypes.toSet().obs; + + /// 支付偏好串行保存中;保存期间的新变更会继续下一轮写入。 + bool _payPersisting = false; + + /// 支付偏好存在待写入变更;连续拖动或切换开关时合并为一次落盘。 + bool _payPendingPersist = false; /// 商品餐段时间配置;餐段校验弹框和自动切换按该时间范围判定。 final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; @@ -118,31 +143,12 @@ class SettingController extends GetxController { final appTheme = Get.find(); appTheme.setSizeScalar(fontSizeType); }); - globalPayTableC.addValueListener((v) async { - List usePayList = []; - for (final i in v) { - final payType = OrderPayDisplayType.values.fromCode( - i, - ); - if (payType == null) continue; - usePayList.add(payType); - } - usePayList.sort((pren, next) => pren.code.compareTo(next.code)); - sysSetting.value?.usePayDisplayType = usePayList; - await AppSettingTool.setSysSetting(sysSetting.value!); - }); - defaultGlobalPayTableC.addValueListener((v) async { - final payType = OrderPayDisplayType.values.fromCode( - v.first, - ); - if (payType == null) return; - sysSetting.value?.defaultPayDisplayType = payType; - await AppSettingTool.setSysSetting(sysSetting.value!); - }); mqUpdateListen = appC.mqUpdateEventBus.on().listen( (event) async { if (event == MqtActionEnum.CONFIG) { - sysSetting.value = await AppSettingTool.getSysSetting(); + sysSetting.value = _orderedSysSetting( + await AppSettingTool.getSysSetting(), + ); } }, ); @@ -158,8 +164,6 @@ class SettingController extends GetxController { void onClose() { super.onClose(); globalFontSizeC.dispose(); - globalPayTableC.dispose(); - defaultGlobalPayTableC.dispose(); mqUpdateListen.cancel(); // 隐藏键盘 // FocusManager.instance.primaryFocus?.unfocus(); @@ -361,21 +365,177 @@ class SettingController extends GetxController { } Future getSetting() async { - sysSetting.value = await AppSettingTool.getSysSetting(); + final setting = await AppSettingTool.getSysSetting(); + final storedPayTypeOrder = await AppSettingTool.getPayTypeOrder(); + final reidentifiablePayTypes = + await AppSettingTool.getCashierReidentifiablePayTypes(); + final byCode = { + for (final item in OrderPayDisplayType.values) item.code: item, + }; + final restoredPayTypeOrder = []; + for (final code in storedPayTypeOrder) { + final item = byCode[code]; + if (item != null && !restoredPayTypeOrder.contains(item)) { + restoredPayTypeOrder.add(item); + } + } + for (final item in OrderPayDisplayType.values) { + if (!restoredPayTypeOrder.contains(item)) { + restoredPayTypeOrder.add(item); + } + } + payTypeOrder.assignAll(restoredPayTypeOrder); + cashierReidentifiablePayTypes.assignAll(reidentifiablePayTypes); + sysSetting.value = _orderedSysSetting(setting); productMealPeriodSchedule.value = await AppSettingTool.getProductMealPeriodSchedule(); + manualHardwareInput.value = + AppRuntimeTool.isEmulator || sysSetting.value!.manualHardwareInput; + AppRuntimeTool.applyManualHardwareInput( + sysSetting.value!.manualHardwareInput, + ); if (sysSetting.value!.globalFontSize != null) { globalFontSizeC.value = {sysSetting.value!.globalFontSize!.code}; } - if (!ObjectUtil.isEmptyList(sysSetting.value!.usePayDisplayType)) { - globalPayTableC.value = - sysSetting.value!.usePayDisplayType!.map((v) => v.code).toSet(); + } + + /// 将设置中的启用支付方式按本地排序重排;null 保持 null 由收银页按默认处理。 + SysSetting _orderedSysSetting(SysSetting setting) { + if (setting.usePayDisplayType != null) { + setting.usePayDisplayType = payTypeOrder + .where(setting.usePayDisplayType!.contains) + .toList(); } - if (sysSetting.value!.defaultPayDisplayType != null) { - defaultGlobalPayTableC.value = { - sysSetting.value!.defaultPayDisplayType!.code - }; + return setting; + } + + /// 启用或停用支付方式,始终至少保留一种并修正失效的默认方式。 + void togglePayType(OrderPayDisplayType type) { + final setting = sysSetting.value; + if (setting == null) return; + final enabled = List.from( + setting.usePayDisplayType ?? OrderPayDisplayType.values, + ); + if (enabled.contains(type)) { + if (enabled.length == 1) { + Toast.error(msg: '至少保留一种支付方式'); + return; + } + enabled.remove(type); + } else { + enabled.add(type); } + final orderedEnabled = payTypeOrder.where(enabled.contains).toList(); + + // 默认支付方式必须属于启用集合,否则收银页会出现默认值不可选择的矛盾状态。 + if (!orderedEnabled.contains(setting.defaultPayDisplayType)) { + setting.defaultPayDisplayType = orderedEnabled.first; + } + setting.usePayDisplayType = orderedEnabled; + sysSetting.refresh(); + _schedulePayPreferencesPersist(); + } + + /// 切换指定支付方式能否在收银弹框中重新识别人员,并立即安排本机持久化。 + void togglePayTypeReidentification(OrderPayDisplayType type) { + if (cashierReidentifiablePayTypes.contains(type)) { + cashierReidentifiablePayTypes.remove(type); + } else { + cashierReidentifiablePayTypes.add(type); + } + _schedulePayPreferencesPersist(); + } + + /// 调整支付方式展示顺序,同时保持启用集合与完整排序一致。 + void reorderPayType(int oldIndex, int newIndex) { + if (oldIndex == newIndex || + oldIndex < 0 || + oldIndex >= payTypeOrder.length) { + return; + } + if (newIndex > oldIndex) newIndex -= 1; + if (newIndex < 0 || newIndex >= payTypeOrder.length) return; + final updated = payTypeOrder.toList(); + final item = updated.removeAt(oldIndex); + updated.insert(newIndex, item); + payTypeOrder.assignAll(updated); + + final setting = sysSetting.value; + if (setting != null) { + final visible = setting.usePayDisplayType ?? OrderPayDisplayType.values; + setting.usePayDisplayType = updated.where(visible.contains).toList(); + sysSetting.refresh(); + } + _schedulePayPreferencesPersist(); + } + + /// 恢复全部支付方式、默认枚举顺序和默认重新识别范围,并安排持久化。 + void resetPayTypes() { + payTypeOrder.assignAll(OrderPayDisplayType.values); + cashierReidentifiablePayTypes.assignAll( + AppSettingTool.defaultCashierReidentifiablePayTypes, + ); + final setting = sysSetting.value; + if (setting != null) { + setting.usePayDisplayType = OrderPayDisplayType.values.toList(); + if (!setting.usePayDisplayType!.contains(setting.defaultPayDisplayType)) { + setting.defaultPayDisplayType = setting.usePayDisplayType!.first; + } + sysSetting.refresh(); + } + _schedulePayPreferencesPersist(); + } + + /// 设置默认支付方式;未启用的支付方式不能成为默认值。 + void setDefaultPayType(OrderPayDisplayType type) { + final setting = sysSetting.value; + if (setting == null) return; + final enabled = setting.usePayDisplayType ?? OrderPayDisplayType.values; + if (!enabled.contains(type)) { + Toast.error(msg: '请先启用该支付方式'); + return; + } + setting.defaultPayDisplayType = type; + sysSetting.refresh(); + _schedulePayPreferencesPersist(); + } + + /// 支付设置表格“是否默认”开关入口;默认方式唯一,关闭当前默认时提示改选其他方式。 + void toggleDefaultPayType(OrderPayDisplayType type, bool value) { + if (value) { + setDefaultPayType(type); + return; + } + if (sysSetting.value?.defaultPayDisplayType == type) { + Toast.error(msg: '默认支付方式不能取消,请选择其他支付方式'); + } + } + + /// 支付方式连续拖动或切换时串行保存,始终以最后一次界面状态为准。 + void _schedulePayPreferencesPersist() { + _payPendingPersist = true; + if (!_payPersisting) unawaited(_flushPayPreferences()); + } + + /// 串行写入支付方式偏好,保存期间的新变更会继续下一轮写入。 + Future _flushPayPreferences() async { + _payPersisting = true; + while (_payPendingPersist) { + _payPendingPersist = false; + final setting = sysSetting.value; + if (setting == null) continue; + try { + await AppSettingTool.setPayTypePreferences( + sysInfo: setting, + order: payTypeOrder, + reidentifiableTypes: cashierReidentifiablePayTypes, + ); + } catch (e, stack) { + logger.e('|$_tag|保存支付管理配置失败error:$e|stack:$stack|'); + Toast.error(msg: '支付设置保存失败,请重试'); + } + } + _payPersisting = false; } Future checkIsBind() async { @@ -410,4 +570,163 @@ class SettingController extends GetxController { productMealPeriodSaving.value = false; } } + + /// 从 Android 原生能力重新读取当前 Kiosk 状态。 + Future refreshKioskStatus() async { + kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); + } + + /// 立即执行真实 Root 命令并局部持久化结果;失败状态也作为有效检查结果保存。 + Future refreshRootAccess({bool showFeedback = true}) async { + if (rootRefreshing.value) return; + rootRefreshing.value = true; + try { + final result = await AppRuntimeTool.checkRootAccess(); + await AppSettingTool.setRootAccessResult(result); + final setting = sysSetting.value; + if (setting != null) { + setting.rootAccessStatus = result.status; + setting.rootAccessCheckedAt = result.checkedAt; + setting.rootAccessDiagnostic = result.diagnostic; + sysSetting.refresh(); + } + if (showFeedback) { + if (result.available) { + Toast.success(msg: 'Root 权限可用'); + } else { + Toast.error(msg: result.diagnostic); + } + } + } catch (e, stack) { + logger.e('|$_tag|刷新Root状态失败error:|$e|stack:$stack|'); + if (showFeedback) Toast.error(msg: 'Root 状态检查失败,请稍后重试'); + } finally { + rootRefreshing.value = false; + } + } + + /// 经用户二次确认后通过 Root 配置 Device Owner;不删除账户、不移除其他 Owner。 + Future enableKioskWithRoot() async { + final setting = sysSetting.value; + if (setting == null || kioskProvisioning.value) return; + if (setting.rootAccessStatus != RootAccessStatus.available) { + kioskProvisionMessage.value = 'Root 不可用,请先刷新 Root 状态并完成授权'; + return; + } + final confirmed = await appMessageDialog( + Get.context!, + '将使用 Root 把当前应用配置为 Android Device Owner,并立即进入 Kiosk。该操作不会删除账户或移除其他 Device Owner,系统拒绝时只会返回原因。是否继续?', + ); + if (!confirmed) return; + + kioskProvisioning.value = true; + kioskProvisionMessage.value = ''; + EasyLoading.show( + status: '正在启用专用设备模式...', + maskType: EasyLoadingMaskType.black, + ); + try { + final result = await AppRuntimeTool.enableKioskWithRoot(); + kioskStatus.value = result.status; + if (result.success) { + kioskProvisionMessage.value = ''; + Toast.success(msg: 'Kiosk 已启用并自动进入'); + } else { + kioskProvisionMessage.value = result.message; + Toast.error(msg: result.message); + } + } catch (e, stack) { + logger.e('|$_tag|Root启用Kiosk失败error:|$e|stack:$stack|'); + kioskProvisionMessage.value = '启用失败,请稍后重试并检查系统 Device Owner 状态'; + Toast.error(msg: kioskProvisionMessage.value); + } finally { + EasyLoading.dismiss(); + kioskProvisioning.value = false; + } + } + + /// 切换主控手动硬件输入;虚拟机强制开启,开启前需操作员确认。 + Future setManualHardwareInput(bool enabled) async { + if (AppRuntimeTool.isEmulator && !enabled) { + Toast.error(msg: '虚拟机必须使用手动硬件输入模式'); + manualHardwareInput.value = true; + return; + } + if (enabled && !manualHardwareInput.value) { + final confirmed = await appMessageDialog( + Get.context!, + '设置为主控后,扫码、饭卡读写和人脸识别将改为人工输入,是否继续?', + ); + if (!confirmed) return; + } + final setting = sysSetting.value; + if (setting == null) return; + setting.manualHardwareInput = enabled; + await AppSettingTool.setSysSetting(setting); + AppRuntimeTool.applyManualHardwareInput(enabled); + manualHardwareInput.value = enabled; + sysSetting.refresh(); + Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式'); + } + + /// 立即开启或关闭 Kiosk 自动进入,并同步原生 Lock Task 状态。 + Future setKioskAutoEnterEnabled(bool enabled) async { + if (!kioskStatus.value.available || kioskSwitching.value) return; + kioskSwitching.value = true; + try { + final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled); + kioskStatus.value = status; + final applied = + status.autoEnterEnabled == enabled && status.active == enabled; + if (!applied) { + Toast.error( + msg: enabled ? 'Kiosk 未能立即开启,请检查设备策略' : 'Kiosk 未能立即关闭,请重试', + ); + return; + } + Toast.success(msg: enabled ? 'Kiosk 已立即开启' : 'Kiosk 已立即关闭'); + } finally { + kioskSwitching.value = false; + } + } + + /// 保存全局字体倍率并立即应用到运行中主题。 + Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { + final setting = sysSetting.value; + if (setting == null || setting.globalFontSize == fontSize) return; + setting.globalFontSize = fontSize; + await AppSettingTool.setSysSetting(setting); + Get.find().setSizeScalar(fontSize); + sysSetting.refresh(); + } + + /// 保存按钮圆角并立即应用到运行中主题。 + Future setButtonRadius(SettingButtonRadius value) async { + final setting = sysSetting.value; + if (setting == null || setting.buttonRadius == value) return; + setting.buttonRadius = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setButtonRadius(value); + sysSetting.refresh(); + } + + /// 保存卡片圆角并立即应用到运行中主题。 + Future setCardRadius(SettingCardRadius value) async { + final setting = sysSetting.value; + if (setting == null || setting.cardRadius == value) return; + setting.cardRadius = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setCardRadius(value); + sysSetting.refresh(); + } + + /// 保存按钮尺寸档位并立即应用到运行中主题。 + Future setButtonSize(SettingButtonSize value) async { + final setting = sysSetting.value; + if (setting == null || setting.buttonSize == value) return; + setting.buttonSize = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setButtonSize(value); + sysSetting.refresh(); + } } diff --git a/lib/pages/setting/routes/navigation.dart b/lib/pages/setting/routes/navigation.dart index ca24ad5..09c6874 100644 --- a/lib/pages/setting/routes/navigation.dart +++ b/lib/pages/setting/routes/navigation.dart @@ -6,16 +6,14 @@ import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; /// 设置模块内部导航入口。 /// /// 适配自 biz_xfj_cloud 的同名类:目标项目设置页为单页左菜单结构, -/// 任务日志跳转改走全局 GetX 路由,菜单切换复用 SettingController。 +/// 任务日志作为终端二级路由压入内层工作区栈,菜单切换复用 SettingController。 abstract final class SettingNavigation { - /// 打开指定任务的执行日志页。 + /// 打开指定任务的执行日志页(压入终端内层工作区栈,返回时回到定时任务设置)。 static Future openTaskLogs(ScheduledTaskType type) async { - await Get.toNamed(RouteNames.settingSyncLogs, arguments: type); - } - - /// 从日志页返回定时任务设置。 - static void backToSync() { - Get.back(); + TerminalNavigationTool.pushWorkspace( + RouteNames.settingSyncLogs, + arguments: type, + ); } /// 切换设置左侧菜单到指定功能。 diff --git a/lib/pages/setting/sync_logs/view.dart b/lib/pages/setting/sync_logs/view.dart index 77722cb..2c88de7 100644 --- a/lib/pages/setting/sync_logs/view.dart +++ b/lib/pages/setting/sync_logs/view.dart @@ -6,7 +6,6 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -import '../routes/navigation.dart'; import 'controller.dart'; /// 紧凑型定时任务结构化日志页面。 diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index ce8fd96..60b9bab 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -143,9 +143,9 @@ class SettingPage extends GetView { id: "setting", builder: (_) { return FScaffold( - // 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单保留在内容区。 - header: FHeader(title: SizedBox.shrink()), - content: _buildView().marginOnly(top: 20.h), + // 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单 + // 保留在内容区并直接贴紧顶栏。 + content: _buildView(), contentPad: true, resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart index 77d0081..3cf4e25 100644 --- a/lib/pages/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -1,190 +1,495 @@ +import 'dart:async'; + import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:forui/forui.dart'; -import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:get/get.dart'; +import 'package:mqtt_client/mqtt_client.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import '../index.dart'; +import 'setting_components.dart'; +/// 绑定接口成功即结束主操作,消息服务连接失败由后台定时任务继续恢复,不能反向判定设备绑定失败。 +Future _refreshMqAfterBinding() async { + try { + await AppMqTool.mqRefresh(); + } catch (e, stack) { + logger.e('|绑定后连接消息服务失败error:|$e|stack:$stack|'); + } + if (MqManager.isConnected) return; + final reason = MqManager.connectionFailureReason ?? '消息服务暂不可用'; + logger.w('|绑定成功但消息服务未连接reason:|$reason|'); + Toast.error(msg: '绑定成功,消息服务暂未连接'); +} + +/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。 Future _bindServiceDialog(BuildContext context, SysSetting sys) async { final theme = FTheme.of(context); - TextEditingController uniqueCodeC = TextEditingController(); - if (ObjectUtil.isNotEmpty(sys.serviceId)) { - uniqueCodeC.text = sys.serviceId ?? ""; - } - if (AppSysTool.isDev()) { - // 正式环境-超市 - // uniqueCodeC.text = "1894303392833716226"; - // 正式环境-测试 - // uniqueCodeC.text = "1896157295523049473"; - // 正式环境-新县府02-主要测试 - uniqueCodeC.text = "1887466308336488450"; - - /// 测试环境-测试设备-(食堂主要测试) - // uniqueCodeC.text = "1877592554489143298"; - // 测试少年宫测试 - // uniqueCodeC.text = "1927932460185784321"; - // 测试环境-食堂-浦阳 - // uniqueCodeC.text = "1914567628657778690"; - // 正式环境-超市-浦阳 - // uniqueCodeC.text = "1894303392833716226"; - // 正式环境-超市-新县府-主要测试(2025-8-26) - // uniqueCodeC.text = "1902270180536061954"; - } - await showAdaptiveDialog( + // 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。 + final text = sys.serviceId ?? ""; + final uniqueCodeController = TextEditingController(text: text); + await showAppDialog( context: context, - builder: (context) => FDialog( - direction: Axis.horizontal, + builder: (dialogContext) => AppDialog( title: Text('绑定唯一编号', style: theme.typography.xl2), - body: FTextField( + width: context.width * 0.55, + maxWidth: context.width * 0.55, + onClose: () => Navigator.of(dialogContext).pop(), + semanticLabel: '绑定设备唯一编号弹框', + content: FTextField( textInputAction: TextInputAction.none, - controller: uniqueCodeC, - hint: "请输入设备唯一编号", + controller: uniqueCodeController, + hint: '请输入设备唯一编号', maxLines: 1, - ).width(context.width * 0.5).marginSymmetric(vertical: 20.h), + ).width(double.infinity).marginSymmetric(vertical: 20.h), actions: [ FButton( style: FButtonStyle.outline, label: Text('取消', style: theme.typography.base), - onPress: () => Get.back(), + onPress: () => Navigator.of(dialogContext).pop(), ), FButton( label: Text( '绑定', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), + style: theme.typography.base.copyWith(color: Colors.white), ), onPress: () async { - final code = uniqueCodeC.text; - if (ObjectUtil.isEmpty(code)) { - Toast.error(msg: "请输入设备唯一编号"); + final serverId = int.tryParse(uniqueCodeController.text.trim()); + if (serverId == null) { + Toast.error(msg: '请输入正确的设备唯一编号'); return; } - int? serverId = int.tryParse(code); - if (ObjectUtil.isEmpty(serverId)) { - Toast.error(msg: "服务id格式错误"); - return; - } - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; + if (!AppNetworkTool.isAutoNetworkMsg()) return; EasyLoading.show( status: '绑定中...', maskType: EasyLoadingMaskType.black, ); try { - final flag = await AppSettingTool.bindSys(serverId!); - EasyLoading.dismiss(); - if (flag) { - final settingC = Get.find(); - await settingC.getSetting(); - // 连接mq - await AppMqTool.mqRefresh(); - Toast.success(msg: "绑定成功"); + final success = await AppSettingTool.bindSys(serverId); + if (!success) { + Toast.error(msg: '绑定失败,请核对唯一编号'); + return; } + final controller = Get.find(); + await controller.getSetting(); + Toast.success(msg: '绑定成功'); + unawaited(TerminalHeartbeatTool.report()); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + unawaited(_refreshMqAfterBinding()); } catch (e, stack) { - logger.e("|绑定服务端设备信息error:|$e|stack:|$stack|"); + logger.e('|绑定服务端设备信息error:|$e|stack:$stack|'); + Toast.error(msg: '绑定失败,请稍后重试'); + } finally { EasyLoading.dismiss(); } - Get.back(); }, - ) + ), + ], + ), + ); + uniqueCodeController.dispose(); +} + +Widget settingBaseInfo(BuildContext context) { + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SettingEmpty(message: '暂无终端信息'); + return SingleChildScrollView( + child: Column( + children: [ + SettingGroup( + title: '基本信息', + child: Column( + children: [ + _copyRow( + icon: FAssets.icons.store, + label: '设备类型', + value: sys.sysType.cnStr, + ), + _copyRow( + icon: FAssets.icons.hardDrive, + label: '设备名称', + value: sys.serviceName, + ), + _copyRow( + icon: FAssets.icons.idCard, + label: 'Android ID', + value: sys.androidId, + masked: true, + ), + SettingInfoRow( + icon: FAssets.icons.idCard, + label: '唯一编号', + value: _maskIdentifier(sys.serviceId), + actionLabel: + ObjectUtil.isEmpty(sys.serviceId) ? '立即绑定' : '重新绑定', + onTap: () => _bindServiceDialog(context, sys), + ), + _copyRow( + icon: FAssets.icons.store, + label: '商户编号', + value: sys.serviceMerchantId, + masked: true, + ), + _copyRow( + icon: FAssets.icons.building, + label: '商户部门编号', + value: sys.merchantDeptId, + masked: true, + showDivider: false, + ), + ], + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + _buildMqttGroup(controller, sys), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '运行环境', + child: Column( + children: [ + SettingInfoRow( + icon: FAssets.icons.monitor, + label: '当前设备', + subtitle: '检测到 ${AppRuntimeTool.displayCount} 个显示屏', + value: AppRuntimeTool.isEmulator + ? 'Android 虚拟机' + : 'Android 真实设备', + ), + SettingInfoRow( + icon: FAssets.icons.smartphone, + label: 'Android 版本', + value: AppRuntimeTool.androidVersionLabel, + ), + SettingInfoRow( + icon: FAssets.icons.package, + label: 'APK 版本', + value: AppRuntimeTool.apkVersionLabel, + ), + SettingInfoRow( + icon: sys.rootAccessStatus == RootAccessStatus.available + ? FAssets.icons.shieldCheck + : FAssets.icons.shieldAlert, + label: 'Root 权限', + subtitle: _rootAccessDescription(sys), + value: _rootAccessLabel(sys.rootAccessStatus), + trailing: Tooltip( + message: '重新检查 Root 权限', + child: IconButton( + tooltip: '重新检查 Root 权限', + onPressed: controller.rootRefreshing.value + ? null + : controller.refreshRootAccess, + icon: controller.rootRefreshing.value + ? SizedBox.square( + dimension: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : FIcon( + FAssets.icons.refreshCw, + color: XfjDesignTokens.primary, + size: 20.sp, + ), + ), + ), + ), + SettingInfoRow( + icon: controller.kioskStatus.value.available + ? FAssets.icons.shieldCheck + : FAssets.icons.circleAlert, + label: '专用设备 / Kiosk', + subtitle: controller.kioskProvisionMessage.isNotEmpty + ? controller.kioskProvisionMessage.value + : controller.kioskStatus.value.description, + value: controller.kioskStatus.value.statusLabel, + trailing: controller.kioskStatus.value.available + ? FSwitch( + semanticLabel: '立即开启或关闭 Kiosk', + value: controller.kioskStatus.value.autoEnterEnabled, + enabled: !controller.kioskSwitching.value, + onChange: controller.setKioskAutoEnterEnabled, + ) + : Tooltip( + message: + sys.rootAccessStatus == RootAccessStatus.available + ? '使用 Root 配置 Device Owner 并进入 Kiosk' + : 'Root 不可用,无法自动配置 Device Owner', + child: SettingActionButton( + label: controller.kioskProvisioning.value + ? '正在启用' + : '启用 Kiosk', + icon: FAssets.icons.shieldCheck, + primary: true, + enabled: sys.rootAccessStatus == + RootAccessStatus.available && + !controller.kioskProvisioning.value, + onTap: controller.enableKioskWithRoot, + ), + ), + ), + SettingInfoRow( + icon: FAssets.icons.keyboard, + label: '设置为主控', + subtitle: AppRuntimeTool.isEmulator + ? '虚拟机强制使用人工输入,不连接扫码、饭卡和人脸硬件' + : '开启后扫码、饭卡读写和人脸识别由操作员输入结果', + showDivider: false, + trailing: FSwitch( + semanticLabel: '设置为主控手动硬件输入模式', + value: controller.manualHardwareInput.value, + enabled: !AppRuntimeTool.isEmulator, + onChange: controller.setManualHardwareInput, + ), + ), + ], + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + _terminalDeviceStatusVisibilitySetting(), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '主题样式', + child: Column( + children: [ + SettingSegmentedSelector( + label: '字体大小', + value: sys.globalFontSize ?? SettingGlobalFontSize.xl3, + options: const { + SettingGlobalFontSize.xl2: '紧凑', + SettingGlobalFontSize.xl3: '标准', + SettingGlobalFontSize.xl4: '大号', + }, + onChanged: controller.setGlobalFontSize, + ), + SettingSegmentedSelector( + label: '按钮圆角', + value: sys.buttonRadius, + options: const { + SettingButtonRadius.compact: '紧凑', + SettingButtonRadius.standard: '标准', + SettingButtonRadius.large: '大号', + }, + onChanged: controller.setButtonRadius, + ), + SettingSegmentedSelector( + label: '卡片圆角', + value: sys.cardRadius, + options: const { + SettingCardRadius.compact: '紧凑', + SettingCardRadius.standard: '标准', + SettingCardRadius.large: '大号', + }, + onChanged: controller.setCardRadius, + ), + SettingSegmentedSelector( + label: '按钮大小', + value: sys.buttonSize, + options: const { + SettingButtonSize.compact: '紧凑', + SettingButtonSize.standard: '标准', + SettingButtonSize.large: '大号', + }, + showDivider: false, + onChanged: controller.setButtonSize, + ), + ], + ), + ), + ], + ), + ); + }); +} + +/// 消息服务(MQTT)分组:完整展示本机生效的连接配置与实时连接状态。 +Widget _buildMqttGroup(SettingController controller, SysSetting sys) { + return SettingGroup( + title: '消息服务(MQTT)', + child: Column( + children: [ + _copyRow( + icon: FAssets.icons.server, + label: '服务器地址', + value: sys.mqSocketAddress, + ), + _copyRow( + icon: FAssets.icons.plug, + label: '端口', + value: sys.mqSocketPort, + ), + _copyRow( + icon: FAssets.icons.userRound, + label: '用户名', + value: sys.mqUsername, + ), + _copyRow( + icon: FAssets.icons.lockKeyhole, + label: '密码', + value: sys.mqPassword, + ), + _copyRow( + icon: FAssets.icons.fingerprint, + label: 'Client ID', + value: sys.mqClientId, + ), + _copyRow( + icon: FAssets.icons.radioTower, + label: '订阅主题', + value: sys.mainTopic, + ), + _mqttConnectionRow(controller), ], ), ); } -Widget settingBaseInfo(BuildContext context) { - final theme = FTheme.of(context); - return GetBuilder( - builder: (controller) { - return Obx(() { - final sys = controller.sysSetting.value; - if (sys == null) return const SizedBox.shrink(); - return [ - appDividerTitle(context, "基本信息"), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.files), - title: appPrefixTitle(context, "设备类型"), - details: appSuffixTitle(context, sys.sysType.cnStr), - onPress: () => KeywordTool.copy(sys.sysType.cnStr), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.files), - title: appPrefixTitle(context, "设备名称"), - details: appSuffixTitle(context, sys.serviceName), - onPress: () => KeywordTool.copy(sys.serviceName), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.files), - title: appPrefixTitle(context, "android id"), - details: appSuffixTitle(context, sys.androidId), - onPress: () => KeywordTool.copy(sys.androidId), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines), - title: appPrefixTitle(context, "唯一编号"), - details: appSuffixTitle(context, sys.serviceId), - onPress: () => _bindServiceDialog(context, sys), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.files), - title: appPrefixTitle(context, "商户编号"), - details: appSuffixTitle(context, sys.serviceMerchantId), - onPress: () => KeywordTool.copy(sys.serviceMerchantId), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.files), - title: appPrefixTitle(context, "商户部门编号"), - details: appSuffixTitle(context, sys.merchantDeptId), - onPress: () => KeywordTool.copy(sys.merchantDeptId), - ), - appDividerTitle(context, "主题样式"), - FSelectMenuTile( - groupController: controller.globalFontSizeC, - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines), - title: appPrefixTitle(context, "字体大小"), - autoHide: true, - validator: (value) => value == null ? '请选择' : null, - menu: SettingGlobalFontSize.values.map((v) { - return FSelectTile( - title: Text( - v.title, - style: theme.typography.xl, - ), - value: v.code, - ); - }).toList(), - details: ListenableBuilder( - listenable: controller.globalFontSizeC, - builder: (context, _) { - final v = controller.globalFontSizeC.value.firstOrNull; - if (v == null) return const SizedBox(); - final fontSizeType = SettingGlobalFontSize.values.fromCode( - v, - ); - if (fontSizeType == null) return const SizedBox(); - return Text( - fontSizeType.title, - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ); - }, - ), - ), - ].toColumnSpace(space: 20.h); - }); - }, +/// 实时连接状态行;连接由原生回调更新,页面重建时读取当前快照。 +Widget _mqttConnectionRow(SettingController controller) { + final connected = MqManager.isConnected; + final failureReason = MqManager.connectionFailureReason; + return SettingInfoRow( + icon: connected ? FAssets.icons.wifi : FAssets.icons.circleAlert, + label: '连接状态', + subtitle: connected + ? '消息服务已连接,指令和心跳通道正常。' + : (failureReason ?? '未连接;点击右侧按钮可按当前配置重新连接。'), + value: _mqttStateLabel(MqManager.state), + showDivider: false, + trailing: SettingActionButton( + label: '重新连接', + icon: FAssets.icons.refreshCw, + dense: true, + onTap: () => _reconnectMqtt(controller), + ), ); } + +/// 手动重连消息服务:复用绑定后的刷新链路,结束后刷新页面状态并反馈结果。 +Future _reconnectMqtt(SettingController controller) async { + if (!AppNetworkTool.isAutoNetworkMsg()) return; + EasyLoading.show(status: '连接中...', maskType: EasyLoadingMaskType.black); + try { + await AppMqTool.mqRefresh(); + } catch (e, stack) { + logger.e('|基本信息|手动重连消息服务失败error:|$e|stack:$stack|'); + } finally { + EasyLoading.dismiss(); + } + controller.sysSetting.refresh(); + if (MqManager.isConnected) { + Toast.success(msg: '消息服务已连接'); + } else { + Toast.error( + msg: MqManager.connectionFailureReason ?? '消息服务连接失败,请检查配置或网络', + ); + } +} + +/// 将 mqtt_client 连接状态枚举转换为面向操作人员的中文标签。 +String _mqttStateLabel(MqttConnectionState state) => switch (state) { + MqttConnectionState.connected => '已连接', + MqttConnectionState.connecting => '连接中', + MqttConnectionState.disconnecting => '断开中', + MqttConnectionState.faulted => '异常', + MqttConnectionState.disconnected => '未连接', + }; + +/// 以数据驱动的五行开关提供顶部设备状态逐项配置,并与详情弹框共享应用级响应状态。 +Widget _terminalDeviceStatusVisibilitySetting() { + final control = Get.find(); + final devices = TerminalDeviceType.values; + return SettingGroup( + title: '顶部状态栏', + child: Column( + children: [ + for (var index = 0; index < devices.length; index++) + Obx(() { + final device = devices[index]; + final visible = control.isHeaderDeviceVisible(device); + return SettingInfoRow( + icon: _terminalDeviceStatusIcon(device), + label: device.label, + subtitle: '控制${device.label}状态是否显示在所有业务页面顶部。', + value: visible ? '已显示' : '已隐藏', + showDivider: index < devices.length - 1, + trailing: FSwitch( + key: ValueKey( + 'setting-terminal-device-status-${device.code}-switch', + ), + semanticLabel: '在页面顶部显示${device.label}状态', + value: visible, + onChange: (value) => unawaited( + control.setHeaderDeviceVisible(device, value), + ), + ), + ); + }), + ], + ), + ); +} + +/// 返回设备状态配置行使用的统一 Forui 图标,映射顺序与终端顶部五类设备保持一致。 +SvgAsset _terminalDeviceStatusIcon(TerminalDeviceType type) => switch (type) { + TerminalDeviceType.printer => FAssets.icons.printer, + TerminalDeviceType.steelyard => FAssets.icons.scale, + TerminalDeviceType.cardReader => FAssets.icons.idCard, + TerminalDeviceType.subScreen => FAssets.icons.monitor, + TerminalDeviceType.camera => FAssets.icons.camera, + }; + +/// 将 Root 持久化状态转换为基本信息页短标签。 +String _rootAccessLabel(RootAccessStatus status) => switch (status) { + RootAccessStatus.unknown => '未检查', + RootAccessStatus.available => '可用', + RootAccessStatus.unavailable => '不可用', + }; + +/// 组合 Root 最近检查时间和诊断摘要,旧配置保持明确的未检查提示。 +String _rootAccessDescription(SysSetting sys) { + final checkedAt = sys.rootAccessCheckedAt; + final checkedLabel = checkedAt == null + ? '尚未执行真实 Root 命令检查' + : '最近检查 ${DateUtil.formatDate(checkedAt, format: DateFormats.full)}'; + final diagnostic = sys.rootAccessDiagnostic?.trim(); + return diagnostic == null || diagnostic.isEmpty + ? checkedLabel + : '$checkedLabel · $diagnostic'; +} + +Widget _copyRow({ + required SvgAsset icon, + required String label, + String? value, + bool masked = false, + bool showDivider = true, +}) { + return SettingInfoRow( + icon: icon, + label: label, + value: masked ? _maskIdentifier(value) : _safeValue(value), + actionLabel: '复制', + showDivider: showDivider, + onTap: ObjectUtil.isEmpty(value) ? null : () => KeywordTool.copy(value), + ); +} + +String _safeValue(String? value) { + return ObjectUtil.isEmpty(value) ? '--' : value!; +} + +String _maskIdentifier(String? value) { + if (ObjectUtil.isEmpty(value)) return '--'; + final text = value!; + if (text.length <= 8) return '${text.substring(0, 2)}••••'; + final prefixLength = text.length >= 16 ? 4 : 3; + final suffixLength = text.length >= 16 ? 4 : 3; + return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}'; +} diff --git a/lib/pages/setting/widgets/setting_pay.dart b/lib/pages/setting/widgets/setting_pay.dart index 68f7937..be3e11c 100644 --- a/lib/pages/setting/widgets/setting_pay.dart +++ b/lib/pages/setting/widgets/setting_pay.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; @@ -7,84 +9,551 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import '../index.dart'; +/// “是否显示”列宽;与列头和数据行保持一致,窄屏时退化到固定像素。 +double get _visibilityColumnWidth => math.max(180.w, 144.0); + +/// “是否默认”列宽。 +double get _defaultColumnWidth => math.max(180.w, 144.0); + +/// “是否允许重新识别”列宽。 +double get _reidentifiableColumnWidth => math.max(220.w, 176.0); + +/// “是否支持离线支付”列宽。 +double get _offlineColumnWidth => math.max(150.w, 120.0); + +/// 支付设置分区:以表格形式管理支付方式的顺序、显隐、默认和重新识别能力。 Widget settingPay(BuildContext context) { - final theme = FTheme.of(context); - return GetBuilder(builder: (controller) { - return Obx(() { - final sys = controller.sysSetting.value; - if (sys == null) return const SizedBox.shrink(); - return [ - FSelectMenuTile( - groupController: controller.globalPayTableC, - validator: (value) => value == null ? '请选择' : null, - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines), - title: appPrefixTitle(context, "使用支付方式"), - menu: OrderPayDisplayType.values.map((v) { - return FSelectTile( - title: Text( - v.title, - style: theme.typography.xl, - ), - value: v.code, - ); - }).toList(), - details: ListenableBuilder( - listenable: controller.globalPayTableC, - builder: (context, _) { - final selectList = controller.globalPayTableC.value; - String title = ''; - for (final i in selectList) { - final payType = OrderPayDisplayType.values.fromCode( - i, - ); - if (payType == null) continue; - title += '${payType.title} '; - } - return Text( - title, - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ); - }, + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SizedBox.shrink(); + final enabled = sys.usePayDisplayType ?? OrderPayDisplayType.values; + final defaultType = sys.defaultPayDisplayType ?? enabled.firstOrNull; + return [ + _PayEditorHeader( + onReset: () { + controller.resetPayTypes(); + Toast.success(msg: '支付设置已还原'); + }, + ), + SizedBox(height: 12.h), + const _PayColumnHeader(), + SizedBox(height: XfjDesignTokens.cardGap.h), + ReorderableListView.builder( + shrinkWrap: true, + primary: false, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: controller.payTypeOrder.length, + onReorder: controller.reorderPayType, + proxyDecorator: (child, _, animation) => AnimatedBuilder( + animation: animation, + builder: (context, child) => Material( + color: Colors.transparent, + elevation: 6 * animation.value, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: child, ), + child: child, ), - FSelectMenuTile( - groupController: controller.defaultGlobalPayTableC, - validator: (value) => value == null ? '请选择' : null, - autoHide: true, - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines), - title: appPrefixTitle(context, "默认支付方式"), - menu: OrderPayDisplayType.values.map((v) { - return FSelectTile( - title: Text( - v.title, - style: theme.typography.xl, - ), - value: v.code, - ); - }).toList(), - details: ListenableBuilder( - listenable: controller.defaultGlobalPayTableC, - builder: (context, _) { - final v = controller.defaultGlobalPayTableC.value.firstOrNull; - if (v == null) return const SizedBox(); - final payType = OrderPayDisplayType.values.fromCode( - v, - ); - if (payType == null) return const SizedBox(); - return Text( - payType.title, - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ); - }, - ), - ), - ].toColumnSpace(space: 20.h); - }); + itemBuilder: (context, index) { + final type = controller.payTypeOrder[index]; + final visible = enabled.contains(type); + final reidentifiable = + controller.cashierReidentifiablePayTypes.contains(type); + return Padding( + key: ValueKey(type.code), + padding: EdgeInsets.only( + bottom: index == controller.payTypeOrder.length - 1 + ? 0 + : XfjDesignTokens.cardGap.h, + ), + child: _PayRow( + type: type, + index: index, + visible: visible, + reidentifiable: reidentifiable, + isDefault: type == defaultType, + onVisibleChanged: (_) => controller.togglePayType(type), + onDefaultChanged: (value) => + controller.toggleDefaultPayType(type, value), + onReidentifiableChanged: (_) => + controller.togglePayTypeReidentification(type), + onMoveUp: index == 0 + ? null + : () => controller.reorderPayType(index, index - 1), + onMoveDown: index == controller.payTypeOrder.length - 1 + ? null + : () => controller.reorderPayType(index, index + 2), + ), + ); + }, + ), + ].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable(); }); } + +class _PayEditorHeader extends StatelessWidget { + /// 创建包含使用说明与还原操作的支付配置标题区。 + const _PayEditorHeader({required this.onReset}); + + /// 恢复默认支付配置的回调。 + final VoidCallback onReset; + + /// 构建不会挤压说明文字的标题与次级操作。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '支付方式', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4.h), + Text( + '拖动手柄或上下按钮调整顺序;重新识别开启后,可按当前支付方式重新确认人员。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w * XfjDesignTokens.buttonSizeScale, + vertical: 8.h * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + FIcon( + FAssets.icons.gripVertical, + color: XfjDesignTokens.primary, + size: 18.sp, + ), + SizedBox(width: 8.w), + Text( + '拖动手柄', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + FButton( + style: FButtonStyle.outline, + prefix: FIcon( + FAssets.icons.rotateCcw, + color: XfjDesignTokens.ink, + size: 18.sp, + ), + label: Text( + '还原', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + onPress: onReset, + ), + ], + ), + ], + ); + } +} + +class _PayColumnHeader extends StatelessWidget { + /// 创建与支付方式数据行严格对齐的列标题。 + const _PayColumnHeader(); + + /// 构建支付名称、显隐、默认、重新识别和离线能力五组列标题。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final style = theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + SizedBox(width: 48.w), + SizedBox(width: 8.w), + SizedBox(width: 32.w), + SizedBox(width: 12.w), + SizedBox(width: 22.w), + SizedBox(width: 10.w), + Expanded(child: Text('支付名称', style: style)), + SizedBox(width: 120.w), + SizedBox( + width: _visibilityColumnWidth, + child: Text('是否显示', textAlign: TextAlign.center, style: style), + ), + SizedBox(width: 12.w), + SizedBox( + width: _defaultColumnWidth, + child: Text('是否默认', textAlign: TextAlign.center, style: style), + ), + SizedBox(width: 12.w), + SizedBox( + width: _reidentifiableColumnWidth, + child: Text( + '是否允许重新识别', + textAlign: TextAlign.center, + style: style, + ), + ), + SizedBox(width: 12.w), + SizedBox( + width: _offlineColumnWidth, + child: Text( + '是否支持离线支付', + textAlign: TextAlign.center, + style: style, + ), + ), + ], + ), + ); + } +} + +/// 支付行沿用左侧菜单的拖动、精确移动和显隐反馈,并展示默认、重新识别与离线能力。 +class _PayRow extends StatelessWidget { + /// 创建单个支付方式配置行。 + const _PayRow({ + required this.type, + required this.index, + required this.visible, + required this.reidentifiable, + required this.isDefault, + required this.onVisibleChanged, + required this.onDefaultChanged, + required this.onReidentifiableChanged, + this.onMoveUp, + this.onMoveDown, + }); + + /// 当前行对应的支付方式及跨端稳定编码。 + final OrderPayDisplayType type; + + /// 当前支付方式在完整排序中的零基索引。 + final int index; + + /// 当前支付方式是否在收银页展示。 + final bool visible; + + /// 当前支付方式是否允许重新识别人员。 + final bool reidentifiable; + + /// 当前支付方式是否为默认方式。 + final bool isDefault; + + /// 显隐开关变化回调。 + final ValueChanged onVisibleChanged; + + /// 默认开关变化回调;开启即设为默认,关闭当前默认会被拦截。 + final ValueChanged onDefaultChanged; + + /// 重新识别开关变化回调。 + final ValueChanged onReidentifiableChanged; + + /// 精确上移回调;为空表示已经位于首行。 + final VoidCallback? onMoveUp; + + /// 精确下移回调;为空表示已经位于末行。 + final VoidCallback? onMoveDown; + + /// 构建固定高度的数据行,所有开关同时使用文字表达状态。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: visible ? XfjDesignTokens.surface : XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + FAssets.icons.gripVertical, + color: XfjDesignTokens.muted, + size: 22.sp, + ).center(), + ), + ), + SizedBox(width: 8.w), + Container( + width: 32.w, + height: 32.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: visible + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + '${index + 1}', + style: theme.typography.sm.copyWith( + color: + visible ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + SizedBox(width: 12.w), + FIcon( + _payIcon(type), + color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted, + size: 22.sp, + ), + SizedBox(width: 10.w), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + type.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: + visible ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + if (isDefault) ...[ + SizedBox(width: 10.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 5.h, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + '默认', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + ), + _PayMoveButton( + semanticLabel: '${type.title}上移', + icon: FAssets.icons.chevronUp, + onTap: onMoveUp, + ), + SizedBox(width: 8.w), + _PayMoveButton( + semanticLabel: '${type.title}下移', + icon: FAssets.icons.chevronDown, + onTap: onMoveDown, + ), + SizedBox(width: 16.w), + SizedBox( + width: _visibilityColumnWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + visible ? '显示' : '隐藏', + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: visible + ? XfjDesignTokens.success + : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 12.w), + FSwitch( + semanticLabel: '${type.title}${visible ? '已显示' : '已隐藏'}', + value: visible, + onChange: onVisibleChanged, + ), + ], + ), + ), + SizedBox(width: 12.w), + SizedBox( + width: _defaultColumnWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + isDefault ? '默认' : '非默认', + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: isDefault + ? XfjDesignTokens.success + : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 12.w), + FSwitch( + semanticLabel: '${type.title}${isDefault ? '默认' : '非默认'}', + value: isDefault, + onChange: onDefaultChanged, + ), + ], + ), + ), + SizedBox(width: 12.w), + SizedBox( + width: _reidentifiableColumnWidth, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + reidentifiable ? '允许' : '不允许', + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: reidentifiable + ? XfjDesignTokens.success + : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 12.w), + FSwitch( + semanticLabel: + '${type.title}${reidentifiable ? '允许重新识别' : '不允许重新识别'}', + value: reidentifiable, + onChange: onReidentifiableChanged, + ), + ], + ), + ), + SizedBox(width: 12.w), + SizedBox( + width: _offlineColumnWidth, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + decoration: BoxDecoration( + color: type.supportOffLine + ? XfjDesignTokens.success.withValues(alpha: 0.1) + : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + type.supportOffLine ? '支持' : '不支持', + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: type.supportOffLine + ? XfjDesignTokens.success + : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _PayMoveButton extends StatelessWidget { + const _PayMoveButton({ + required this.semanticLabel, + required this.icon, + this.onTap, + }); + + final String semanticLabel; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: semanticLabel, + child: Material( + color: enabled ? XfjDesignTokens.control : XfjDesignTokens.canvas, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + icon, + color: enabled ? XfjDesignTokens.ink : XfjDesignTokens.line, + size: 18.sp, + ).center(), + ), + ), + ), + ); + } +} + +SvgAsset _payIcon(OrderPayDisplayType type) { + return switch (type) { + OrderPayDisplayType.walletCard => FAssets.icons.idCard, + OrderPayDisplayType.qrCode => FAssets.icons.qrCode, + OrderPayDisplayType.qrCodeScan => FAssets.icons.scanLine, + OrderPayDisplayType.face => FAssets.icons.scanFace, + OrderPayDisplayType.cash => FAssets.icons.banknote, + _ => FAssets.icons.creditCard, + }; +} diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart index 63cd80a..1dbf666 100644 --- a/lib/pages/supermarket_order/controller.dart +++ b/lib/pages/supermarket_order/controller.dart @@ -88,9 +88,22 @@ class SupermarketOrderController extends GetxController with OrderMixin { Future doPay() async { final validShopCar = _validateShopCar(); if (!validShopCar) return; + final snapshot = _createCheckoutSnapshot(); // 创建本地订单 await createSqlOrder(); - selectPayTypeDialog(); + await selectPayTypeDialog(snapshot: snapshot); + } + + /// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。 + CashierCheckoutSnapshot _createCheckoutSnapshot() { + return CashierCheckoutSnapshot.capture( + cartTitle: '当前购物车商品', + cartItems: shopCardList.toList(growable: false), + originalAmount: totalPrice.value, + discountAmount: 0, + surchargeAmount: amountMealPrice.value, + payableAmount: totalPrice.value + amountMealPrice.value, + ); } // |下单|点击取消下单 @@ -311,6 +324,7 @@ class SupermarketOrderController extends GetxController with OrderMixin { orderPayType, orderPayDisplayType, qrCode, + faceRecognition, ) async { final log = "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; @@ -321,18 +335,22 @@ class SupermarketOrderController extends GetxController with OrderMixin { this.globalQrCode = qrCode; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return; + if (!validPayUser) return false; final validShopCar = _validateShopCar(); - if (!validShopCar) return; + if (!validShopCar) return false; EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black, ); await updateSqlOrder(); - await createApiOrder(); + final isNetwork = AppNetworkTool.isAutoNetwork(); + final orderResult = await createApiOrder(); EasyLoading.dismiss(); + // 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。 + if (isNetwork && orderResult == null) return false; _clearShopCard(); putOffSendSubScreen(); + return true; }; // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; @@ -346,8 +364,11 @@ class SupermarketOrderController extends GetxController with OrderMixin { if (!validShopCar) return; final orderRes = await createApiOrder(); + if (orderRes == null) { + throw StateError("创建二维码支付订单失败"); + } TtsManage.speak("请扫描屏幕二维码"); - await onOrderCreate.call(orderRes!); + await onOrderCreate.call(orderRes); }; // |"二维码扫一扫支付" "支付成功"| diff --git a/lib/pages/take_food/view.dart b/lib/pages/take_food/view.dart index cef1bda..071f9c5 100644 --- a/lib/pages/take_food/view.dart +++ b/lib/pages/take_food/view.dart @@ -143,9 +143,8 @@ class TakeFoodPage extends GetView { id: "take_food", builder: (_) { return FScaffold( - // 标题与返回入口由终端外壳顶栏统一提供。 - header: FHeader(title: SizedBox.shrink()), - content: _buildView(context).marginOnly(top: 20.h), + // 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。 + content: _buildView(context), resizeToAvoidBottomInset: false, ); }, diff --git a/lib/pages/terminal/view.dart b/lib/pages/terminal/view.dart index 11c9484..f588149 100644 --- a/lib/pages/terminal/view.dart +++ b/lib/pages/terminal/view.dart @@ -19,6 +19,8 @@ import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart'; import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart'; import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/sync_logs/controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/sync_logs/view.dart'; import 'package:pj_supermarket_cloud/pages/setting/view.dart'; import 'package:pj_supermarket_cloud/pages/supermarket_order/controller.dart'; import 'package:pj_supermarket_cloud/pages/supermarket_order/view.dart'; @@ -329,4 +331,13 @@ final Map _terminalRoutes = { Get.lazyPut(() => FaceSettingController(), fenix: true); }), ), + RouteNames.settingSyncLogs: _TerminalRouteConfig( + name: RouteNames.settingSyncLogs, + title: '定时任务日志', + page: () => const ScheduledTaskLogsPage(), + binding: BindingsBuilder(() { + // 任务类型由页面首帧从 RouteSettings 注入后再触发首查。 + Get.lazyPut(() => ScheduledTaskLogsController(), fenix: true); + }), + ), };