This commit is contained in:
dzc committed 2026-09-10 14:50:55 +08:00
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();
}
}
+4
View File
@@ -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();
}
}