feat:"收银"页面副屏的支付成功状态。

feat:"收银"弹框增加重新识别功能。
This commit is contained in:
dzc committed 2026-08-14 18:41:13 +08:00
1 parent aff30bf45d
commit 8cfbbd768e
17 files changed
+1104 -123

No files matched your search

@@ -49,6 +49,9 @@ enum SubScreenAction implements BaseEnum {
/// 取消人脸识别副屏会话。
faceRecognitionCancel("faceRecognitionCancel", "人脸识别取消"),
/// 通知副屏当前订单已经完成支付,适用于饭卡、钱包、人脸和付款码等非聚合码支付。
orderPaySuccess("orderPaySuccess", "订单支付成功"),
;
@override
+44 -1
View File
@@ -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<OrderPayDisplayType> 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 <String>[];
}
/// 支付排序属于终端 UI 扩展配置,与现有支付显隐和默认方式一起原子保存。
/// 读取允许重新识别人员的支付方式;缺少或非列表配置使用默认值,显式空列表保持全部关闭。
static Future<Set<OrderPayDisplayType>>
getCashierReidentifiablePayTypes() async {
final setting = await _getSetting();
final sysJson = Map<String, dynamic>.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<String>()
.map((code) => payTypeByCode[code])
.whereType<OrderPayDisplayType>()
.toSet();
}
/// 支付排序、显隐、默认方式及重新识别能力作为同一份终端支付偏好原子保存。
static Future<void> setPayTypePreferences({
required SysSetting sysInfo,
required List<OrderPayDisplayType> order,
required Set<OrderPayDisplayType> 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;
});
}
@@ -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<void> 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<List<Map<String, dynamic>>> buildSubScreenShopList(
List<ShopCardEntity> cartItems,
@@ -79,10 +79,16 @@ class SelectPayTypeDialogController extends GetxController {
RxList<OrderPayDisplayType> usePayDisplayTypeList =
<OrderPayDisplayType>[].obs;
/// 当前允许重新识别支付人员的支付方式,来源于本机收银页面设置。
final reidentifiablePayTypes = <OrderPayDisplayType>{}.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<void> 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<void> _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<void> _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<void> 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<void> _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 = <ServiceWallet>[];
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<ServiceUser> _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<void> _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 = <ServiceWallet>[];
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<void> _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<ServiceWallet> 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<List<ServiceWallet>> _loadWalletListForUser(
ServiceUser user, {
bool forceWalletLookup = false,
}) async {
if (appSetting.sysType != SysType.SUPERMARKET) return <ServiceWallet>[];
final payType = orderPayDisplayType.value;
if (payType == null ||
(!forceWalletLookup &&
!OrderPayDisplayType.isSelectWalletPayType(payType)) ||
AppUserTool.isGenerateExternalUser(user)) {
return <ServiceWallet>[];
}
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<void> _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 <Widget>[
_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 = <Widget>[
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),
<Widget>[
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
? <Widget>[
information,
SizedBox(height: 10.h),
action.width(double.infinity),
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch)
: <Widget>[
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,
+23 -1
View File
@@ -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|');
@@ -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(
@@ -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),
@@ -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<OrderPayDisplayType> order;
/// 当前在收银页展示的支付方式集合。
final Set<OrderPayDisplayType> visibleTypes;
/// 当前允许重新识别人员的支付方式集合。
final Set<OrderPayDisplayType> reidentifiableTypes;
/// 当前默认支付方式;为空表示没有可用支付方式。
final OrderPayDisplayType? defaultType;
/// 恢复支付方式默认顺序、显隐和重新识别开关的回调。
final VoidCallback onReset;
/// 调整支付方式排序的回调,索引语义遵循 ReorderableListView。
final void Function(int oldIndex, int newIndex) onReorder;
/// 切换支付方式显隐状态的回调。
final ValueChanged<OrderPayDisplayType> onVisibilityToggle;
/// 切换支付方式重新识别能力的回调。
final ValueChanged<OrderPayDisplayType> 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<bool> onVisibleChanged;
/// 重新识别开关变化回调。
final ValueChanged<bool> 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(
@@ -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,
},
);
}
@@ -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;
@@ -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() {
+102 -4
View File
@@ -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: <Widget>[
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,
),
+80 -3
View File
@@ -88,9 +88,8 @@ void main() {
OrderPayDisplayType.qrCode,
]
..defaultPayDisplayType = OrderPayDisplayType.cash;
final controller = Get.put<SelectPayTypeDialogController>(
_CashierDialogTestController(setting),
);
final controller = _CashierDialogTestController(setting);
Get.put<SelectPayTypeDialogController>(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<void> reidentifyCurrentUser() async {
reidentifyCount++;
lastReidentificationMethod = currentReidentificationMethod;
}
}
@@ -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>{
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);
});
}
@@ -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'] = <Map<String, dynamic>>[],
),
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<Map<String, dynamic>>;
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());
@@ -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();
}
@@ -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'] = <Map<String, dynamic>>[],
),
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);