副屏
This commit is contained in:
1 parent
154122a847
commit
2e929c199b
21 files changed
+5517
-85
No files matched your search
@@ -0,0 +1,423 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart';
|
||||
|
||||
/// 副屏 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);
|
||||
if (scenario == CustomerDisplayScenario.paying) {
|
||||
coordinator._startCountdown();
|
||||
}
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
/// 普通二维码支付的默认有效秒数。
|
||||
final int qrCountdownSeconds;
|
||||
|
||||
/// 支付成功后保留商品、金额和成功提示的秒数,倒计时结束后自动清屏。
|
||||
final int successCountdownSeconds;
|
||||
|
||||
/// 倒计时刷新间隔;生产环境为一秒,测试可注入更短间隔。
|
||||
final Duration timerTick;
|
||||
|
||||
CustomerDisplayState _state;
|
||||
Timer? _countdownTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
CustomerDisplayState get state => _state;
|
||||
|
||||
/// 无效、重复或乱序消息不会清空最近一次有效状态。
|
||||
bool handleAction(String action, [Object? arguments]) {
|
||||
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();
|
||||
// 业务入口会在购物车创建前发送跳页消息;没有商品上下文时继续待机,
|
||||
// 避免副屏提前露出空的结算框架。
|
||||
if (_state.products.isEmpty) return false;
|
||||
return _replace(_state.withScenario(CustomerDisplayScenario.review));
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (action == _SubScreenProtocol.qrCodeScan ||
|
||||
action == _SubScreenProtocol.aggregateOrderPay) {
|
||||
// 清屏后的迟到支付消息没有订单上下文,必须继续展示欢迎页,
|
||||
// 不能重新构造一个无商品、无金额的支付框架。
|
||||
if (_state.products.isEmpty) return false;
|
||||
final isAggregate = action == _SubScreenProtocol.aggregateOrderPay;
|
||||
final qrCodeData = _parseQrCode(
|
||||
arguments,
|
||||
requireAggregateProtocol: isAggregate,
|
||||
);
|
||||
if (qrCodeData == null) return false;
|
||||
final countdownSeconds = isAggregate
|
||||
? _parseAggregateCountdown(arguments)
|
||||
: qrCountdownSeconds;
|
||||
if (countdownSeconds == null) return false;
|
||||
// 插件可能重复投递同一支付消息,不能因此重置顾客正在观察的倒计时。
|
||||
if (_state.scenario == CustomerDisplayScenario.paying &&
|
||||
_state.qrCodeData == qrCodeData &&
|
||||
_state.countdownSeconds > 0) {
|
||||
return false;
|
||||
}
|
||||
final changed = _replace(
|
||||
_state.withScenario(
|
||||
CustomerDisplayScenario.paying,
|
||||
qrCodeData: qrCodeData,
|
||||
countdownSeconds: countdownSeconds,
|
||||
),
|
||||
);
|
||||
if (changed) _startCountdown();
|
||||
return changed;
|
||||
}
|
||||
if (action == _SubScreenProtocol.orderPaySuccess ||
|
||||
action == _SubScreenProtocol.qrCodeScanSuccess ||
|
||||
action == _SubScreenProtocol.aggregateOrderPaySuccess) {
|
||||
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();
|
||||
final changed = _replace(
|
||||
_state.withScenario(
|
||||
CustomerDisplayScenario.success,
|
||||
countdownSeconds: successCountdownSeconds,
|
||||
),
|
||||
);
|
||||
if (changed) _startCountdown();
|
||||
return changed;
|
||||
}
|
||||
if (action == _SubScreenProtocol.qrCodeScanCancel ||
|
||||
action == _SubScreenProtocol.aggregateOrderPayCancel) {
|
||||
if (_state.scenario != CustomerDisplayScenario.paying) return false;
|
||||
_stopCountdown();
|
||||
return _replace(_state.withScenario(CustomerDisplayScenario.cancelled));
|
||||
}
|
||||
} catch (_) {
|
||||
// 平台消息来自另一个 Engine,解析失败时保留最近有效状态即可,不能影响副屏渲染。
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
CustomerDisplayState? _parseProductState(Object? arguments) {
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return null;
|
||||
final rawProducts = data['shopList'];
|
||||
final totalPrice = _nonNegativeInt(data['totalPrice']);
|
||||
final amountMealPrice = _nonNegativeInt(data['amountMealPrice']);
|
||||
final businessTitle = _businessTitle(data['sysType']);
|
||||
if (rawProducts is! List ||
|
||||
totalPrice == null ||
|
||||
amountMealPrice == null ||
|
||||
businessTitle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final products = <CustomerDisplayProduct>[];
|
||||
for (final rawProduct in rawProducts) {
|
||||
final product = _parseProduct(rawProduct);
|
||||
if (product == null) return null;
|
||||
products.add(product);
|
||||
}
|
||||
|
||||
// 空购物车的有效快照等价于业务清屏,欢迎页不保留上一位顾客的信息。
|
||||
if (products.isEmpty) return CustomerDisplayState.idle();
|
||||
|
||||
String? customerName;
|
||||
final rawPayUser = data['payUser'];
|
||||
if (rawPayUser != null) {
|
||||
final payUser = _normalizeMap(rawPayUser);
|
||||
if (payUser == null) return null;
|
||||
final username = payUser['username'];
|
||||
if (username != null && username is! String) return null;
|
||||
customerName = username?.trim();
|
||||
}
|
||||
|
||||
return CustomerDisplayState(
|
||||
scenario: CustomerDisplayScenario.review,
|
||||
businessTitle: businessTitle,
|
||||
customerName: customerName,
|
||||
products: List.unmodifiable(products),
|
||||
totalPrice: totalPrice,
|
||||
amountMealPrice: amountMealPrice,
|
||||
);
|
||||
}
|
||||
|
||||
/// 将单个主屏购物车条目解析为副屏商品,并兼容旧协议缺失套餐字段的情况。
|
||||
CustomerDisplayProduct? _parseProduct(Object? rawProduct) {
|
||||
final data = _normalizeMap(rawProduct);
|
||||
if (data == null) return null;
|
||||
final name = data['productName'];
|
||||
final unitPrice = _nonNegativeInt(data['productPrice']);
|
||||
final totalPrice = _nonNegativeInt(data['totalPrice']);
|
||||
final nature = data['nature'];
|
||||
final rawImageSource = data['productPic'];
|
||||
if (name is! String ||
|
||||
name.trim().isEmpty ||
|
||||
unitPrice == null ||
|
||||
totalPrice == null ||
|
||||
nature is! String ||
|
||||
(rawImageSource != null && rawImageSource is! String)) {
|
||||
return null;
|
||||
}
|
||||
final trimmedImageSource = (rawImageSource as String?)?.trim();
|
||||
|
||||
String quantityLabel;
|
||||
if (nature == 'WEIGHT') {
|
||||
final weight = data['productWidget'];
|
||||
if (weight is! num || !weight.isFinite || weight < 0) return null;
|
||||
quantityLabel = '${weight.toDouble().toStringAsFixed(2)} kg';
|
||||
} else if (nature == 'DEFAULT') {
|
||||
final count = _nonNegativeInt(data['productNumber']);
|
||||
if (count == null) return null;
|
||||
quantityLabel = '$count 件';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawIsCombo = data['isCombo'];
|
||||
if (rawIsCombo != null && rawIsCombo is! bool) return null;
|
||||
final comboItems = <CustomerDisplayComboItem>[];
|
||||
final rawComboItems = data['comboItems'];
|
||||
if (rawComboItems != null) {
|
||||
if (rawComboItems is! List) return null;
|
||||
for (final rawComboItem in rawComboItems) {
|
||||
final comboItem = _parseComboItem(rawComboItem);
|
||||
if (comboItem == null) return null;
|
||||
comboItems.add(comboItem);
|
||||
}
|
||||
}
|
||||
|
||||
return CustomerDisplayProduct(
|
||||
name: name.trim(),
|
||||
quantityLabel: quantityLabel,
|
||||
unitPrice: unitPrice,
|
||||
totalPrice: totalPrice,
|
||||
imageSource:
|
||||
trimmedImageSource?.isEmpty == true ? null : trimmedImageSource,
|
||||
// 套餐组成非空时兼容主屏仅透传明细但缺失标识的过渡消息。
|
||||
isCombo: rawIsCombo == true || comboItems.isNotEmpty,
|
||||
comboItems: List.unmodifiable(comboItems),
|
||||
);
|
||||
}
|
||||
|
||||
/// 校验主屏透传的套餐组成;份数必须为正,避免副屏展示无意义的零份菜品。
|
||||
CustomerDisplayComboItem? _parseComboItem(Object? rawComboItem) {
|
||||
final data = _normalizeMap(rawComboItem);
|
||||
if (data == null) return null;
|
||||
final name = data['name'];
|
||||
final quantity = _nonNegativeInt(data['quantity']);
|
||||
if (name is! String ||
|
||||
name.trim().isEmpty ||
|
||||
quantity == null ||
|
||||
quantity == 0) {
|
||||
return null;
|
||||
}
|
||||
return CustomerDisplayComboItem(
|
||||
name: name.trim(),
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析二维码内容;聚合码校验独立协议,旧动作同时拒绝聚合协议内容反向混入。
|
||||
String? _parseQrCode(
|
||||
Object? arguments, {
|
||||
required bool requireAggregateProtocol,
|
||||
}) {
|
||||
final data = _normalizeMap(arguments);
|
||||
final value = data?['qrCodeData'];
|
||||
if (value is! String || value.trim().isEmpty) return null;
|
||||
if (requireAggregateProtocol) {
|
||||
if (data?['qrCodeType'] != 'AGGREGATE_ORDER_PAY' ||
|
||||
data?['aggregateNo'] is! String ||
|
||||
(data?['aggregateNo'] as String).trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
if (uri == null ||
|
||||
uri.scheme != 'https' ||
|
||||
!uri.path.contains('/aggregate-order-pay/cashier/')) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
if (data?['qrCodeType'] == 'AGGREGATE_ORDER_PAY' ||
|
||||
(uri?.path.contains('/aggregate-order-pay/cashier/') ?? false)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// 按服务端过期时间计算聚合码剩余秒数,拒绝过期或异常时间。
|
||||
int? _parseAggregateCountdown(Object? arguments) {
|
||||
final data = _normalizeMap(arguments);
|
||||
final rawExpireTime = data?['expireTime'];
|
||||
if (rawExpireTime is! String) return null;
|
||||
final expireTime = DateTime.tryParse(rawExpireTime);
|
||||
if (expireTime == null) return null;
|
||||
final milliseconds = expireTime.difference(DateTime.now()).inMilliseconds;
|
||||
if (milliseconds <= 0) return null;
|
||||
final seconds = (milliseconds / Duration.millisecondsPerSecond).ceil();
|
||||
return seconds.clamp(1, qrCountdownSeconds).toInt();
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _normalizeMap(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
try {
|
||||
final normalized = jsonDecode(jsonEncode(value));
|
||||
if (normalized is Map<String, dynamic>) return normalized;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
if (value is! num || !value.isFinite || value < 0) return null;
|
||||
if (value != value.truncateToDouble()) return null;
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
String? _businessTitle(Object? value) {
|
||||
return switch (value) {
|
||||
'SUPERMARKET' => '智慧超市',
|
||||
'CANTEEN' => '智慧食堂',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// 启动支付码或支付成功倒计时;支付码到期进入超时态,成功倒计时到期清除顾客数据。
|
||||
void _startCountdown() {
|
||||
_stopCountdown();
|
||||
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 != countdownScenario) {
|
||||
_stopCountdown();
|
||||
return;
|
||||
}
|
||||
final nextSeconds = _state.countdownSeconds - 1;
|
||||
if (nextSeconds <= 0) {
|
||||
_stopCountdown();
|
||||
final nextState = countdownScenario == CustomerDisplayScenario.success
|
||||
? CustomerDisplayState.idle()
|
||||
: _state.withScenario(CustomerDisplayScenario.timeout);
|
||||
_replace(nextState);
|
||||
return;
|
||||
}
|
||||
_replace(
|
||||
_state.withScenario(
|
||||
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;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 释放协调器并取消仍在运行的倒计时,防止副屏 Engine 销毁后继续回调。
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopCountdown();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
|
||||
|
||||
enum FaceRecognitionDisplayTransition {
|
||||
none,
|
||||
show,
|
||||
restoreNow,
|
||||
restoreAfterSuccess
|
||||
}
|
||||
|
||||
class FaceRecognitionActionResult {
|
||||
const FaceRecognitionActionResult(this.accepted, this.transition);
|
||||
|
||||
static const ignored = FaceRecognitionActionResult(
|
||||
false,
|
||||
FaceRecognitionDisplayTransition.none,
|
||||
);
|
||||
|
||||
final bool accepted;
|
||||
final FaceRecognitionDisplayTransition transition;
|
||||
}
|
||||
|
||||
/// 副屏人脸状态只消费版本递增的当前 Session,避免上一位顾客的消息覆盖下一位。
|
||||
class FaceRecognitionCoordinator extends ChangeNotifier {
|
||||
static const _start = 'faceRecognitionStart';
|
||||
static const _detecting = 'faceRecognitionDetecting';
|
||||
static const _success = 'faceRecognitionSuccess';
|
||||
static const _failed = 'faceRecognitionFailed';
|
||||
static const _cancel = 'faceRecognitionCancel';
|
||||
|
||||
FaceRecognitionDisplayState? _state;
|
||||
bool _disposed = false;
|
||||
|
||||
FaceRecognitionDisplayState? get state => _state;
|
||||
|
||||
FaceRecognitionActionResult handleAction(String action, Object? arguments) {
|
||||
if (_disposed) return FaceRecognitionActionResult.ignored;
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return FaceRecognitionActionResult.ignored;
|
||||
final sessionId = data['sessionId'];
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
final message = data['message'];
|
||||
if (sessionId is! String || sessionId.trim().isEmpty || revision == null) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
|
||||
if (action == _start) {
|
||||
final scene = _parseScene(data['scene']);
|
||||
if (scene == null) return FaceRecognitionActionResult.ignored;
|
||||
final current = _state;
|
||||
if (current != null &&
|
||||
current.sessionId != sessionId &&
|
||||
current.status != FaceRecognitionDisplayStatus.success &&
|
||||
current.status != FaceRecognitionDisplayStatus.failed &&
|
||||
current.status != FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
// 取消后不允许同一会话的迟到开始消息重新打开副屏人脸页。
|
||||
if (current != null &&
|
||||
current.sessionId == sessionId &&
|
||||
current.status == FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
if (current != null &&
|
||||
current.sessionId == sessionId &&
|
||||
revision <= current.revision) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
_state = FaceRecognitionDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scene: scene,
|
||||
status: FaceRecognitionDisplayStatus.initializing,
|
||||
message: message is String && message.trim().isNotEmpty
|
||||
? message.trim()
|
||||
: '正在启动摄像头',
|
||||
payableAmount: _nonNegativeInt(data['payableAmount']),
|
||||
totalAmount: _nonNegativeInt(data['totalAmount']),
|
||||
discountAmount: _nonNegativeInt(data['discountAmount']),
|
||||
);
|
||||
notifyListeners();
|
||||
return const FaceRecognitionActionResult(
|
||||
true,
|
||||
FaceRecognitionDisplayTransition.show,
|
||||
);
|
||||
}
|
||||
|
||||
final current = _state;
|
||||
if (current == null ||
|
||||
current.sessionId != sessionId ||
|
||||
revision <= current.revision) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
// 取消是本会话的终态,原生取消过程中迟到的检测事件不得恢复视图。
|
||||
if (current.status == FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
final status = switch (action) {
|
||||
_detecting => FaceRecognitionDisplayStatus.detecting,
|
||||
_success => FaceRecognitionDisplayStatus.success,
|
||||
_failed => FaceRecognitionDisplayStatus.failed,
|
||||
_cancel => FaceRecognitionDisplayStatus.cancelled,
|
||||
_ => null,
|
||||
};
|
||||
if (status == null) return FaceRecognitionActionResult.ignored;
|
||||
_state = current.copyWith(
|
||||
revision: revision,
|
||||
status: status,
|
||||
message: message is String && message.trim().isNotEmpty
|
||||
? message.trim()
|
||||
: _defaultMessage(status),
|
||||
);
|
||||
notifyListeners();
|
||||
return FaceRecognitionActionResult(
|
||||
true,
|
||||
switch (status) {
|
||||
FaceRecognitionDisplayStatus.success =>
|
||||
FaceRecognitionDisplayTransition.restoreAfterSuccess,
|
||||
FaceRecognitionDisplayStatus.cancelled =>
|
||||
FaceRecognitionDisplayTransition.restoreNow,
|
||||
_ => FaceRecognitionDisplayTransition.show,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _defaultMessage(FaceRecognitionDisplayStatus status) {
|
||||
return switch (status) {
|
||||
FaceRecognitionDisplayStatus.initializing => '正在启动摄像头',
|
||||
FaceRecognitionDisplayStatus.detecting => '请正对摄像头',
|
||||
FaceRecognitionDisplayStatus.success => '识别成功,请稍候',
|
||||
FaceRecognitionDisplayStatus.failed => '识别未完成,请按主屏提示重试',
|
||||
FaceRecognitionDisplayStatus.cancelled => '已取消人脸识别',
|
||||
};
|
||||
}
|
||||
|
||||
FaceRecognitionDisplayScene? _parseScene(Object? value) {
|
||||
for (final scene in FaceRecognitionDisplayScene.values) {
|
||||
if (scene.name == value) return scene;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object?>? _normalizeMap(Object? value) {
|
||||
if (value is! Map) return null;
|
||||
return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
final parsed = value is int
|
||||
? value
|
||||
: value is num
|
||||
? value.toInt()
|
||||
: int.tryParse('$value');
|
||||
return parsed != null && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
library sub_utils;
|
||||
|
||||
export './customer_display_coordinator.dart';
|
||||
export './face_recognition_coordinator.dart';
|
||||
export './reservation_query_coordinator.dart';
|
||||
export './sub_display_coordinator.dart';
|
||||
export './sub_page_tool.dart';
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
|
||||
|
||||
/// 校验并维护预约查询副屏快照,拒绝重复、乱序、旧会话和格式错误消息。
|
||||
class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
ReservationQueryCoordinator({
|
||||
ReservationQueryDisplayState? initialState,
|
||||
this.timerTick = const Duration(seconds: 1),
|
||||
DateTime Function()? now,
|
||||
}) : assert(timerTick > Duration.zero),
|
||||
_state = initialState ?? ReservationQueryDisplayState.idle(),
|
||||
_now = now ?? DateTime.now {
|
||||
if (_state.expiresAt != null) _startExpiryTimer();
|
||||
}
|
||||
|
||||
factory ReservationQueryCoordinator.demo(
|
||||
ReservationQueryDisplayScenario scenario,
|
||||
) {
|
||||
return ReservationQueryCoordinator(
|
||||
initialState: ReservationQueryDisplayState.demo(scenario),
|
||||
);
|
||||
}
|
||||
|
||||
final Duration timerTick;
|
||||
final DateTime Function() _now;
|
||||
|
||||
ReservationQueryDisplayState _state;
|
||||
Timer? _expiryTimer;
|
||||
String? _activeSessionId;
|
||||
int _lastRevision = -1;
|
||||
bool _disposed = false;
|
||||
|
||||
ReservationQueryDisplayState get state => _state;
|
||||
|
||||
bool handleUpdate(Object? arguments) {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return false;
|
||||
final next = _parseState(data);
|
||||
if (next == null || !_canAccept(next.sessionId, next.revision)) {
|
||||
return false;
|
||||
}
|
||||
_commitEnvelope(next.sessionId, next.revision);
|
||||
if (next.expiresAt != null && !next.expiresAt!.isAfter(_now())) {
|
||||
_stopExpiryTimer();
|
||||
return _replace(_privacyClearedState(next));
|
||||
}
|
||||
final changed = _replace(next);
|
||||
if (next.expiresAt == null) {
|
||||
_stopExpiryTimer();
|
||||
} else {
|
||||
_startExpiryTimer();
|
||||
}
|
||||
return changed;
|
||||
} catch (_) {
|
||||
// 副屏输入不可信,任何解析异常都只丢弃当前消息并保留最近有效快照。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool handleClear(Object? arguments) {
|
||||
if (_disposed) return false;
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return false;
|
||||
final sessionId = _nonEmptyString(data['sessionId']);
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
if (sessionId == null ||
|
||||
revision == null ||
|
||||
!_canAccept(sessionId, revision)) {
|
||||
return false;
|
||||
}
|
||||
_commitEnvelope(sessionId, revision);
|
||||
_stopExpiryTimer();
|
||||
return _replace(
|
||||
ReservationQueryDisplayState.idle(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ReservationQueryDisplayState? _parseState(Map<String, dynamic> data) {
|
||||
final sessionId = _nonEmptyString(data['sessionId']);
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
final scenarioName = _nonEmptyString(data['scenario']);
|
||||
final continuousFaceRecognition =
|
||||
_optionalBool(data['continuousFaceRecognition']);
|
||||
ReservationQueryDisplayScenario? scenario;
|
||||
for (final value in ReservationQueryDisplayScenario.values) {
|
||||
if (value.name == scenarioName) {
|
||||
scenario = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionId == null || revision == null || scenario == null) return null;
|
||||
|
||||
if (scenario == ReservationQueryDisplayScenario.idle ||
|
||||
scenario == ReservationQueryDisplayScenario.recognizing ||
|
||||
scenario == ReservationQueryDisplayScenario.loading ||
|
||||
scenario == ReservationQueryDisplayScenario.error) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario != ReservationQueryDisplayScenario.success &&
|
||||
scenario != ReservationQueryDisplayScenario.empty) {
|
||||
return null;
|
||||
}
|
||||
final maskedName = _nonEmptyString(data['maskedName']);
|
||||
final merchantName = _nonEmptyString(data['merchantName']);
|
||||
final deptName = _optionalString(data['deptName']);
|
||||
final totalDayCount = _nonNegativeInt(data['totalDayCount']);
|
||||
final totalMealCount = _nonNegativeInt(data['totalMealCount']);
|
||||
final currentPage = _positiveInt(data['currentPage']);
|
||||
final totalPages = _positiveInt(data['totalPages']);
|
||||
final expiresAtText = _nonEmptyString(data['expiresAt']);
|
||||
final expiresAt = DateTime.tryParse(expiresAtText ?? '');
|
||||
final rawDays = data['days'];
|
||||
if (maskedName == null ||
|
||||
merchantName == null ||
|
||||
totalDayCount == null ||
|
||||
totalMealCount == null ||
|
||||
currentPage == null ||
|
||||
totalPages == null ||
|
||||
currentPage > totalPages ||
|
||||
expiresAt == null ||
|
||||
rawDays is! List ||
|
||||
rawDays.length > 10) {
|
||||
return null;
|
||||
}
|
||||
final days = <ReservationMenuDayData>[];
|
||||
for (final rawDay in rawDays) {
|
||||
if (rawDay is! Map) return null;
|
||||
days.add(
|
||||
ReservationMenuDayData.fromJson(Map<String, dynamic>.from(rawDay)),
|
||||
);
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.success && days.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.empty && days.isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
final remaining = (expiresAt.difference(_now()).inMilliseconds / 1000)
|
||||
.ceil()
|
||||
.clamp(0, 1 << 30)
|
||||
.toInt();
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario,
|
||||
maskedName: maskedName,
|
||||
deptName: deptName,
|
||||
merchantName: merchantName,
|
||||
totalDayCount: totalDayCount,
|
||||
totalMealCount: totalMealCount,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
days: List.unmodifiable(days),
|
||||
expiresAt: expiresAt,
|
||||
remainingSeconds: remaining,
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
bool _canAccept(String sessionId, int revision) {
|
||||
final active = _activeSessionId;
|
||||
if (active == null) return true;
|
||||
if (active == sessionId) return revision > _lastRevision;
|
||||
|
||||
// 主屏会话 ID 使用微秒时间戳,新会话必须大于当前会话,避免离页后的旧消息回流。
|
||||
final activeOrder = int.tryParse(active);
|
||||
final incomingOrder = int.tryParse(sessionId);
|
||||
if (activeOrder != null && incomingOrder != null) {
|
||||
return incomingOrder > activeOrder;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _commitEnvelope(String sessionId, int revision) {
|
||||
_activeSessionId = sessionId;
|
||||
_lastRevision = revision;
|
||||
}
|
||||
|
||||
void _startExpiryTimer() {
|
||||
_stopExpiryTimer();
|
||||
final expiresAt = _state.expiresAt;
|
||||
if (expiresAt == null) return;
|
||||
_expiryTimer = Timer.periodic(timerTick, (_) {
|
||||
if (_disposed) return;
|
||||
final remaining =
|
||||
(expiresAt.difference(_now()).inMilliseconds / 1000).ceil();
|
||||
if (remaining <= 0) {
|
||||
_stopExpiryTimer();
|
||||
_replace(_privacyClearedState(_state));
|
||||
return;
|
||||
}
|
||||
_replace(_state.copyWith(remainingSeconds: remaining));
|
||||
});
|
||||
}
|
||||
|
||||
void _stopExpiryTimer() {
|
||||
_expiryTimer?.cancel();
|
||||
_expiryTimer = null;
|
||||
}
|
||||
|
||||
bool _replace(ReservationQueryDisplayState next) {
|
||||
if (_disposed || next == _state) return false;
|
||||
_state = next;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _normalizeMap(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
try {
|
||||
final normalized = jsonDecode(jsonEncode(value));
|
||||
return normalized is Map<String, dynamic> ? normalized : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _nonEmptyString(Object? value) {
|
||||
if (value is! String || value.trim().isEmpty) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
String? _optionalString(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is! String) throw const FormatException('字符串字段格式错误');
|
||||
final normalized = value.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
if (value is! num || !value.isFinite || value < 0) return null;
|
||||
if (value != value.truncateToDouble()) return null;
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
int? _positiveInt(Object? value) {
|
||||
final result = _nonNegativeInt(value);
|
||||
return result == null || result == 0 ? null : result;
|
||||
}
|
||||
|
||||
/// 读取可选布尔协议字段;旧主屏未发送时按关闭处理,错误类型直接拒绝整条消息。
|
||||
bool _optionalBool(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
throw const FormatException('布尔字段格式错误');
|
||||
}
|
||||
|
||||
/// 清除身份结果;持续人脸保留组合页并等待下一位,其他模式回到普通空闲态。
|
||||
ReservationQueryDisplayState _privacyClearedState(
|
||||
ReservationQueryDisplayState current,
|
||||
) {
|
||||
if (!current.continuousFaceRecognition) {
|
||||
return ReservationQueryDisplayState.idle(
|
||||
sessionId: current.sessionId,
|
||||
revision: current.revision,
|
||||
);
|
||||
}
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: current.sessionId,
|
||||
revision: current.revision,
|
||||
scenario: ReservationQueryDisplayScenario.recognizing,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopExpiryTimer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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';
|
||||
import 'face_recognition_coordinator.dart';
|
||||
import 'reservation_query_coordinator.dart';
|
||||
|
||||
enum SubDisplayMode { welcome, settlement, reservationQuery, faceRecognition }
|
||||
|
||||
/// 副屏唯一消息入口,负责在欢迎、结算、预约查询和人脸页面之间切换。
|
||||
class SubDisplayCoordinator extends ChangeNotifier {
|
||||
SubDisplayCoordinator({
|
||||
CustomerDisplayCoordinator? customerCoordinator,
|
||||
ReservationQueryCoordinator? reservationCoordinator,
|
||||
FaceRecognitionCoordinator? faceRecognitionCoordinator,
|
||||
}) : customerCoordinator =
|
||||
customerCoordinator ?? CustomerDisplayCoordinator(),
|
||||
reservationCoordinator =
|
||||
reservationCoordinator ?? ReservationQueryCoordinator(),
|
||||
faceRecognitionCoordinator =
|
||||
faceRecognitionCoordinator ?? FaceRecognitionCoordinator() {
|
||||
_lastReservationScenario = this.reservationCoordinator.state.scenario;
|
||||
this.customerCoordinator.addListener(_notifyFromChild);
|
||||
this.reservationCoordinator.addListener(_notifyFromReservation);
|
||||
this.faceRecognitionCoordinator.addListener(_notifyFromChild);
|
||||
}
|
||||
|
||||
static const _reservationUpdate = 'reservationQueryUpdate';
|
||||
static const _reservationClear = 'reservationQueryClear';
|
||||
static const _faceStart = 'faceRecognitionStart';
|
||||
static const _updateCard = 'updateCard';
|
||||
static const _jumpSubPage = 'jumpSubPage';
|
||||
static const _jumpSubBack = 'jumpSubBack';
|
||||
static const _paymentActions = {
|
||||
'orderPaySuccess',
|
||||
'qrCodeScan',
|
||||
'qrCodeScanSuccess',
|
||||
'qrCodeScanCancel',
|
||||
'aggregateOrderPay',
|
||||
'aggregateOrderPaySuccess',
|
||||
'aggregateOrderPayCancel',
|
||||
};
|
||||
|
||||
final CustomerDisplayCoordinator customerCoordinator;
|
||||
final ReservationQueryCoordinator reservationCoordinator;
|
||||
final FaceRecognitionCoordinator faceRecognitionCoordinator;
|
||||
|
||||
SubDisplayMode _mode = SubDisplayMode.welcome;
|
||||
SubDisplayMode _modeBeforeFace = SubDisplayMode.welcome;
|
||||
Timer? _faceRestoreTimer;
|
||||
String? _reservationSessionAtFaceStart;
|
||||
int _reservationRevisionAtFaceStart = -1;
|
||||
bool _reservationSuccessDelayPassed = false;
|
||||
bool _reservationResultReady = false;
|
||||
bool _reservationClearedDuringFace = false;
|
||||
bool _reservationFaceHandoffActive = false;
|
||||
late ReservationQueryDisplayScenario _lastReservationScenario;
|
||||
bool _disposed = false;
|
||||
|
||||
SubDisplayMode get mode => _mode;
|
||||
|
||||
bool handleAction(String action, [Object? arguments]) {
|
||||
if (_disposed) return false;
|
||||
if (action.startsWith('faceRecognition')) {
|
||||
final result = faceRecognitionCoordinator.handleAction(action, arguments);
|
||||
if (!result.accepted) return false;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (_isContinuousReservationFace(faceState)) {
|
||||
// 持续人脸由预约组合页长期持有副屏 Texture,人脸动作只刷新状态和框,
|
||||
// 禁止再切换到旧的独立人脸页面或等待三秒恢复计时器。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_modeBeforeFace = SubDisplayMode.reservationQuery;
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
return true;
|
||||
}
|
||||
switch (result.transition) {
|
||||
case FaceRecognitionDisplayTransition.show:
|
||||
_faceRestoreTimer?.cancel();
|
||||
if (action == _faceStart) {
|
||||
_resetReservationFaceHandoff();
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
_beginReservationFaceHandoff();
|
||||
}
|
||||
}
|
||||
if (_mode != SubDisplayMode.faceRecognition) _modeBeforeFace = _mode;
|
||||
_replaceMode(SubDisplayMode.faceRecognition);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreNow:
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_replaceMode(_modeBeforeFace);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreAfterSuccess:
|
||||
_faceRestoreTimer?.cancel();
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
if (_reservationClearedDuringFace) {
|
||||
_replaceMode(SubDisplayMode.welcome);
|
||||
_resetReservationFaceHandoff();
|
||||
break;
|
||||
}
|
||||
// 预约查询必须在人脸成功提示展示满 1 秒且查询拿到最终结果后再恢复,
|
||||
// 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = _hasNewReservationTerminalState();
|
||||
_faceRestoreTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (_disposed) return;
|
||||
_reservationSuccessDelayPassed = true;
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
});
|
||||
} else {
|
||||
_resetReservationFaceHandoff();
|
||||
_faceRestoreTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (!_disposed) _replaceMode(_modeBeforeFace);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.none:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action == _reservationUpdate) {
|
||||
final accepted = reservationCoordinator.handleUpdate(arguments);
|
||||
if (accepted) {
|
||||
_reservationClearedDuringFace = false;
|
||||
if (reservationCoordinator.state.continuousFaceRecognition) {
|
||||
// 该快照必须先于原生 faceStart 落地;即使消息顺序偶发反转,也强制恢复组合页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_modeBeforeFace = SubDisplayMode.reservationQuery;
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
} else {
|
||||
_showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
_handleReservationUpdateDuringFace();
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (action == _reservationClear) {
|
||||
final accepted = reservationCoordinator.handleClear(arguments);
|
||||
if (accepted) {
|
||||
_showOrRememberMode(SubDisplayMode.welcome);
|
||||
_handleReservationClearDuringFace();
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (action == _jumpSubBack) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (action == _jumpSubPage) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (action == _updateCard) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (_paymentActions.contains(action)) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _replaceMode(SubDisplayMode next) {
|
||||
if (_mode == next) return false;
|
||||
_mode = next;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 判断当前人脸动作是否属于已启用组合页的预约持续识别会话。
|
||||
bool _isContinuousReservationFace(FaceRecognitionDisplayState? faceState) {
|
||||
return faceState?.scene == FaceRecognitionDisplayScene.reservation &&
|
||||
reservationCoordinator.state.continuousFaceRecognition;
|
||||
}
|
||||
|
||||
void _beginReservationFaceHandoff() {
|
||||
final reservationState = reservationCoordinator.state;
|
||||
_reservationSessionAtFaceStart = reservationState.sessionId;
|
||||
_reservationRevisionAtFaceStart = reservationState.revision;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
_reservationFaceHandoffActive = true;
|
||||
}
|
||||
|
||||
void _handleReservationUpdateDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
if (!_hasNewReservationTerminalState()) return;
|
||||
_reservationResultReady = true;
|
||||
if (faceState?.status == FaceRecognitionDisplayStatus.failed) {
|
||||
// 可重试的人脸失败不会产生预约结果;只有业务已收口为 error 时才离开人脸页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_restoreReservationAfterFace();
|
||||
return;
|
||||
}
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
}
|
||||
|
||||
void _handleReservationClearDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
_resetReservationFaceHandoff(preserveClear: true);
|
||||
_reservationClearedDuringFace = true;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
// 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_replaceMode(SubDisplayMode.welcome);
|
||||
_resetReservationFaceHandoff();
|
||||
}
|
||||
|
||||
bool _hasNewReservationTerminalState() {
|
||||
if (!_reservationFaceHandoffActive) return false;
|
||||
final state = reservationCoordinator.state;
|
||||
final isNewer = state.sessionId != _reservationSessionAtFaceStart ||
|
||||
state.revision > _reservationRevisionAtFaceStart;
|
||||
if (!isNewer) return false;
|
||||
return state.scenario == ReservationQueryDisplayScenario.success ||
|
||||
state.scenario == ReservationQueryDisplayScenario.empty ||
|
||||
state.scenario == ReservationQueryDisplayScenario.error;
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFaceIfReady() {
|
||||
if (!_reservationSuccessDelayPassed || !_reservationResultReady) return;
|
||||
_restoreReservationAfterFace();
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFace() {
|
||||
_faceRestoreTimer?.cancel();
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
_resetReservationFaceHandoff();
|
||||
}
|
||||
|
||||
void _resetReservationFaceHandoff({bool preserveClear = false}) {
|
||||
_reservationSessionAtFaceStart = null;
|
||||
_reservationRevisionAtFaceStart = -1;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
_reservationFaceHandoffActive = false;
|
||||
if (!preserveClear) _reservationClearedDuringFace = false;
|
||||
}
|
||||
|
||||
/// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。
|
||||
/// 这样 success 的 3 秒提示和 failed 的重试引导不会被金额或查询消息提前顶掉。
|
||||
bool _showOrRememberMode(SubDisplayMode next) {
|
||||
if (_mode == SubDisplayMode.faceRecognition) {
|
||||
final changed = _modeBeforeFace != next;
|
||||
_modeBeforeFace = next;
|
||||
return changed;
|
||||
}
|
||||
return _replaceMode(next);
|
||||
}
|
||||
|
||||
/// 转发子协调器刷新,并在支付成功倒计时清空商品后同步恢复欢迎模式。
|
||||
void _notifyFromChild() {
|
||||
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() {
|
||||
if (_disposed) return;
|
||||
final previous = _lastReservationScenario;
|
||||
final current = reservationCoordinator.state.scenario;
|
||||
_lastReservationScenario = current;
|
||||
|
||||
if (previous != ReservationQueryDisplayScenario.idle &&
|
||||
current == ReservationQueryDisplayScenario.idle) {
|
||||
final modeBeforeTransition = _mode;
|
||||
// 预约结果可由副屏自己的隐私计时器清空;顶层必须同步回欢迎页,
|
||||
// 否则 Coordinator 已无身份数据但仍会停留在预约查询框架。
|
||||
_showOrRememberMode(SubDisplayMode.welcome);
|
||||
_handleReservationClearDuringFace();
|
||||
if (_mode == modeBeforeTransition) notifyListeners();
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
customerCoordinator.removeListener(_notifyFromChild);
|
||||
reservationCoordinator.removeListener(_notifyFromReservation);
|
||||
faceRecognitionCoordinator.removeListener(_notifyFromChild);
|
||||
customerCoordinator.dispose();
|
||||
reservationCoordinator.dispose();
|
||||
faceRecognitionCoordinator.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user