feat:"收银"页面副屏的支付成功状态。
feat:"收银"弹框增加重新识别功能。
This commit is contained in:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user