diff --git a/lib/common/models/plugs/sub_screen_enum_models.dart b/lib/common/models/plugs/sub_screen_enum_models.dart index 547f3cc..7d9d81f 100644 --- a/lib/common/models/plugs/sub_screen_enum_models.dart +++ b/lib/common/models/plugs/sub_screen_enum_models.dart @@ -49,6 +49,9 @@ enum SubScreenAction implements BaseEnum { /// 取消人脸识别副屏会话。 faceRecognitionCancel("faceRecognitionCancel", "人脸识别取消"), + + /// 通知副屏当前订单已经完成支付,适用于饭卡、钱包、人脸和付款码等非聚合码支付。 + orderPaySuccess("orderPaySuccess", "订单支付成功"), ; @override diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 6f0f096..9986a56 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -14,6 +14,24 @@ abstract class AppSettingTool { static const String _terminalMenuOrderKey = "terminalMenuOrder"; static const String _hiddenTerminalMenusKey = "hiddenTerminalMenus"; static const String _payTypeOrderKey = "payTypeOrder"; + + /// 收银支付方式允许重新识别人员的编码列表;缺失时使用默认集合,空列表表示全部关闭。 + static const String _cashierReidentifiablePayTypesKey = + "cashierReidentifiablePayTypes"; + + /// 收银支付方式的默认重新识别范围;旧配置缺少对应键及用户执行“还原”时均采用该集合。 + static const Set defaultCashierReidentifiablePayTypes = { + // 饭卡支付允许重新刷卡,以便纠正首次识别到的人员。 + OrderPayDisplayType.walletCard, + // 人脸支付允许重新调用摄像头,以便纠正首次识别到的人员。 + OrderPayDisplayType.face, + // 中医院属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.zyy, + // 人民医院属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.rmyy, + // 超市消费卡属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.csxfk, + }; static const String _cashierShowHoldActionKey = "cashierShowHoldAction"; static const String _cashierShowMoreActionKey = "cashierShowMoreAction"; @@ -395,20 +413,45 @@ abstract class AppSettingTool { const []; } - /// 支付排序属于终端 UI 扩展配置,与现有支付显隐和默认方式一起原子保存。 + /// 读取允许重新识别人员的支付方式;缺少或非列表配置使用默认值,显式空列表保持全部关闭。 + static Future> + getCashierReidentifiablePayTypes() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final payTypeByCode = { + for (final payType in OrderPayDisplayType.values) payType.code: payType, + }; + final source = sysJson[_cashierReidentifiablePayTypesKey]; + if (source is! List) { + return defaultCashierReidentifiablePayTypes.toSet(); + } + return source + .whereType() + .map((code) => payTypeByCode[code]) + .whereType() + .toSet(); + } + + /// 支付排序、显隐、默认方式及重新识别能力作为同一份终端支付偏好原子保存。 static Future setPayTypePreferences({ required SysSetting sysInfo, required List order, + required Set reidentifiableTypes, }) { // 在首次异步等待前生成快照,避免连续拖动时后一次修改污染本次落盘内容。 final nextSysJson = sysInfo.toJson(); final visiblePayTypes = nextSysJson['usePayDisplayType']; final defaultPayType = nextSysJson['defaultPayDisplayType']; final orderCodes = order.map((item) => item.code).toList(); + final reidentifiableCodes = OrderPayDisplayType.values + .where(reidentifiableTypes.contains) + .map((item) => item.code) + .toList(); return _updateSysJson((sysJson) { sysJson['usePayDisplayType'] = visiblePayTypes; sysJson['defaultPayDisplayType'] = defaultPayType; sysJson[_payTypeOrderKey] = orderCodes; + sysJson[_cashierReidentifiablePayTypesKey] = reidentifiableCodes; }); } diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 65af07b..b04836c 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -448,6 +448,8 @@ mixin OrderMixin { } await orderDao.edit(sqlOrderDetail); if (!isPendingOrderCode) { + // 支付结果已经落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。 + unawaited(sendSubScreenPaySuccess()); if (sqlOrderDetail.payPrice != 0) { final price = AppPriceTool.fenToYuan(sqlOrderDetail.payPrice); TtsManage.speak("购买成功总金额$price元"); @@ -459,6 +461,7 @@ mixin OrderMixin { Toast.error(msg: "结算订单失败"); logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|"); } + return null; } // 获取商品渠道id @@ -588,6 +591,27 @@ mixin OrderMixin { ); } + /// 尽力通知副屏展示通用支付成功页,不参与主屏订单成功与否的判定。 + /// + /// 使用原生单向通道规避双屏插件业务消息不回包的问题,并限制等待时间;调用方还需 + /// 以非阻塞方式触发,确保副屏不可用或通道异常时主屏仍能清理购物车和关闭支付弹框。 + Future sendSubScreenPaySuccess() async { + try { + final delivered = await AppRuntimeTool.sendViceMessage( + SubScreenAction.orderPaySuccess.code, + ).timeout(const Duration(seconds: 1)); + if (!delivered) { + logger.w('|$_tag|副屏不可用,支付成功消息未投递|'); + } + } on TimeoutException { + logger.w('|$_tag|通知副屏支付成功超时,主屏继续完成订单收尾|'); + } catch (error, stack) { + logger.e( + '|$_tag|通知副屏支付成功失败error:|$error|stack:|$stack|', + ); + } + } + /// 构建副屏使用的购物车商品列表;子类可在不改变下单协议的前提下补充展示字段。 Future>> buildSubScreenShopList( List cartItems, diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 304106e..1e94b8e 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -79,10 +79,16 @@ class SelectPayTypeDialogController extends GetxController { RxList usePayDisplayTypeList = [].obs; + /// 当前允许重新识别支付人员的支付方式,来源于本机收银页面设置。 + final reidentifiablePayTypes = {}.obs; + OrderType orderType = OrderType.BELOW; RxBool isRecognition = RxBool(false); + /// 当前识别是否由“重新识别”按钮发起,用于保留旧人员并正确释放对应识别设备。 + final isReidentifying = false.obs; + /// 支付提交正在执行时为 true,用于禁用重复提交和弹框关闭。 final isSubmitting = false.obs; @@ -116,6 +122,9 @@ class SelectPayTypeDialogController extends GetxController { /// 人脸准备请求序号;关闭、切换或重新打开弹框时递增,使迟到的异步结果立即失效。 int _faceDispatchRequestId = 0; + /// 饭卡读取请求序号;取消、关闭或再次发起后,旧异步回调不得替换当前人员。 + int _walletCardDispatchRequestId = 0; + int? _payableAmount; int? _totalAmount; int? _discountAmount; @@ -123,10 +132,14 @@ class SelectPayTypeDialogController extends GetxController { /// 当前弹框使用的不可变购物车与金额快照。 late CashierCheckoutSnapshot checkoutSnapshot; + /// 加载系统支付配置与重新识别开关,并注册支付方式选择监听。 @override void onInit() async { super.onInit(); + final reidentifiablePayTypesFuture = + AppSettingTool.getCashierReidentifiablePayTypes(); appSetting = await AppSettingTool.getSysSetting(); + reidentifiablePayTypes.assignAll(await reidentifiablePayTypesFuture); // 监听支付方式变化 payTypeRadioC.addValueListener((v) { if (v.isEmpty) return; @@ -156,19 +169,10 @@ class SelectPayTypeDialogController extends GetxController { /// 应用已确认的支付方式,并清理上一方式遗留的局部提示。 void _applyPayDisplayType(OrderPayDisplayType payType) { final isOffline = OrderPayDisplayType.isOffline(payType); + orderPayType.value = _defaultOrderPayType(payType); + globalQrCode = null; 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.AGGREGATE_ORDER_PAY; } else if (payUser.value != null && AppUserTool.isGenerateExternalUser(payUser.value!)) { payUser.value = null; @@ -177,6 +181,21 @@ class SelectPayTypeDialogController extends GetxController { faceWorkspaceMessage.value = ''; } + /// 将界面支付方式映射为默认订单支付类型;二维码真实扫码结果仍可在识别后覆盖该默认值。 + OrderPayType _defaultOrderPayType(OrderPayDisplayType payType) { + return switch (payType) { + OrderPayDisplayType.qrCodeScan => OrderPayType.AGGREGATE_ORDER_PAY, + 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(_closeFaceSession(cancel: true)); @@ -191,8 +210,10 @@ class SelectPayTypeDialogController extends GetxController { _payableAmount = snapshot.payableAmount; _totalAmount = snapshot.originalAmount; _discountAmount = snapshot.discountAmount; - if (appSetting.defaultPayDisplayType != null) { - payTypeRadioC.value = {appSetting.defaultPayDisplayType!}; + final defaultPayType = appSetting.defaultPayDisplayType; + if (defaultPayType != null) { + _applyPayDisplayType(defaultPayType); + payTypeRadioC.value = {defaultPayType}; } if (!ObjectUtil.isEmptyList(appSetting.usePayDisplayType)) { final isNetwork = AppNetworkTool.isAutoNetwork(); @@ -208,12 +229,15 @@ class SelectPayTypeDialogController extends GetxController { } } + /// 清理当前交易的人员、钱包、识别会话和支付凭据,供弹框关闭与再次打开共用。 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; @@ -226,6 +250,7 @@ class SelectPayTypeDialogController extends GetxController { if (appSetting.sysType == SysType.CANTEEN) { BarcodeScanManage.instance.dispose(); } + app.serialCardManage.callback = null; app.serialCardManage.close(); unawaited(_closeFaceSession(cancel: true)); @@ -257,6 +282,10 @@ class SelectPayTypeDialogController extends GetxController { // |确定|点击 Future doSubmit() async { if (payUser.value == null || isSubmitting.value) return; + if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { + await doRecognition(); + return; + } isSubmitting.value = true; try { // 判断是否为线下支付 @@ -325,15 +354,23 @@ class SelectPayTypeDialogController extends GetxController { /// 根据当前支付方式停止硬件、轮询或人脸会话,供切换和关闭流程共用。 Future _stopCurrentRecognition() async { final currentType = orderPayDisplayType.value; + final wasReidentifying = isReidentifying.value; + final activeRecognitionType = + wasReidentifying ? currentReidentificationMethod : currentType; isRecognition.value = false; - if (currentType == OrderPayDisplayType.face) { + isReidentifying.value = false; + if (activeRecognitionType == OrderPayDisplayType.walletCard) { + _walletCardDispatchRequestId++; + app.serialCardManage.callback = null; + app.serialCardManage.close(); + return; + } + if (activeRecognitionType == OrderPayDisplayType.face) { _faceDispatchRequestId++; await _closeFaceSession(cancel: true); - } else if (currentType == OrderPayDisplayType.qrCode) { + } else if (activeRecognitionType == OrderPayDisplayType.qrCode) { BarcodeScanManage.instance.dispose(); - } else if (currentType == OrderPayDisplayType.walletCard) { - app.serialCardManage.close(); - } else if (currentType == OrderPayDisplayType.qrCodeScan) { + } else if (activeRecognitionType == OrderPayDisplayType.qrCodeScan) { SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.aggregateOrderPayCancel.name, ); @@ -413,58 +450,127 @@ 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; @@ -473,20 +579,41 @@ class SelectPayTypeDialogController extends GetxController { ? card.trim() : "请在合适区域放置饭卡"; Toast.error(msg: message); - isRecognition.value = false; - app.serialCardManage.close(); 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 // ************************************************************************** @@ -546,12 +673,14 @@ class SelectPayTypeDialogController extends GetxController { } 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; @@ -575,6 +704,7 @@ class SelectPayTypeDialogController extends GetxController { } finally { faceUserLoading.value = false; isRecognition.value = false; + isReidentifying.value = false; _faceCallbackLock = false; } } @@ -585,12 +715,14 @@ class SelectPayTypeDialogController extends GetxController { 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 = '已取消人脸识别,可重新发起'; @@ -599,30 +731,48 @@ class SelectPayTypeDialogController extends GetxController { // android人脸识别成功回调 Future _faceDiscernAfter(FaceStream event) async { + final reidentifying = isReidentifying.value; try { final isNetwork = AppNetworkTool.isAutoNetwork(); - final user = await AppFaceTool.resolveRecognizedUser( + final nextUser = await AppFaceTool.resolveRecognizedUser( recognition: event, sysType: appSetting.sysType, ); - if (user == null) { + if (nextUser == null) { throw StateError('该人脸样本对应人员已失效,请重新同步或重新录入人脸'); } - payUser.value = user; + var nextWallets = []; if (isNetwork) { - logger.i("|_tag|人脸识别|有网络|,用户信息:|${payUser.toJson()}|"); - await _getWalletList(); + 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 { - payUser.refresh(); - logger.i("|_tag|人脸识别|无网络|,用户信息:|${payUser.toJson()}|"); + logger.i("|_tag|人脸识别|无网络|,用户信息:|${nextUser.toJson()}|"); } + final nextWallet = _defaultWalletForUser(nextUser, nextWallets); + if (nextWallet != null) { + _applyWalletToUser(nextUser, nextWallet); + } + // 人员和钱包使用同一批识别结果一次替换,避免界面短暂混用新旧人员数据。 + userWalletList.assignAll(nextWallets); + currentWallet.value = nextWallet; + payUser.value = nextUser; orderPayType.value = OrderPayType.FACE; orderType = OrderType.BELOW; - onDiscernUser?.call(payUser.value); + onDiscernUser?.call(nextUser); faceWorkspaceMessage.value = ''; } catch (e, task) { logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); - payUser.value = null; + // 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。 + if (!reidentifying) payUser.value = null; final message = e.toString().replaceFirst('Bad state: ', '').trim(); faceWorkspaceMessage.value = message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message; @@ -815,11 +965,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) { @@ -834,26 +980,57 @@ 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|"); } @@ -1589,6 +1766,7 @@ class _CheckoutPaymentPane extends StatelessWidget { ), SizedBox(height: 12.h), _PaymentMethodGrid(controller: controller), + _PaymentReidentificationBar(controller: controller), SizedBox(height: 14.h), if (bounded) _PaymentWorkspace( @@ -2398,13 +2576,17 @@ class _CheckoutPaymentActions extends StatelessWidget { final submitting = controller.isSubmitting.value; final recognizing = controller.isRecognition.value; final hasUser = controller.payUser.value != null; + final selectedType = controller.orderPayDisplayType.value; + final aggregateOrderPay = selectedType == OrderPayDisplayType.qrCodeScan; final label = submitting ? '支付提交中...' : recognizing ? '识别进行中' - : hasUser - ? '确认支付 ${_paymentMoney(controller.checkoutSnapshot.payableAmount)}' - : '开始识别'; + : aggregateOrderPay + ? '发起扫码支付' + : hasUser + ? '确认支付 ${_paymentMoney(controller.checkoutSnapshot.payableAmount)}' + : '开始识别'; return [ _PaymentDialogButton( label: '取消', @@ -2416,9 +2598,11 @@ class _CheckoutPaymentActions extends StatelessWidget { label: label, onTap: !paymentEnabled || recognizing || submitting ? null - : hasUser - ? controller.doSubmit - : controller.doRecognition, + : aggregateOrderPay + ? controller.doRecognition + : hasUser + ? controller.doSubmit + : controller.doRecognition, ).expanded(flex: 2), ].toRow(); }); @@ -2490,6 +2674,108 @@ class _PaymentMethodGrid extends StatelessWidget { } } +/// 支付方式下方的独立重新识别操作条,避免次级操作挤占人员信息标题空间。 +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 { /// 创建带选择态和禁用态的等宽支付方式按钮。 @@ -2689,6 +2975,60 @@ class _PaymentRecognitionCard extends StatelessWidget { } } +/// 收银工作区统一的重新识别次级按钮,固定触控尺寸且不会与主支付操作争夺层级。 +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, diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index c444475..7cf65f9 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -47,6 +47,10 @@ class SettingController extends GetxController with WidgetsBindingObserver { ...OrderPayDisplayType.values, ].obs; + /// 允许在收银弹框内重新识别人员的支付方式集合;初始化采用业务默认值,配置加载后以持久化结果为准。 + final cashierReidentifiablePayTypes = + AppSettingTool.defaultCashierReidentifiablePayTypes.toSet().obs; + /// 页面设置当前编辑的终端一级页面,默认展示收银页面配置。 final pageSettingType = TerminalMenuItemType.cashier.obs; @@ -206,11 +210,14 @@ class SettingController extends GetxController with WidgetsBindingObserver { await refreshKioskStatus(); final setting = await AppSettingTool.getSysSetting(); final storedPayTypeOrderFuture = AppSettingTool.getPayTypeOrder(); + final reidentifiablePayTypesFuture = + AppSettingTool.getCashierReidentifiablePayTypes(); final cashierPagePreferencesFuture = AppSettingTool.getCashierPagePreferences(); final reservationQueryPreferencesFuture = AppSettingTool.getReservationQueryPagePreferences(); final storedPayTypeOrder = await storedPayTypeOrderFuture; + final reidentifiablePayTypes = await reidentifiablePayTypesFuture; final cashierPagePreferences = await cashierPagePreferencesFuture; final reservationQueryPreferences = await reservationQueryPreferencesFuture; final byCode = { @@ -229,6 +236,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } payTypeOrder.assignAll(restoredPayTypeOrder); + cashierReidentifiablePayTypes.assignAll(reidentifiablePayTypes); cashierShowHoldAction.value = cashierPagePreferences.showHoldAction; cashierShowMoreAction.value = cashierPagePreferences.showMoreAction; reservationQueryMethodOrder.assignAll( @@ -533,6 +541,16 @@ class SettingController extends GetxController with WidgetsBindingObserver { _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 || @@ -556,9 +574,12 @@ class SettingController extends GetxController with WidgetsBindingObserver { _schedulePayPreferencesPersist(); } - /// 恢复全部支付方式及默认枚举顺序,并安排持久化。 + /// 恢复全部支付方式、默认枚举顺序和默认重新识别范围,并安排持久化。 void resetPayTypes() { payTypeOrder.assignAll(OrderPayDisplayType.values); + cashierReidentifiablePayTypes.assignAll( + AppSettingTool.defaultCashierReidentifiablePayTypes, + ); final setting = sysSetting.value; if (setting != null) { setting.usePayDisplayType = OrderPayDisplayType.values.toList(); @@ -648,6 +669,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { await AppSettingTool.setPayTypePreferences( sysInfo: setting, order: payTypeOrder, + reidentifiableTypes: cashierReidentifiablePayTypes, ); } catch (e, stack) { logger.e('|$_tag|保存支付管理配置失败error:|$e|stack:|$stack|'); diff --git a/lib/pages/terminal/setting/widgets/setting_page.dart b/lib/pages/terminal/setting/widgets/setting_page.dart index 5303a2a..dd447f7 100644 --- a/lib/pages/terminal/setting/widgets/setting_page.dart +++ b/lib/pages/terminal/setting/widgets/setting_page.dart @@ -410,6 +410,8 @@ class _CashierSettings extends StatelessWidget { child: SettingPayTypeEditor( order: controller.payTypeOrder.toList(), visibleTypes: enabled.toSet(), + reidentifiableTypes: + controller.cashierReidentifiablePayTypes.toSet(), defaultType: defaultType, onReset: () { controller.resetPayTypes(); @@ -418,6 +420,7 @@ class _CashierSettings extends StatelessWidget { }, onReorder: controller.reorderPayType, onVisibilityToggle: controller.togglePayType, + onReidentifiableToggle: controller.togglePayTypeReidentification, ), ), SettingGroup( diff --git a/lib/pages/terminal/setting/widgets/setting_pay.dart b/lib/pages/terminal/setting/widgets/setting_pay.dart index cce16ab..fedb061 100644 --- a/lib/pages/terminal/setting/widgets/setting_pay.dart +++ b/lib/pages/terminal/setting/widgets/setting_pay.dart @@ -23,6 +23,8 @@ Widget settingPay(BuildContext context) { child: SettingPayTypeEditor( order: controller.payTypeOrder.toList(), visibleTypes: enabled.toSet(), + reidentifiableTypes: + controller.cashierReidentifiablePayTypes.toSet(), defaultType: defaultType, onReset: () { controller.resetPayTypes(); @@ -30,6 +32,7 @@ Widget settingPay(BuildContext context) { }, onReorder: controller.reorderPayType, onVisibilityToggle: controller.togglePayType, + onReidentifiableToggle: controller.togglePayTypeReidentification, ), ), SizedBox(height: XfjDesignTokens.cardGap.h), diff --git a/lib/pages/terminal/setting/widgets/setting_pay_type_editor.dart b/lib/pages/terminal/setting/widgets/setting_pay_type_editor.dart index f8f3d4d..cf06a44 100644 --- a/lib/pages/terminal/setting/widgets/setting_pay_type_editor.dart +++ b/lib/pages/terminal/setting/widgets/setting_pay_type_editor.dart @@ -6,27 +6,48 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// 收银支付编辑器,统一承载支付方式排序、显隐和离线能力展示。 +/// 收银支付编辑器,统一承载支付方式排序、显隐、重新识别和离线能力展示。 /// /// 组件只通过回调修改业务状态,具体持久化仍由 SettingController 负责。 class SettingPayTypeEditor extends StatelessWidget { + /// 创建支付方式配置表格;所有集合均为控制器当前状态的只读快照。 const SettingPayTypeEditor({ super.key, required this.order, required this.visibleTypes, + required this.reidentifiableTypes, required this.defaultType, required this.onReset, required this.onReorder, required this.onVisibilityToggle, + required this.onReidentifiableToggle, }); + /// 支付方式完整展示顺序,包含当前被隐藏的方式。 final List order; + + /// 当前在收银页展示的支付方式集合。 final Set visibleTypes; + + /// 当前允许重新识别人员的支付方式集合。 + final Set reidentifiableTypes; + + /// 当前默认支付方式;为空表示没有可用支付方式。 final OrderPayDisplayType? defaultType; + + /// 恢复支付方式默认顺序、显隐和重新识别开关的回调。 final VoidCallback onReset; + + /// 调整支付方式排序的回调,索引语义遵循 ReorderableListView。 final void Function(int oldIndex, int newIndex) onReorder; + + /// 切换支付方式显隐状态的回调。 final ValueChanged onVisibilityToggle; + /// 切换支付方式重新识别能力的回调。 + final ValueChanged onReidentifiableToggle; + + /// 构建标题、列头及支持拖动排序的支付方式行。 @override Widget build(BuildContext context) { return Padding( @@ -60,6 +81,7 @@ class SettingPayTypeEditor extends StatelessWidget { itemBuilder: (context, index) { final type = order[index]; final visible = visibleTypes.contains(type); + final reidentifiable = reidentifiableTypes.contains(type); return Padding( key: ValueKey(type.code), padding: EdgeInsets.only( @@ -70,8 +92,10 @@ class SettingPayTypeEditor extends StatelessWidget { type: type, index: index, visible: visible, + reidentifiable: reidentifiable, isDefault: type == defaultType, onVisibleChanged: (_) => onVisibilityToggle(type), + onReidentifiableChanged: (_) => onReidentifiableToggle(type), onMoveUp: index == 0 ? null : () => onReorder(index, index - 1), onMoveDown: index == order.length - 1 @@ -88,10 +112,13 @@ class SettingPayTypeEditor extends StatelessWidget { } class _PayTypeEditorHeader extends StatelessWidget { + /// 创建包含使用说明与还原操作的支付配置标题区。 const _PayTypeEditorHeader({required this.onReset}); + /// 恢复默认支付配置的回调。 final VoidCallback onReset; + /// 构建不会挤压说明文字的标题与次级操作。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -110,7 +137,7 @@ class _PayTypeEditorHeader extends StatelessWidget { ), SizedBox(height: 4.h), Text( - '拖动手柄调整顺序,关闭后隐藏收银入口;至少保留一种支付方式。', + '拖动手柄调整顺序;重新识别开启后,可按当前支付方式重新确认人员。', style: theme.typography.sm.copyWith( color: XfjDesignTokens.muted, ), @@ -175,12 +202,15 @@ class _PayTypeEditorHeader extends StatelessWidget { } class _PayTypeColumnHeader extends StatelessWidget { + /// 创建与支付方式数据行严格对齐的列标题。 const _PayTypeColumnHeader(); + /// 构建支付名称、显隐、重新识别和离线能力四组列标题。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final visibilityColumnWidth = math.max(180.w, 144.0); + final reidentifiableColumnWidth = math.max(220.w, 176.0); final style = theme.typography.sm.copyWith( color: XfjDesignTokens.muted, fontWeight: FontWeight.w700, @@ -202,6 +232,15 @@ class _PayTypeColumnHeader extends StatelessWidget { 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: 150.w, child: Text( @@ -216,30 +255,54 @@ class _PayTypeColumnHeader extends StatelessWidget { } } -/// 支付行沿用左侧菜单的拖动、精确移动和显隐反馈,额外展示离线能力。 +/// 支付行沿用左侧菜单的拖动、精确移动和显隐反馈,并展示重新识别与离线能力。 class _PayTypeRow extends StatelessWidget { + /// 创建单个支付方式配置行。 const _PayTypeRow({ required this.type, required this.index, required this.visible, + required this.reidentifiable, required this.isDefault, required this.onVisibleChanged, + 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 onReidentifiableChanged; + + /// 精确上移回调;为空表示已经位于首行。 final VoidCallback? onMoveUp; + + /// 精确下移回调;为空表示已经位于末行。 final VoidCallback? onMoveDown; + /// 构建固定高度的数据行,所有开关同时使用文字表达状态。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final visibilityColumnWidth = math.max(180.w, 144.0); + final reidentifiableColumnWidth = math.max(220.w, 176.0); return Container( constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h), padding: EdgeInsets.symmetric(horizontal: 12.w), @@ -371,6 +434,34 @@ class _PayTypeRow extends StatelessWidget { ), ), 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: 150.w, child: Container( diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart index 4f149e4..7d64aa3 100644 --- a/lib/sub_screen/common/models/customer_display_models.dart +++ b/lib/sub_screen/common/models/customer_display_models.dart @@ -188,7 +188,11 @@ class CustomerDisplayState { qrCodeData: scenario == CustomerDisplayScenario.paying ? 'biz-xfj-customer-preview' : null, - countdownSeconds: scenario == CustomerDisplayScenario.paying ? 298 : 0, + countdownSeconds: switch (scenario) { + CustomerDisplayScenario.paying => 298, + CustomerDisplayScenario.success => 10, + _ => 0, + }, ); } diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index b671965..644ab76 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -7,27 +7,51 @@ import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_m /// 副屏 Engine 不能依赖主屏 GetX/公共导出树,这里只镜像既有协议值用于消息解析。 abstract final class _SubScreenProtocol { + /// 主屏同步完整购物车快照的动作编码。 static const updateCard = 'updateCard'; + + /// 主屏请求进入结算展示的动作编码。 static const jumpSubPage = 'jumpSubPage'; + + /// 主屏请求退出结算展示的动作编码。 static const jumpSubBack = 'jumpSubBack'; + + /// 主屏确认非聚合码订单已完成支付的动作编码。 + static const orderPaySuccess = 'orderPaySuccess'; + + /// 主屏展示旧版钱包二维码的动作编码。 static const qrCodeScan = 'qrCodeScan'; + + /// 主屏确认旧版钱包二维码支付成功的动作编码。 static const qrCodeScanSuccess = 'qrCodeScanSuccess'; + + /// 主屏取消旧版钱包二维码支付的动作编码。 static const qrCodeScanCancel = 'qrCodeScanCancel'; + + /// 主屏展示支付宝、微信聚合订单码的动作编码。 static const aggregateOrderPay = 'aggregateOrderPay'; + + /// 主屏确认聚合订单码支付成功的动作编码。 static const aggregateOrderPaySuccess = 'aggregateOrderPaySuccess'; + + /// 主屏取消或终止聚合订单码支付的动作编码。 static const aggregateOrderPayCancel = 'aggregateOrderPayCancel'; } /// 将插件消息转换为纯展示状态,副屏页面不直接依赖 GetX、主屏 Controller 或路由栈。 class CustomerDisplayCoordinator extends ChangeNotifier { + /// 创建副屏结算状态协调器;成功保留秒数可注入,以便测试完整清屏生命周期。 CustomerDisplayCoordinator({ CustomerDisplayState? initialState, this.qrCountdownSeconds = 300, + this.successCountdownSeconds = 10, this.timerTick = const Duration(seconds: 1), }) : assert(qrCountdownSeconds > 0), + assert(successCountdownSeconds > 0), assert(timerTick > Duration.zero), _state = initialState ?? CustomerDisplayState.idle(); + /// 创建仅供主屏调试预览使用的副屏状态,不读取真实订单和顾客信息。 factory CustomerDisplayCoordinator.demo(CustomerDisplayScenario scenario) { final state = CustomerDisplayState.demo(scenario); final coordinator = CustomerDisplayCoordinator(initialState: state); @@ -37,7 +61,13 @@ class CustomerDisplayCoordinator extends ChangeNotifier { return coordinator; } + /// 普通二维码支付的默认有效秒数。 final int qrCountdownSeconds; + + /// 支付成功后保留商品、金额和成功提示的秒数,倒计时结束后自动清屏。 + final int successCountdownSeconds; + + /// 倒计时刷新间隔;生产环境为一秒,测试可注入更短间隔。 final Duration timerTick; CustomerDisplayState _state; @@ -51,10 +81,14 @@ class CustomerDisplayCoordinator extends ChangeNotifier { if (_disposed) return false; try { if (action == _SubScreenProtocol.jumpSubBack) { + // 支付后的旧返回消息可能由主屏三秒延迟任务发出,成功页必须完整保留十秒。 + if (_state.scenario == CustomerDisplayScenario.success) return false; _stopCountdown(); return _replace(CustomerDisplayState.idle()); } if (action == _SubScreenProtocol.jumpSubPage) { + // 新订单尚未产生商品快照时,不能让跳页消息用上一单商品重新进入核对状态。 + if (_state.scenario == CustomerDisplayScenario.success) return false; _stopCountdown(); // 业务入口会在购物车创建前发送跳页消息;没有商品上下文时继续待机, // 避免副屏提前露出空的结算框架。 @@ -64,6 +98,11 @@ class CustomerDisplayCoordinator extends ChangeNotifier { if (action == _SubScreenProtocol.updateCard) { final next = _parseProductState(arguments); if (next == null) return false; + // 主屏支付后清空购物车会形成空快照;成功保留期只接受非空的新订单快照。 + if (_state.scenario == CustomerDisplayScenario.success && + next.products.isEmpty) { + return false; + } _stopCountdown(); return _replace(next); } @@ -98,11 +137,28 @@ class CustomerDisplayCoordinator extends ChangeNotifier { if (changed) _startCountdown(); return changed; } - if (action == _SubScreenProtocol.qrCodeScanSuccess || + if (action == _SubScreenProtocol.orderPaySuccess || + action == _SubScreenProtocol.qrCodeScanSuccess || action == _SubScreenProtocol.aggregateOrderPaySuccess) { - if (_state.scenario != CustomerDisplayScenario.paying) return false; + if (_state.scenario == CustomerDisplayScenario.success || + _state.products.isEmpty) { + return false; + } + final acceptsReviewState = action == _SubScreenProtocol.orderPaySuccess; + if (_state.scenario != CustomerDisplayScenario.paying && + (!acceptsReviewState || + _state.scenario != CustomerDisplayScenario.review)) { + return false; + } _stopCountdown(); - return _replace(_state.withScenario(CustomerDisplayScenario.success)); + final changed = _replace( + _state.withScenario( + CustomerDisplayScenario.success, + countdownSeconds: successCountdownSeconds, + ), + ); + if (changed) _startCountdown(); + return changed; } if (action == _SubScreenProtocol.qrCodeScanCancel || action == _SubScreenProtocol.aggregateOrderPayCancel) { @@ -305,38 +361,50 @@ class CustomerDisplayCoordinator extends ChangeNotifier { }; } + /// 启动支付码或支付成功倒计时;支付码到期进入超时态,成功倒计时到期清除顾客数据。 void _startCountdown() { _stopCountdown(); - if (_state.scenario != CustomerDisplayScenario.paying || - _state.countdownSeconds <= 0) { + final countdownScenario = _state.scenario; + if (countdownScenario != CustomerDisplayScenario.paying && + countdownScenario != CustomerDisplayScenario.success) { + return; + } + if (_state.countdownSeconds <= 0) { return; } _countdownTimer = Timer.periodic(timerTick, (_) { - if (_disposed || _state.scenario != CustomerDisplayScenario.paying) { + if (_disposed || _state.scenario != countdownScenario) { _stopCountdown(); return; } final nextSeconds = _state.countdownSeconds - 1; if (nextSeconds <= 0) { _stopCountdown(); - _replace(_state.withScenario(CustomerDisplayScenario.timeout)); + final nextState = countdownScenario == CustomerDisplayScenario.success + ? CustomerDisplayState.idle() + : _state.withScenario(CustomerDisplayScenario.timeout); + _replace(nextState); return; } _replace( _state.withScenario( - CustomerDisplayScenario.paying, - qrCodeData: _state.qrCodeData, + countdownScenario, + qrCodeData: countdownScenario == CustomerDisplayScenario.paying + ? _state.qrCodeData + : null, countdownSeconds: nextSeconds, ), ); }); } + /// 停止并释放当前支付码或成功保留计时器。 void _stopCountdown() { _countdownTimer?.cancel(); _countdownTimer = null; } + /// 仅在状态实际变化时通知副屏重建,返回值用于消息去重判断。 bool _replace(CustomerDisplayState next) { if (_disposed || next == _state) return false; _state = next; @@ -344,6 +412,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier { return true; } + /// 释放协调器并取消仍在运行的倒计时,防止副屏 Engine 销毁后继续回调。 @override void dispose() { if (_disposed) return; diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index 92b69c9..c444f94 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/customer_display_models.dart'; import '../models/face_recognition_display_models.dart'; import '../models/reservation_query_display_models.dart'; import 'customer_display_coordinator.dart'; @@ -35,6 +36,7 @@ class SubDisplayCoordinator extends ChangeNotifier { static const _jumpSubPage = 'jumpSubPage'; static const _jumpSubBack = 'jumpSubBack'; static const _paymentActions = { + 'orderPaySuccess', 'qrCodeScan', 'qrCodeScanSuccess', 'qrCodeScanCancel', @@ -149,7 +151,10 @@ class SubDisplayCoordinator extends ChangeNotifier { } if (action == _jumpSubBack) { final changed = customerCoordinator.handleAction(action, arguments); - final modeChanged = _showOrRememberMode(SubDisplayMode.welcome); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); return changed || modeChanged; } if (action == _jumpSubPage) { @@ -278,8 +283,20 @@ class SubDisplayCoordinator extends ChangeNotifier { return _replaceMode(next); } + /// 转发子协调器刷新,并在支付成功倒计时清空商品后同步恢复欢迎模式。 void _notifyFromChild() { - if (!_disposed) notifyListeners(); + if (_disposed) return; + final customerState = customerCoordinator.state; + final customerCleared = customerState.products.isEmpty && + customerState.scenario == CustomerDisplayScenario.idle && + (_mode == SubDisplayMode.settlement || + _modeBeforeFace == SubDisplayMode.settlement); + if (customerCleared) { + final modeChanged = _showOrRememberMode(SubDisplayMode.welcome); + if (!modeChanged) notifyListeners(); + return; + } + notifyListeners(); } void _notifyFromReservation() { diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index 3b87fed..cecc525 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -259,6 +260,7 @@ class _ProductPanel extends StatelessWidget { /// 构建适合远距离核对的商品区域,并在商品较多时保留纵向滚动能力。 @override Widget build(BuildContext context) { + final isPaymentSuccess = state.scenario == CustomerDisplayScenario.success; return Container( decoration: _surfaceDecoration(radius: 16 * scale), child: Column( @@ -277,7 +279,11 @@ class _ProductPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - state.products.isEmpty ? '欢迎光临' : '请核对商品信息', + state.products.isEmpty + ? '欢迎光临' + : isPaymentSuccess + ? '本次购买商品' + : '请核对商品信息', style: TextStyle( color: XfjDesignTokens.ink, fontSize: _fontSize(34, scale), @@ -288,7 +294,9 @@ class _ProductPanel extends StatelessWidget { Text( state.products.isEmpty ? '商品加入购物车后将在这里实时展示' - : '本次消费共 ${state.products.length} 项商品', + : isPaymentSuccess + ? '支付已完成,${state.countdownSeconds} 秒后自动清屏' + : '本次消费共 ${state.products.length} 项商品', style: TextStyle( color: XfjDesignTokens.muted, fontSize: _fontSize(22, scale), @@ -330,7 +338,9 @@ class _ProductPanel extends StatelessWidget { border: Border(top: BorderSide(color: XfjDesignTokens.line)), ), child: Text( - '商品和金额由收银员实时更新,如有疑问请及时告知收银员', + isPaymentSuccess + ? '本次支付已完成,请带好您的商品,感谢您的使用' + : '商品和金额由收银员实时更新,如有疑问请及时告知收银员', style: TextStyle( color: XfjDesignTokens.muted, fontSize: _fontSize(18, scale), @@ -836,12 +846,18 @@ class _PriceLine extends StatelessWidget { } } +/// 副屏结算状态主体,分别展示支付码、支付结果和顾客下一步指引。 class _PaymentStatePanel extends StatelessWidget { + /// 创建读取当前结算状态的右侧展示区域。 const _PaymentStatePanel({required this.state, required this.scale}); + /// 副屏协调器提供的订单金额、支付场景和倒计时快照。 final CustomerDisplayState state; + + /// 依据副屏分辨率计算的几何缩放比例。 final double scale; + /// 根据支付场景构建二维码、成功页或取消和超时提示。 @override Widget build(BuildContext context) { if (state.scenario == CustomerDisplayScenario.paying && @@ -919,6 +935,88 @@ class _PaymentStatePanel extends StatelessWidget { ); } + if (state.scenario == CustomerDisplayScenario.success) { + final successColor = XfjDesignTokens.success; + return Semantics( + label: '支付成功,${state.countdownSeconds} 秒后自动清屏', + child: DecoratedBox( + key: const ValueKey('customer-payment-success'), + decoration: BoxDecoration( + color: successColor.withValues(alpha: 0.07), + border: Border.all(color: successColor.withValues(alpha: 0.24)), + borderRadius: BorderRadius.circular(16 * scale), + ), + child: [ + Container( + width: 86 * scale, + height: 86 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: successColor, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 50 * scale, + ), + ), + Text( + '支付成功', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(52, scale), + height: 1, + fontWeight: FontWeight.w900, + ), + ), + Text( + '请带好您的商品', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(27, scale), + height: 1.15, + fontWeight: FontWeight.w700, + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${state.countdownSeconds}', + style: TextStyle( + color: successColor, + fontSize: _fontSize(48, scale), + fontWeight: FontWeight.w900, + ), + ), + TextSpan( + text: ' 秒后自动清屏', + style: TextStyle( + color: successColor, + fontSize: _fontSize(25, scale), + fontWeight: FontWeight.w800, + ), + ), + ], + ), + key: const ValueKey('customer-success-countdown'), + textAlign: TextAlign.center, + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ] + .toColumnSpace( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + space: 12 * scale, + ) + .padding(horizontal: 18 * scale, vertical: 14 * scale), + ), + ); + } + final visual = _scenarioVisual(state.scenario); return Container( alignment: Alignment.center, @@ -1120,7 +1218,7 @@ _ScenarioVisual _scenarioVisual(CustomerDisplayScenario scenario) { ), CustomerDisplayScenario.success => const _ScenarioVisual( title: '支付成功', - description: '本次支付已完成,请确认商品后带走,感谢您的使用', + description: '本次支付已完成,请带好商品,感谢您的使用', icon: Icons.check_rounded, color: XfjDesignTokens.success, ), diff --git a/test/common/cashier_checkout_dialog_test.dart b/test/common/cashier_checkout_dialog_test.dart index e51d045..5bb0e89 100644 --- a/test/common/cashier_checkout_dialog_test.dart +++ b/test/common/cashier_checkout_dialog_test.dart @@ -88,9 +88,8 @@ void main() { OrderPayDisplayType.qrCode, ] ..defaultPayDisplayType = OrderPayDisplayType.cash; - final controller = Get.put( - _CashierDialogTestController(setting), - ); + final controller = _CashierDialogTestController(setting); + Get.put(controller); final snapshot = CashierCheckoutSnapshot.capture( cartItems: [ ShopCardEntity( @@ -246,6 +245,71 @@ void main() { expect(find.text('钱包可用'), findsOneWidget); expect(tester.takeException(), isNull); + controller + ..orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan + ..orderPayType.value = OrderPayType.AGGREGATE_ORDER_PAY + ..reidentifiablePayTypes.add(OrderPayDisplayType.qrCodeScan); + await tester.pumpAndSettle(); + + final reidentificationBar = find.byKey( + const ValueKey('cashier-reidentification-bar'), + ); + final reidentifyButton = find.byKey( + const ValueKey('cashier-reidentify-user'), + ); + expect(reidentificationBar, findsOneWidget); + expect(reidentifyButton, findsOneWidget); + expect( + tester.getSize(reidentifyButton).height, + greaterThanOrEqualTo(48), + ); + expect(find.text('重新刷饭卡'), findsOneWidget); + expect(find.text('发起扫码支付'), findsOneWidget); + expect( + tester.getTopLeft(reidentificationBar).dy, + greaterThan( + tester + .getBottomLeft( + find.byKey(const ValueKey('payment-method-qrCode')), + ) + .dy, + ), + ); + expect( + tester.getTopLeft(find.text('人员信息')).dy, + greaterThan(tester.getBottomLeft(reidentificationBar).dy), + ); + await tester.tap(reidentifyButton); + await tester.pump(); + expect(controller.reidentifyCount, 1); + expect( + controller.lastReidentificationMethod, + OrderPayDisplayType.walletCard, + ); + expect( + controller.orderPayDisplayType.value, + OrderPayDisplayType.qrCodeScan, + ); + + controller + ..orderPayDisplayType.value = OrderPayDisplayType.face + ..orderPayType.value = OrderPayType.FACE + ..reidentifiablePayTypes.add(OrderPayDisplayType.face); + await tester.pumpAndSettle(); + expect(find.text('重新人脸识别'), findsOneWidget); + await tester.tap(reidentifyButton); + await tester.pump(); + expect(controller.reidentifyCount, 2); + expect( + controller.lastReidentificationMethod, + OrderPayDisplayType.face, + ); + + controller.reidentifiablePayTypes.remove(OrderPayDisplayType.face); + await tester.pumpAndSettle(); + expect(reidentificationBar, findsNothing); + expect(reidentifyButton, findsNothing); + await tester.tap( find.byKey(const ValueKey('cashier-checkout-close')), ); @@ -299,6 +363,12 @@ class _CashierDialogTestController extends SelectPayTypeDialogController { /// 当前测试视口使用的静态终端配置。 final SysSetting setting; + /// 测试期间记录重新识别按钮的实际点击次数,不启动真实串口。 + int reidentifyCount = 0; + + /// 最近一次点击对应的实际识别设备,用于确认人脸与饭卡动作会随支付方式切换。 + OrderPayDisplayType? lastReidentificationMethod; + /// 同步注入测试支付配置,避免 Widget 测试读取真实设备存储。 // 测试环境没有 Floor 数据库,不能调用生产 onInit 中的异步配置读取。 @override @@ -308,4 +378,11 @@ class _CashierDialogTestController extends SelectPayTypeDialogController { orderPayDisplayType.value = OrderPayDisplayType.cash; orderPayType.value = OrderPayType.CASH; } + + /// 拦截重新识别,验证按钮命中、支付方式保持及切换后的识别语义。 + @override + Future reidentifyCurrentUser() async { + reidentifyCount++; + lastReidentificationMethod = currentReidentificationMethod; + } } diff --git a/test/pages/terminal/setting_components_test.dart b/test/pages/terminal/setting_components_test.dart index 5a03e29..6647039 100644 --- a/test/pages/terminal/setting_components_test.dart +++ b/test/pages/terminal/setting_components_test.dart @@ -3,9 +3,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:forui/forui.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/terminal/setting/widgets/setting_components.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/widgets/setting_pay_type_editor.dart'; void main() { + test('饭卡、人脸、人民医院和超市消费卡默认允许重新识别', () { + expect( + AppSettingTool.defaultCashierReidentifiablePayTypes, + const { + OrderPayDisplayType.walletCard, + OrderPayDisplayType.face, + OrderPayDisplayType.rmyy, + OrderPayDisplayType.csxfk, + }, + ); + }); + testWidgets('分段选择器在页面设置宽度内可容纳八个支付选项', (tester) async { await tester.binding.setSurfaceSize(const Size(1920, 1080)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -52,4 +66,57 @@ void main() { expect(selected, 7); expect(tester.takeException(), isNull); }); + + testWidgets('支付方式表格可逐项切换重新识别并同步文字状态', (tester) async { + await tester.binding.setSurfaceSize(const Size(1920, 1080)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final reidentifiableTypes = { + OrderPayDisplayType.qrCodeScan, + }; + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => SingleChildScrollView( + child: SettingPayTypeEditor( + order: const [OrderPayDisplayType.qrCodeScan], + visibleTypes: const {OrderPayDisplayType.qrCodeScan}, + reidentifiableTypes: reidentifiableTypes, + defaultType: OrderPayDisplayType.qrCodeScan, + onReset: () {}, + onReorder: (_, __) {}, + onVisibilityToggle: (_) {}, + onReidentifiableToggle: (type) => setState(() { + if (!reidentifiableTypes.remove(type)) { + reidentifiableTypes.add(type); + } + }), + ).width(1600), + ), + ), + ), + ), + ), + ), + ); + + expect(find.text('是否允许重新识别'), findsOneWidget); + expect(find.text('允许'), findsOneWidget); + final switchFinder = find.bySemanticsLabel('二维码扫一扫允许重新识别'); + expect(switchFinder, findsOneWidget); + await tester.tap(switchFinder); + await tester.pump(); + + expect(find.text('不允许'), findsOneWidget); + expect( + find.bySemanticsLabel('二维码扫一扫不允许重新识别'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index 9817b50..dd042d3 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -108,8 +108,69 @@ void main() { ); expect(coordinator.handleAction('qrCodeScanSuccess'), isTrue); expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.countdownSeconds, 10); expect(coordinator.handleAction('qrCodeScanCancel'), isFalse); expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.countdownSeconds, 10); + + coordinator.dispose(); + }); + + testWidgets('普通支付成功保留商品十秒并屏蔽旧订单清屏消息', (tester) async { + final coordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 3, + ); + coordinator.handleAction('updateCard', _productEvent()); + final paidProducts = coordinator.state.products; + final paidPrice = coordinator.state.payablePrice; + + expect(coordinator.handleAction('orderPaySuccess'), isTrue); + expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.products, paidProducts); + expect(coordinator.state.payablePrice, paidPrice); + expect(coordinator.state.countdownSeconds, 3); + expect(coordinator.handleAction('jumpSubBack'), isFalse); + expect(coordinator.handleAction('jumpSubPage'), isFalse); + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isFalse, + ); + + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state.countdownSeconds, 2); + expect(coordinator.handleAction('orderPaySuccess'), isFalse); + expect(coordinator.state.countdownSeconds, 2); + expect(coordinator.state.products, paidProducts); + + await tester.pump(const Duration(seconds: 2)); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + expect(coordinator.state.products, isEmpty); + expect(coordinator.state.payablePrice, 0); + + coordinator.dispose(); + }); + + testWidgets('成功保留期的新订单快照立即接管且旧倒计时不再清屏', (tester) async { + final coordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 3, + ); + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction('orderPaySuccess'); + await tester.pump(const Duration(seconds: 1)); + + final nextOrder = _productEvent(); + final nextProducts = nextOrder['shopList']! as List>; + nextProducts.first['productName'] = '下一单纯牛奶'; + expect(coordinator.handleAction('updateCard', nextOrder), isTrue); + expect(coordinator.state.scenario, CustomerDisplayScenario.review); + expect(coordinator.state.products.first.name, '下一单纯牛奶'); + + await tester.pump(const Duration(seconds: 3)); + expect(coordinator.state.scenario, CustomerDisplayScenario.review); + expect(coordinator.state.products.first.name, '下一单纯牛奶'); coordinator.dispose(); }); @@ -227,6 +288,20 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('支付成功状态释放后停止自动清屏计时器', (tester) async { + final coordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 2, + ); + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction('orderPaySuccess'); + + coordinator.dispose(); + await tester.pump(const Duration(seconds: 3)); + + expect(coordinator.handleAction('jumpSubBack'), isFalse); + expect(tester.takeException(), isNull); + }); + testWidgets('页面切换消息停止支付计时且保留商品上下文', (tester) async { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 3); coordinator.handleAction('updateCard', _productEvent()); diff --git a/test/sub_screen/customer_display_widget_test.dart b/test/sub_screen/customer_display_widget_test.dart index f335ac5..a33089d 100644 --- a/test/sub_screen/customer_display_widget_test.dart +++ b/test/sub_screen/customer_display_widget_test.dart @@ -46,6 +46,16 @@ void main() { expect(find.text('biz-xfj-customer-preview'), findsNothing); } + if (scenario == CustomerDisplayScenario.success) { + expect(find.text('本次购买商品'), findsOneWidget); + expect(find.text('请核对商品信息'), findsNothing); + expect(find.byKey(const ValueKey('customer-payment-success')), + findsOneWidget); + expect(find.byKey(const ValueKey('customer-success-countdown')), + findsOneWidget); + expect(find.text('请带好您的商品'), findsOneWidget); + } + await tester.pumpWidget(const SizedBox.shrink()); coordinator.dispose(); } diff --git a/test/sub_screen/sub_display_coordinator_test.dart b/test/sub_screen/sub_display_coordinator_test.dart index 2efd4e8..992d4c3 100644 --- a/test/sub_screen/sub_display_coordinator_test.dart +++ b/test/sub_screen/sub_display_coordinator_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; void main() { @@ -77,6 +78,40 @@ void main() { ); }); + testWidgets('支付成功期间保持结算页并在十秒倒计时结束后返回欢迎页', (tester) async { + final customerCoordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 2, + ); + final coordinator = SubDisplayCoordinator( + customerCoordinator: customerCoordinator, + ); + addTearDown(coordinator.dispose); + + coordinator.handleAction('updateCard', _productEvent()); + expect(coordinator.handleAction('orderPaySuccess'), isTrue); + expect(coordinator.mode, SubDisplayMode.settlement); + expect( + coordinator.customerCoordinator.state.scenario.name, + 'success', + ); + + expect(coordinator.handleAction('jumpSubBack'), isFalse); + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isFalse, + ); + expect(coordinator.mode, SubDisplayMode.settlement); + + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.mode, SubDisplayMode.settlement); + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + testWidgets('收银人脸结束后恢复进入人脸前的页面', (tester) async { final coordinator = SubDisplayCoordinator(); addTearDown(coordinator.dispose);