Files
biz_xfj_cloud/lib/pages/terminal/menu_query/controller.dart
T
2026-09-07 12:53:05 +08:00

1305 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'reservation_menu_repository.dart';
import 'reservation_recognition_gateway.dart';
import 'reservation_sub_screen_gateway.dart';
import 'reservation_verification_gateway.dart';
enum ReservationMenuQueryStatus {
idle,
recognizing,
querying,
success,
empty,
identifyError,
networkError,
deviceError,
expired,
}
extension ReservationMenuQueryStatusX on ReservationMenuQueryStatus {
bool get hasIdentity =>
this == ReservationMenuQueryStatus.success ||
this == ReservationMenuQueryStatus.empty;
}
/// 快速核销在查询成功后交给页面层展示的一次性弹框请求。
class ReservationQuickVerificationRequest {
/// 创建已通过当前查询结果校验的快速核销请求,页面仍需复用 Controller 完成最终提交校验。
const ReservationQuickVerificationRequest({
required this.result,
required this.day,
required this.meal,
});
/// 触发请求时的完整预约结果,用于弹框展示脱敏身份与食堂信息。
final ReservationMenuDisplayData result;
/// 今天的预约日期,快速核销禁止自动选择未来日期。
final ReservationMenuDayData day;
/// 操作员在识别前选中的真实预约餐段,必须包含可核销订单 ID。
final ReservationMenuMealData meal;
}
/// 本人预约查询状态机,识别、网络、副屏和隐私计时都从这里统一收口。
class ReservationMenuQueryController extends GetxController {
/// 创建查询状态机;生产环境使用现有硬件与服务实现,测试可注入网关、时钟和续轮间隔。
ReservationMenuQueryController({
ReservationMenuRepository? repository,
ReservationRecognitionGateway? recognitionGateway,
ReservationSubScreenGateway? subScreenGateway,
ReservationVerificationGateway? verificationGateway,
bool Function()? networkAvailable,
Future<ReservationQueryPagePreferences> Function()? preferencesLoader,
Future<void> Function(ReservationMealSlot)? mealSlotSaver,
ProductHourTypeControl? hourTypeControl,
Future<void> Function(String)? speaker,
void Function(String)? successNotifier,
void Function(String)? errorNotifier,
this.privacyDuration = const Duration(seconds: 60),
this.continuousFaceResultDuration = const Duration(seconds: 10),
this.timerTick = const Duration(seconds: 1),
this.recognitionRestartDelay = const Duration(milliseconds: 300),
this.recognitionFailureRestartDelay = const Duration(seconds: 2),
DateTime Function()? now,
}) : _repository = repository ?? HardReservationMenuRepository(),
_recognitionGateway =
recognitionGateway ?? TerminalReservationRecognitionGateway(),
_subScreenGateway =
subScreenGateway ?? TerminalReservationSubScreenGateway(),
_verificationGateway =
verificationGateway ?? TerminalReservationVerificationGateway(),
_networkAvailable = networkAvailable ?? AppNetworkTool.isAutoNetwork,
_preferencesLoader = preferencesLoader ??
AppSettingTool.getReservationQueryPagePreferences,
_mealSlotSaver =
mealSlotSaver ?? AppSettingTool.setReservationQuerySelectedMealSlot,
_speaker = speaker ?? TtsManage.speak,
_successNotifier =
successNotifier ?? ((message) => Toast.success(msg: message)),
_errorNotifier =
errorNotifier ?? ((message) => Toast.error(msg: message)),
_now = now ?? DateTime.now,
_hourTypeControl = hourTypeControl ??
(Get.isRegistered<ProductHourTypeControl>()
? Get.find<ProductHourTypeControl>()
: ProductHourTypeControl(persistSelection: false)),
_ownsHourTypeControl = hourTypeControl == null &&
!Get.isRegistered<ProductHourTypeControl>();
final ReservationMenuRepository _repository;
final ReservationRecognitionGateway _recognitionGateway;
final ReservationSubScreenGateway _subScreenGateway;
final ReservationVerificationGateway _verificationGateway;
final bool Function() _networkAvailable;
final Future<ReservationQueryPagePreferences> Function() _preferencesLoader;
/// 最近餐段的本机缓存写入器,测试可注入替身且生产环境复用 AppSettingTool 的串行写队列。
final Future<void> Function(ReservationMealSlot) _mealSlotSaver;
final Future<void> Function(String) _speaker;
final void Function(String) _successNotifier;
final void Function(String) _errorNotifier;
/// 查询菜单复用的餐段控制器;生产环境指向应用级实例,测试可注入独立实例。
final ProductHourTypeControl _hourTypeControl;
/// 是否由当前页面创建独立餐段控制器;仅测试回退场景需要自行释放。
final bool _ownsHourTypeControl;
/// 普通查询结果的隐私展示时长,当前生产默认六十秒。
final Duration privacyDuration;
/// 持续人脸模式单个用户菜单的展示时长,到期后自动进入下一轮识别。
final Duration continuousFaceResultDuration;
/// 隐私倒计时刷新间隔,测试可注入更短间隔验证状态转换。
final Duration timerTick;
/// 正常等待超时后开启下一轮前的缓冲时间,确保旧串口或原生会话已完成资源释放。
final Duration recognitionRestartDelay;
/// 单次刷卡或查询失败后的续轮缓冲时间,避免卡片未移开时高频重复识别和请求。
final Duration recognitionFailureRestartDelay;
final DateTime Function() _now;
final method = ReservationRecognitionMethod.card.obs;
final methodOrder = <ReservationRecognitionMethod>[
...ReservationRecognitionMethod.values,
].obs;
/// 是否自动启动当前查询方式的硬件识别;页面生命周期内使用进入页面时读取的配置快照。
final autoRecognitionEnabled = false.obs;
/// 自动识别是否持续接待下一位;饭卡结果展示期保持读卡,正常等待超时也会继续下一轮。
final continuousRecognitionEnabled = true.obs;
/// 当前页面查询请求是否要求服务端脱敏用户姓名。
final nameMaskEnabled = true.obs;
/// 当前操作员在识别前选择的餐段,页面会在多位用户间保留选择以支持连续核销。
final selectedMealSlot = ReservationMealSlot.morning.obs;
/// 是否允许在查询菜单主动修改餐段;关闭后严格按餐段配置时间自动切换。
final allowManualMealPeriodChange = true.obs;
/// 查询菜单与收银页面共用餐段组件的单选 Controller,值使用后端稳定餐段编码。
FRadioSelectGroupController<String> get mealSlotC =>
_hourTypeControl.selection;
/// 全局餐段选择监听句柄,页面关闭时用于解除订阅。
late final ValueChanged<Set<String>> _mealSlotListener;
/// 识别查询成功后是否直接请求页面打开今天所选餐段的核销弹框。
final quickVerificationEnabled = false.obs;
final verificationEnabled = false.obs;
final verificationDateScope = ReservationVerificationDateScope.today.obs;
final status = ReservationMenuQueryStatus.idle.obs;
final result = Rxn<ReservationMenuDisplayData>();
final currentPage = 1.obs;
final remainingSeconds = 0.obs;
final errorMessage = ''.obs;
Timer? _privacyTimer;
/// 正常等待超时或同卡重复读取后的待重启任务;切换方式、清屏或退出页面时必须取消。
Timer? _recognitionRestartTimer;
DateTime? _expiresAt;
int _requestGeneration = 0;
int _revision = 0;
String _sessionId = '';
bool _closed = false;
bool _recognitionLocked = false;
bool _verificationSubmitting = false;
/// 恢复旧版页面偏好时临时禁止重复写入最近餐段缓存。
bool _suppressMealSlotPersistence = false;
/// 菜单详情或核销弹框是否正在展示;展示期间禁止自动续轮和任何新的硬件识别。
bool _resultDialogPresented = false;
String? _recognizedIdentityValue;
/// 当前真实人脸查询产生的本地证据会话;核销成功后移交上传队列,其他清屏路径必须删除。
String? _recognizedFaceEvidenceSessionId;
/// 页面层订阅的一次性快速核销请求;使用广播流避免测试或页面尚未挂载时缓存过期弹框。
final StreamController<ReservationQuickVerificationRequest>
_quickVerificationRequestController =
StreamController<ReservationQuickVerificationRequest>.broadcast();
/// 页面偏好、副屏和识别会话的首次初始化任务。
Future<void>? _initializationFuture;
/// 页面偏好与识别会话初始化完成后再执行依赖当前餐段的提示。
Future<void> get initialization => _initializationFuture ?? Future.value();
/// 页面层使用的只读快速核销请求流,Controller 不直接依赖弹框或导航上下文。
Stream<ReservationQuickVerificationRequest> get quickVerificationRequests =>
_quickVerificationRequestController.stream;
/// 当前是否为“自动识别 + 持续自动识别 + 人脸”的常驻摄像头模式。
bool get continuousFaceRecognitionActive =>
autoRecognitionEnabled.value &&
continuousRecognitionEnabled.value &&
method.value == ReservationRecognitionMethod.face;
/// 当前是否为“自动识别 + 持续自动识别 + 饭卡”的结果期持续读卡模式。
bool get continuousCardRecognitionActive =>
autoRecognitionEnabled.value &&
continuousRecognitionEnabled.value &&
method.value == ReservationRecognitionMethod.card;
List<ReservationMenuDayData> get currentDays =>
result.value?.pageDays(currentPage.value) ?? const [];
int get totalPages => result.value?.totalPages ?? 1;
/// 注册公共餐段组件的编码监听,并异步恢复查询偏好与初始化识别会话。
@override
void onInit() {
super.onInit();
_mealSlotListener = _handleMealSlotSelection;
mealSlotC.addValueListener(_mealSlotListener);
_initializationFuture = _initialize();
}
/// 恢复全局餐段与页面偏好,再建立副屏和识别会话。
Future<void> _initialize() async {
// 页面单测可注入独立餐段控制器;生产环境使用应用级控制器并等待其缓存恢复。
if (!_ownsHourTypeControl) await _hourTypeControl.initAsync();
await Future.wait([
_loadPagePreferences(),
_subScreenGateway.show(),
]);
await _beginSession();
}
/// 读取当前页面的一次性配置快照;读取失败时保留安全默认值继续提供手动查询。
Future<void> _loadPagePreferences() async {
try {
final preferences = await _preferencesLoader();
if (_closed) return;
methodOrder.assignAll(preferences.methodOrder);
method.value = preferences.defaultMethod;
autoRecognitionEnabled.value = preferences.autoRecognitionEnabled;
continuousRecognitionEnabled.value =
preferences.continuousRecognitionEnabled;
nameMaskEnabled.value = preferences.nameMaskEnabled;
verificationEnabled.value = preferences.verificationEnabled;
quickVerificationEnabled.value = preferences.verificationEnabled &&
preferences.quickVerificationEnabled;
if (_ownsHourTypeControl &&
ReservationMealSlot.querySelectableValues
.contains(preferences.selectedMealSlot)) {
final restoredType = ProductHourType.values.fromCode(
preferences.selectedMealSlot.code,
);
if (restoredType != null) {
_suppressMealSlotPersistence = true;
try {
_hourTypeControl.setSelected(restoredType, persist: false);
} finally {
_suppressMealSlotPersistence = false;
}
}
}
selectedMealSlot.value = _mealSlotForProductHourType(
_hourTypeControl.selected.value,
);
allowManualMealPeriodChange.value =
preferences.allowManualMealPeriodChange;
verificationDateScope.value = preferences.verificationDateScope;
} catch (error, stack) {
// 配置损坏或读取失败时继续使用安全默认值,不能让查询入口不可用。
logger.e('|预约查询|加载页面配置失败error:|$error|stack:|$stack|');
}
}
/// 更新预选餐段;等待硬件识别时仍允许调整,身份已获取并查询服务端后锁定本轮目标。
void selectMealSlot(ReservationMealSlot next, {bool fromGlobal = false}) {
if (_closed ||
!ReservationMealSlot.querySelectableValues.contains(next) ||
(!fromGlobal && status.value == ReservationMenuQueryStatus.querying) ||
selectedMealSlot.value == next) {
return;
}
selectedMealSlot.value = next;
if (!mealSlotC.contains(next.code)) mealSlotC.value = {next.code};
if (!_suppressMealSlotPersistence) {
unawaited(_persistSelectedMealSlot(next));
}
}
/// 接收公共餐段组件的编码选择,并映射回查询菜单的强类型餐段状态。
void _handleMealSlotSelection(Set<String> selectedCodes) {
final selectedCode = selectedCodes.firstOrNull;
if (selectedCode == null) return;
for (final mealSlot in ReservationMealSlot.querySelectableValues) {
if (mealSlot.code == selectedCode) {
selectMealSlot(mealSlot, fromGlobal: true);
return;
}
}
}
/// 把全局商品餐段映射为查询菜单餐段;异常编码回退早餐以保持页面可查询。
ReservationMealSlot _mealSlotForProductHourType(ProductHourType hourType) {
return ReservationMealSlot.querySelectableValues.firstWhere(
(slot) => slot.code == hourType.code,
orElse: () => ReservationMealSlot.morning,
);
}
/// 异步缓存最近餐段;失败时保留内存中的即时选择,避免本机设置故障阻断识别查询。
Future<void> _persistSelectedMealSlot(ReservationMealSlot mealSlot) async {
try {
await _mealSlotSaver(mealSlot);
} catch (error, stack) {
logger.e('|预约查询|缓存最近餐段失败error:|$error|stack:|$stack|');
}
}
/// 切换本人识别方式,清理旧身份与硬件回调,并按配置决定是否立即启动新方式。
Future<void> selectMethod(ReservationRecognitionMethod next) async {
if (_closed || next == method.value) return;
_cancelRecognitionRestart();
final generation = ++_requestGeneration;
_stopPrivacyTimer();
await _discardCurrentFaceEvidence('切换预约查询识别方式');
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
method.value = next;
status.value = ReservationMenuQueryStatus.idle;
// generation 已先递增,即使旧硬件回调稍晚到达也不会进入查询流程。
// 自动模式直接交给新识别方式更新副屏,避免 idle 消息晚到并覆盖人脸实时画面。
if (!autoRecognitionEnabled.value) unawaited(_syncSubScreen('idle'));
await _recognitionGateway.stopAll();
if (_closed || generation != _requestGeneration) return;
if (autoRecognitionEnabled.value) await startRecognition();
}
/// 主动开始一轮识别;持续饭卡模式会在单次刷卡或查询失败后自动恢复下一轮。
Future<void> startRecognition() => _startRecognition();
/// 建立一次防重复回调的识别代次;持续饭卡结果期可保留当前菜单并只重新布防读卡器。
Future<void> _startRecognition({bool preserveCurrentResult = false}) async {
if (_closed || _resultDialogPresented) return;
final preserveCardResult = preserveCurrentResult &&
continuousCardRecognitionActive &&
result.value != null &&
status.value.hasIdentity;
if (preserveCurrentResult && !preserveCardResult) return;
_cancelRecognitionRestart();
final generation = ++_requestGeneration;
_recognitionLocked = false;
if (!preserveCardResult) {
_stopPrivacyTimer();
await _discardCurrentFaceEvidence('开始新的预约查询识别');
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = ReservationMenuQueryStatus.recognizing;
}
// 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、
// 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。
if (method.value == ReservationRecognitionMethod.card &&
!preserveCardResult) {
unawaited(_syncSubScreen('loading'));
} else if (continuousFaceRecognitionActive) {
// 先让副屏进入组合页面再启动人脸服务,避免人脸 start 消息抢先把副屏切到旧的独立页面。
await _syncSubScreen('recognizing');
if (_closed || generation != _requestGeneration) return;
}
Future<void> recognized(ReservationRecognizedIdentity identity) async {
if (_closed || generation != _requestGeneration || _recognitionLocked) {
return;
}
final identityValue = identity.identityValue.trim();
if (identityValue.isEmpty) {
_recognitionLocked = true;
if (preserveCardResult) {
// 持续读卡可能收到“读卡成功但卡号为空”的无效帧;它不代表下一位用户,不能覆盖当前菜单。
errorMessage.value = '';
_refreshRetainedCardResultDisplay();
await _restartRecognitionAfterDelay(
generation,
preserveCurrentResult: true,
);
} else if (continuousCardRecognitionActive) {
// 持续模式把空卡视为本轮偶发失败,提示后重新布防,不能切到需要人工恢复的错误页。
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: '未读取到有效饭卡,请重新刷卡',
);
} else {
errorMessage.value = '未读取到有效饭卡,请重新刷卡';
status.value = ReservationMenuQueryStatus.identifyError;
await _syncSubScreen('error');
}
return;
}
if (preserveCardResult && identityValue == _recognizedIdentityValue) {
// 卡片未移开时硬件可能在重新布防后再次返回同一卡号;不重复查询,短暂延迟后继续等待换卡。
_recognitionLocked = true;
await _restartRecognitionAfterDelay(
generation,
preserveCurrentResult: true,
);
return;
}
// 同一硬件事件可能被串口或原生通道重复投递,只允许首个结果进入查询。
_recognitionLocked = true;
_cancelRecognitionRestart();
_stopPrivacyTimer();
await _recognitionGateway.stopAll();
if (_closed || generation != _requestGeneration) return;
await _replaceFaceEvidenceSession(identity.faceEvidenceSessionId);
await _queryIdentity(
identityValue,
generation,
preserveCurrentResultUntilSuccess: preserveCardResult,
faceEvidenceSessionId: identity.faceEvidenceSessionId,
);
}
void failed(ReservationRecognitionFailure failure) {
if (_closed || generation != _requestGeneration || _recognitionLocked) {
return;
}
_recognitionLocked = true;
if (method.value == ReservationRecognitionMethod.card &&
failure.type == ReservationRecognitionFailureType.identify &&
continuousCardRecognitionActive) {
if (preserveCardResult) {
// 读卡器空数据属于无效硬件帧,不是新的用户身份;保留当前菜单并继续等待下一张有效卡。
errorMessage.value = '';
_refreshRetainedCardResultDisplay();
unawaited(_restartRecognitionAfterDelay(
generation,
preserveCurrentResult: true,
delay: recognitionFailureRestartDelay,
));
} else {
unawaited(_resumeContinuousCardRecognitionAfterFailure(
generation,
message: failure.message,
));
}
return;
}
if (failure.type == ReservationRecognitionFailureType.timeout &&
autoRecognitionEnabled.value &&
continuousRecognitionEnabled.value) {
// 正常无人等待超时不向用户闪现错误页;先完整释放本轮资源,再平滑进入下一轮。
errorMessage.value = '';
if (!preserveCardResult) {
status.value = ReservationMenuQueryStatus.recognizing;
}
unawaited(_restartRecognitionAfterDelay(
generation,
preserveCurrentResult: preserveCardResult,
));
return;
}
if (preserveCardResult) _stopPrivacyTimer();
if (failure.type == ReservationRecognitionFailureType.cancelled) {
if (autoRecognitionEnabled.value) {
// 用户主动取消后暂停自动识别并提供恢复入口,禁止立即拉起造成无法退出的弹窗循环。
status.value = ReservationMenuQueryStatus.identifyError;
errorMessage.value = failure.message;
unawaited(_syncSubScreen('error'));
} else {
status.value = ReservationMenuQueryStatus.idle;
errorMessage.value = '';
unawaited(_syncSubScreen('idle'));
}
return;
}
errorMessage.value = failure.message;
status.value = failure.type == ReservationRecognitionFailureType.device
? ReservationMenuQueryStatus.deviceError
: ReservationMenuQueryStatus.identifyError;
unawaited(_syncSubScreen('error'));
}
try {
if (method.value == ReservationRecognitionMethod.card) {
await _recognitionGateway.startCard(
onRecognized: recognized,
onFailure: failed,
);
} else {
await _recognitionGateway.startFace(
onRecognized: recognized,
onFailure: failed,
embeddedPreview: continuousFaceRecognitionActive,
);
}
} catch (error, stack) {
if (_closed || generation != _requestGeneration) return;
logger.e('|预约查询|启动识别失败error:|$error|stack:|$stack|');
if (preserveCardResult) _stopPrivacyTimer();
errorMessage.value = '${method.value.title}设备不可用,请检查设备后重试';
status.value = ReservationMenuQueryStatus.deviceError;
await _syncSubScreen('error');
}
}
/// 释放当前识别会话并延迟续轮;代次变化时旧任务立即失效。
///
/// [preserveCurrentResult] 仅用于持续饭卡结果期,保留当前菜单等待不同卡号。
/// [delay] 允许单次识别或查询失败使用更长间隔,防止卡片未移开时高频重试。
Future<void> _restartRecognitionAfterDelay(
int generation, {
bool preserveCurrentResult = false,
Duration? delay,
}) async {
await _recognitionGateway.stopAll();
if (_closed ||
_resultDialogPresented ||
generation != _requestGeneration ||
!autoRecognitionEnabled.value ||
!continuousRecognitionEnabled.value) {
return;
}
_recognitionRestartTimer = Timer(delay ?? recognitionRestartDelay, () {
_recognitionRestartTimer = null;
if (_closed ||
_resultDialogPresented ||
generation != _requestGeneration ||
!autoRecognitionEnabled.value ||
!continuousRecognitionEnabled.value) {
return;
}
unawaited(_startRecognition(
preserveCurrentResult: preserveCurrentResult,
));
});
}
/// 取消尚未开始的持续识别续轮,避免方式切换、隐私清屏或销毁后启动旧硬件任务。
void _cancelRecognitionRestart() {
_recognitionRestartTimer?.cancel();
_recognitionRestartTimer = null;
}
/// 查询识别到的身份,并在持续饭卡换卡场景中将新结果作为一次原子提交。
///
/// [preserveCurrentResultUntilSuccess] 为 true 时,网络或业务失败只提示操作员并恢复读卡,
/// 当前菜单和对应身份保持不变;服务端成功返回后才一起替换为新用户结果。
Future<void> _queryIdentity(
String identityValue,
int generation, {
bool preserveCurrentResultUntilSuccess = false,
String? faceEvidenceSessionId,
}) async {
final preserveCurrentResult = preserveCurrentResultUntilSuccess &&
result.value != null &&
status.value.hasIdentity;
if (!_networkAvailable()) {
await _discardFaceEvidenceSession(
faceEvidenceSessionId,
reason: '预约查询断网,未取得可核销订单',
);
if (preserveCurrentResult) {
await _retainCurrentCardResultAfterQueryFailure(
generation,
message: '预约查询需要联网,请检查网络后重试',
);
return;
}
if (continuousCardRecognitionActive) {
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: '预约查询需要联网,请检查网络后重试',
);
return;
}
errorMessage.value = '预约查询需要联网,请检查网络后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
return;
}
if (!preserveCurrentResult) {
status.value = ReservationMenuQueryStatus.querying;
errorMessage.value = '';
// 副屏仅用于展示,插件异常或不回包不能挡在预约接口之前。
unawaited(_syncSubScreen('loading'));
} else {
// 换卡请求期间继续展示上一位结果,并同步刷新副屏到期时间,成功后再原子替换。
_refreshRetainedCardResultDisplay();
}
var keepFaceEvidence = false;
try {
logger.i(
'|预约查询|开始请求预约菜单|method:${method.value.name}|'
'identity:${_maskedIdentity(identityValue)}|',
);
final response = await _repository.query(
method: method.value,
identityValue: identityValue,
maskName: nameMaskEnabled.value,
);
// 新的刷卡或人脸事件已经开始时,旧请求即使成功也不能覆盖新身份。
if (_closed || generation != _requestGeneration) return;
if (response == null) {
const message = '未获取到预约查询结果';
if (preserveCurrentResult) {
await _retainCurrentCardResultAfterQueryFailure(
generation,
message: message,
);
return;
}
if (continuousCardRecognitionActive) {
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: message,
);
return;
}
throw const ReservationMenuRepositoryException('未获取到预约查询结果');
}
result.value = ReservationMenuDisplayData.fromService(response);
_recognizedIdentityValue = identityValue;
keepFaceEvidence = true;
currentPage.value = 1;
errorMessage.value = '';
status.value = response.days.isEmpty
? ReservationMenuQueryStatus.empty
: ReservationMenuQueryStatus.success;
logger.i(
'|预约查询|预约菜单请求完成|method:${method.value.name}|'
'dayCount:${response.days.length}|',
);
_startPrivacyTimer();
await _syncSubScreen(
response.days.isEmpty ? 'empty' : 'success',
includeResult: true,
);
if (_closed || generation != _requestGeneration) return;
if (response.days.isEmpty) {
_errorNotifier('当前用户暂无有效预约');
}
final quickVerificationPending = _publishQuickVerificationRequest();
if (_closed || generation != _requestGeneration) return;
if (continuousCardRecognitionActive &&
!quickVerificationPending &&
!_resultDialogPresented) {
// 菜单继续展示并维持隐私倒计时,读卡器在后台等待下一张不同饭卡直接覆盖当前结果。
await _startRecognition(preserveCurrentResult: true);
}
} on ReservationMenuIdentityException catch (error) {
if (_closed || generation != _requestGeneration) return;
if (preserveCurrentResult) {
await _retainCurrentCardResultAfterQueryFailure(
generation,
message: error.message,
);
return;
}
if (continuousCardRecognitionActive) {
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: error.message,
);
return;
}
errorMessage.value = error.message;
status.value = ReservationMenuQueryStatus.identifyError;
await _syncSubScreen('error');
} on ReservationMenuRepositoryException catch (error) {
if (_closed || generation != _requestGeneration) return;
// 终端未绑定等本机配置错误必须保留人工恢复入口,不能被持续模式静默重试。
errorMessage.value = error.message;
status.value = ReservationMenuQueryStatus.deviceError;
await _syncSubScreen('error');
} on DioException catch (error, stack) {
if (_closed || generation != _requestGeneration) return;
logger.e('|预约查询|网络请求失败error:|$error|stack:|$stack|');
if (preserveCurrentResult) {
await _retainCurrentCardResultAfterQueryFailure(
generation,
message: '预约查询失败,请检查网络后重试',
);
return;
}
if (continuousCardRecognitionActive) {
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: '预约查询失败,请检查网络后重试',
);
return;
}
errorMessage.value = '预约查询失败,请检查网络后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
} catch (error, stack) {
if (_closed || generation != _requestGeneration) return;
logger.e('|预约查询|查询失败error:|$error|stack:|$stack|');
if (preserveCurrentResult) {
await _retainCurrentCardResultAfterQueryFailure(
generation,
message: '暂时无法查询预约,请稍后重试',
);
return;
}
if (continuousCardRecognitionActive) {
await _resumeContinuousCardRecognitionAfterFailure(
generation,
message: '暂时无法查询预约,请稍后重试',
);
return;
}
errorMessage.value = '暂时无法查询预约,请稍后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
} finally {
if (!keepFaceEvidence) {
await _discardFaceEvidenceSession(
faceEvidenceSessionId,
reason: '预约查询未取得有效结果',
);
}
}
}
/// 用新识别结果替换当前人脸证据会话;旧会话尚未核销时立即清理本地图片。
Future<void> _replaceFaceEvidenceSession(String? nextSessionId) async {
final previousSessionId = _recognizedFaceEvidenceSessionId;
if (previousSessionId != null && previousSessionId != nextSessionId) {
await _discardFaceEvidenceSession(
previousSessionId,
reason: '新的预约查询身份已替换旧人脸结果',
);
}
_recognizedFaceEvidenceSessionId = nextSessionId;
}
/// 清理当前页面持有但尚未绑定核销订单的人脸证据会话。
Future<void> _discardCurrentFaceEvidence(String reason) async {
await _discardFaceEvidenceSession(
_recognizedFaceEvidenceSessionId,
reason: reason,
);
}
/// 按会话精确回收未使用图片;清理失败只记录类型,过期维护仍会继续收敛文件。
Future<void> _discardFaceEvidenceSession(
String? sessionId, {
required String reason,
}) async {
if (sessionId == null || sessionId.isEmpty) return;
if (_recognizedFaceEvidenceSessionId == sessionId) {
_recognizedFaceEvidenceSessionId = null;
}
try {
await _verificationGateway.discardFaceEvidence(
recognitionSessionId: sessionId,
reason: reason,
);
} catch (error) {
logger.w(
'|预约查询|未使用人脸证据清理失败|errorType:${error.runtimeType}|',
);
}
}
/// 首次刷卡或查询失败后清空本轮临时状态,并在持续模式下重新等待下一张饭卡。
///
/// 这里不进入设备故障页,因为读卡器已完成本轮身份上报;失败只影响当前卡片,
/// 不能改变操作员在页面设置中开启的持续识别意图。
Future<void> _resumeContinuousCardRecognitionAfterFailure(
int generation, {
required String message,
}) async {
if (_closed ||
generation != _requestGeneration ||
!continuousCardRecognitionActive ||
_resultDialogPresented) {
return;
}
_stopPrivacyTimer();
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = ReservationMenuQueryStatus.recognizing;
_errorNotifier(message);
unawaited(_syncSubScreen('loading'));
await _restartRecognitionAfterDelay(
generation,
delay: recognitionFailureRestartDelay,
);
}
/// 持续饭卡换卡查询失败时恢复旧结果的隐私计时和读卡,不把失败身份提交为当前用户。
Future<void> _retainCurrentCardResultAfterQueryFailure(
int generation, {
required String message,
}) async {
if (_closed || generation != _requestGeneration) return;
errorMessage.value = '';
_refreshRetainedCardResultDisplay();
_errorNotifier(message);
if (continuousCardRecognitionActive && !_resultDialogPresented) {
await _restartRecognitionAfterDelay(
generation,
preserveCurrentResult: true,
delay: recognitionFailureRestartDelay,
);
}
}
/// 刷新持续饭卡保留结果的主副屏展示期限,空卡或换卡查询失败都不得让副屏提前清空。
void _refreshRetainedCardResultDisplay() {
if (result.value == null || !status.value.hasIdentity) return;
_startPrivacyTimer();
final scenario =
status.value == ReservationMenuQueryStatus.empty ? 'empty' : 'success';
unawaited(_syncSubScreen(scenario, includeResult: true));
}
/// 在快速核销开启且页面正在监听时发布今天预选餐段,并返回是否需要等待弹框接管识别。
bool _publishQuickVerificationRequest() {
if (_closed ||
!verificationEnabled.value ||
!quickVerificationEnabled.value ||
!_quickVerificationRequestController.hasListener) {
return false;
}
final currentResult = result.value;
if (currentResult == null || currentResult.days.isEmpty) return false;
ReservationMenuDayData? today;
for (final day in currentResult.days) {
if (day.isToday(_now())) {
today = day;
break;
}
}
ReservationMenuMealData? targetMeal;
if (today != null && canVerifyDay(today)) {
for (final meal in today.meals) {
if (selectedMealSlot.value.matches(meal) &&
meal.reserved &&
meal.orderIds.isNotEmpty) {
targetMeal = meal;
break;
}
}
}
if (today == null || targetMeal == null) {
_errorNotifier(
'当前用户今天没有可核销的${selectedMealSlot.value.title}预约',
);
return false;
}
_quickVerificationRequestController.add(
ReservationQuickVerificationRequest(
result: currentResult,
day: today,
meal: targetMeal,
),
);
return true;
}
String _maskedIdentity(String value) {
if (value.length <= 2) return '**';
return '${value.substring(0, 1)}***${value.substring(value.length - 1)}';
}
Future<void> changePage(int page) async {
if (result.value == null || page < 1 || page > totalPages) return;
if (page == currentPage.value) return;
currentPage.value = page;
// 持续人脸按“识别成功后的固定十秒”接待下一位,操作员翻页不能延长身份展示。
if (!continuousFaceRecognitionActive) _startPrivacyTimer();
await _syncSubScreen('success', includeResult: true);
}
bool canVerifyDay(ReservationMenuDayData day) {
if (!verificationEnabled.value) return false;
return verificationDateScope.value ==
ReservationVerificationDateScope.all ||
day.isToday(_now());
}
/// 在菜单详情或核销弹框展示前暂停识别,并返回当前调用方是否取得唯一展示权。
///
/// 标记必须先于硬件释放,避免快速核销事件和人工点击在 `stopAll` 等待期间叠加弹框;
/// 请求代次同步失效可保证已经排队的饭卡、人脸回调不能在弹框背后继续查询。
Future<bool> beginResultDialogPresentation() async {
if (_closed || _resultDialogPresented) return false;
_resultDialogPresented = true;
_cancelRecognitionRestart();
_requestGeneration++;
_recognitionLocked = true;
_stopPrivacyTimer();
try {
await _recognitionGateway.stopAll();
} catch (error, stack) {
// 弹框暂停边界已经生效,硬件释放异常只记录诊断,不能阻止用户核对或核销当前结果。
logger.w('|预约查询|弹框展示前停止识别失败error:|$error|stack:|$stack|');
}
return true;
}
/// 菜单详情或核销弹框关闭后恢复页面节奏;自动模式立即等待下一位,手动模式保留结果并重新计时。
Future<void> endResultDialogPresentation() async {
if (!_resultDialogPresented) return;
if (_closed) {
_resultDialogPresented = false;
return;
}
if (autoRecognitionEnabled.value) {
final keepContinuousFacePreview = continuousFaceRecognitionActive;
_cancelRecognitionRestart();
final generation = ++_requestGeneration;
_stopPrivacyTimer();
await _discardCurrentFaceEvidence('预约结果弹框关闭后开始下一轮识别');
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = keepContinuousFacePreview
? ReservationMenuQueryStatus.recognizing
: ReservationMenuQueryStatus.idle;
if (keepContinuousFacePreview) {
unawaited(_syncSubScreen('recognizing'));
} else {
unawaited(_clearSubScreen());
}
var shouldResumeRecognition = false;
try {
await _recognitionGateway.stopAll();
shouldResumeRecognition = !_closed &&
generation == _requestGeneration &&
autoRecognitionEnabled.value;
} catch (error, stack) {
// 关闭弹框后的硬件清理失败不能重新展示旧结果,下一轮启动仍由网关给出可恢复的设备错误。
logger.w('|预约查询|弹框关闭后停止识别失败error:|$error|stack:|$stack|');
shouldResumeRecognition = !_closed &&
generation == _requestGeneration &&
autoRecognitionEnabled.value;
} finally {
// 清屏完成前继续占有弹框边界,避免路由刚关闭时再次点击旧结果叠加第二个弹框。
_resultDialogPresented = false;
}
if (shouldResumeRecognition) await startRecognition();
return;
}
_resultDialogPresented = false;
if (result.value != null && status.value.hasIdentity) {
_startPrivacyTimer();
final scenario = status.value == ReservationMenuQueryStatus.empty
? 'empty'
: 'success';
unawaited(_syncSubScreen(scenario, includeResult: true));
}
}
/// 核销用户在当前餐段弹框中勾选的订单,至少一笔成功后重新查询并替换页面结果。
Future<bool> confirmVerification(
ReservationMenuDayData day,
ReservationMenuMealData meal,
List<int> selectedOrderIds,
) async {
if (_closed || _verificationSubmitting) return false;
final currentMeal = _findCurrentMeal(day, meal);
if (currentMeal == null || !currentMeal.reserved || !canVerifyDay(day)) {
_errorNotifier('当前餐段预约已失效,请重新查询');
return false;
}
if (currentMeal.orderIds.isEmpty) {
_errorNotifier('未获取到可核销的预约订单,请重新查询');
return false;
}
final selectedOrderIdSet = selectedOrderIds.toSet();
if (selectedOrderIdSet.isEmpty) {
_errorNotifier('请至少选择一笔预约订单');
return false;
}
if (selectedOrderIdSet.length != selectedOrderIds.length ||
!selectedOrderIdSet.every(currentMeal.orderIds.contains)) {
_errorNotifier('部分所选预约订单已失效,请重新查询');
return false;
}
// 始终按当前查询结果中的服务端顺序提交,弹框回传列表只能决定选择范围,不能改变核销顺序。
final orderedSelectedOrderIds = currentMeal.orderIds
.where(selectedOrderIdSet.contains)
.toList(growable: false);
if (!_networkAvailable()) {
_errorNotifier('核销需要联网,请检查网络后重试');
return false;
}
final identityValue = _recognizedIdentityValue;
if (identityValue == null || identityValue.isEmpty) {
_errorNotifier('识别信息已失效,请重新识别后核销');
return false;
}
final recognitionMethod = method.value;
final faceEvidenceSessionId = _recognizedFaceEvidenceSessionId;
_verificationSubmitting = true;
_stopPrivacyTimer();
try {
// 所有订单 ID 均已重新通过当前餐段校验,禁止把日期、餐段或身份信息交给服务端参与订单选择。
final verificationResult = await _verificationGateway.cancel(
orderIds: orderedSelectedOrderIds,
faceEvidenceSessionId: faceEvidenceSessionId,
);
if (!verificationResult.anySucceeded) {
throw StateError('所选预约订单核销接口均未返回成功');
}
if (faceEvidenceSessionId != null &&
_recognizedFaceEvidenceSessionId == faceEvidenceSessionId) {
// 核销网关已把会话移交给持久化补传队列,后续页面清屏不得再取消这些任务。
_recognizedFaceEvidenceSessionId = null;
}
unawaited(
_announceVerificationSuccess(verificationResult),
);
final successMessage = _verificationSuccessMessage(verificationResult);
try {
await _refreshAfterVerification(
identityValue,
method: recognitionMethod,
);
_successNotifier('$successMessage,页面已刷新');
_startPrivacyTimer();
final scenario = status.value == ReservationMenuQueryStatus.empty
? 'empty'
: 'success';
unawaited(_syncSubScreen(scenario, includeResult: true));
} catch (error, stack) {
// 核销已由服务端确认成功,后续刷新失败不能反向提示“核销失败”。
// 清掉旧结果可避免用户对同一笔订单再次点击,重新识别即可获取最新数据。
logger.w('|预约查询|核销成功但刷新失败error:|$error|stack:|$stack|');
_successNotifier('$successMessage,请重新识别刷新结果');
_clearStaleResultAfterVerification();
}
return true;
} on DioException catch (error, stack) {
logger.e('|预约查询|核销网络请求失败error:|$error|stack:|$stack|');
_errorNotifier('核销失败,请检查网络后重试');
_startPrivacyTimer();
return false;
} catch (error, stack) {
logger.e('|预约查询|核销失败error:|$error|stack:|$stack|');
_errorNotifier('核销失败,请重新查询后重试');
_startPrivacyTimer();
return false;
} finally {
_verificationSubmitting = false;
}
}
/// 根据逐笔结果生成面向操作员的成功摘要,单笔核销继续保留既有简短文案。
String _verificationSuccessMessage(ReservationVerificationResult result) {
final successCount = result.succeededOrderIds.length;
final failedCount = result.failedOrderIds.length;
if (successCount == 1 && failedCount == 0) return '核销成功';
if (failedCount == 0) return '已核销 $successCount 笔预约';
return '已核销 $successCount 笔预约,$failedCount 笔未成功';
}
/// 不信任弹框回传的餐段对象,重新从当前查询结果中定位餐段及其订单 ID。
/// 这样即使页面数据已经刷新或调用方传入了其他餐段的 ID,也不会发起错误核销。
ReservationMenuMealData? _findCurrentMeal(
ReservationMenuDayData selectedDay,
ReservationMenuMealData selectedMeal,
) {
final currentResult = result.value;
if (currentResult == null) return null;
ReservationMealSlot? selectedSlot;
for (final slot in ReservationMealSlot.values) {
if (slot.matches(selectedMeal)) {
selectedSlot = slot;
break;
}
}
if (selectedSlot == null) return null;
for (final day in currentResult.days) {
if (day.date != selectedDay.date) continue;
for (final meal in day.meals) {
if (selectedSlot.matches(meal)) return meal;
}
return null;
}
return null;
}
/// 播报本次逐笔核销结果;单笔全部成功继续使用操作员熟悉的既有文案。
Future<void> _announceVerificationSuccess(
ReservationVerificationResult result,
) async {
try {
final successCount = result.succeededOrderIds.length;
final failedCount = result.failedOrderIds.length;
final message = failedCount > 0
? '$successCount笔预约核销成功,$failedCount笔未成功'
: successCount == 1
? '核销成功'
: '$successCount笔预约核销成功';
await _speaker(message);
} catch (error, stack) {
// 语音属于成功后的辅助反馈,TTS 异常不能改变核销结果。
logger.w('|预约查询|核销成功语音播报失败error:|$error|stack:|$stack|');
}
}
Future<void> _refreshAfterVerification(
String identityValue, {
required ReservationRecognitionMethod method,
}) async {
final response = await _repository.query(
method: method,
identityValue: identityValue,
maskName: nameMaskEnabled.value,
);
if (response == null) throw StateError('核销后未获取到最新预约结果');
result.value = ReservationMenuDisplayData.fromService(response);
status.value = response.days.isEmpty
? ReservationMenuQueryStatus.empty
: ReservationMenuQueryStatus.success;
if (currentPage.value > totalPages) currentPage.value = totalPages;
}
void _clearStaleResultAfterVerification() {
_cancelRecognitionRestart();
final generation = ++_requestGeneration;
_stopPrivacyTimer();
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = ReservationMenuQueryStatus.idle;
unawaited(_resumeAfterStaleResultClear(generation));
}
/// 核销后刷新失败时先清理主副屏,再按自动模式重新等待身份,避免页面停在无监听的空闲态。
Future<void> _resumeAfterStaleResultClear(int generation) async {
await _recognitionGateway.stopAll();
if (_closed || generation != _requestGeneration) return;
if (continuousFaceRecognitionActive) {
status.value = ReservationMenuQueryStatus.recognizing;
unawaited(_syncSubScreen('recognizing'));
} else {
unawaited(_clearSubScreen());
}
if (!_closed && !_resultDialogPresented && autoRecognitionEnabled.value) {
await startRecognition();
}
}
/// 建立本次页面会话,并按进入页面时加载的开关选择自动识别或手动等待态。
Future<void> _beginSession() async {
_sessionId = '${_now().microsecondsSinceEpoch}';
_revision = 0;
if (autoRecognitionEnabled.value) {
// 配置加载完成后直接复用手动入口,确保饭卡、人脸、防重入和异常处理只有一套状态机。
await startRecognition();
} else {
unawaited(_syncSubScreen('idle'));
}
}
/// 从当前时刻重新计算结果隐私展示期限;弹框占用期间不创建后台清屏或自动续轮任务。
void _startPrivacyTimer() {
if (_closed || _resultDialogPresented) return;
_stopPrivacyTimer();
final displayDuration = continuousFaceRecognitionActive
? continuousFaceResultDuration
: privacyDuration;
_expiresAt = _now().add(displayDuration);
remainingSeconds.value = displayDuration.inSeconds;
_privacyTimer = Timer.periodic(timerTick, (_) {
if (_closed || _expiresAt == null) return;
final milliseconds = _expiresAt!.difference(_now()).inMilliseconds;
if (milliseconds <= 0) {
unawaited(clearForPrivacy(expired: true));
return;
}
remainingSeconds.value = (milliseconds / 1000).ceil();
});
}
void _stopPrivacyTimer() {
_privacyTimer?.cancel();
_privacyTimer = null;
_expiresAt = null;
remainingSeconds.value = 0;
}
/// 清除主屏身份与预约结果;自动模式会在清理完成后重新等待下一位用户。
Future<void> clearForPrivacy({bool expired = false}) async {
if (_closed || _resultDialogPresented) return;
final keepContinuousFacePreview = continuousFaceRecognitionActive;
_cancelRecognitionRestart();
final generation = ++_requestGeneration;
_stopPrivacyTimer();
await _recognitionGateway.stopAll();
if (_closed || generation != _requestGeneration) return;
await _discardCurrentFaceEvidence('预约查询隐私清屏');
result.value = null;
_recognizedIdentityValue = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = keepContinuousFacePreview
? ReservationMenuQueryStatus.recognizing
: expired
? ReservationMenuQueryStatus.expired
: ReservationMenuQueryStatus.idle;
if (keepContinuousFacePreview) {
// 只清身份和预约内容,副屏仍保留同一摄像头 Texture,避免每十秒闪回欢迎页。
await _syncSubScreen('recognizing');
} else {
unawaited(_clearSubScreen());
}
if (_closed || generation != _requestGeneration) return;
// 清屏先让结果期读卡回调代次失效,再统一以空白等待态启动下一轮。
if (autoRecognitionEnabled.value) await startRecognition();
}
Future<void> _syncSubScreen(
String scenario, {
bool includeResult = false,
}) async {
if (_closed || !_subScreenGateway.isAvailable) return;
final display = result.value;
final event = SubScreenReservationQueryEvent(
sessionId: _sessionId,
revision: ++_revision,
scenario: scenario,
maskedName: includeResult ? display?.maskedName : null,
deptName: includeResult ? display?.deptName : null,
merchantName: includeResult ? display?.merchantName : null,
totalDayCount: includeResult ? display?.totalDayCount ?? 0 : 0,
totalMealCount: includeResult ? display?.totalMealCount ?? 0 : 0,
currentPage: currentPage.value,
totalPages: includeResult ? totalPages : 1,
days: includeResult ? currentDays : const [],
expiresAt: _expiresAt?.toIso8601String(),
continuousFaceRecognition: continuousFaceRecognitionActive,
);
try {
await _subScreenGateway.update(event);
} catch (error) {
// 单屏或副屏断开不能影响主屏查询。
logger.w('|预约查询|副屏同步失败:$error');
}
}
Future<void> _clearSubScreen() async {
if (!_subScreenGateway.isAvailable) return;
try {
await _subScreenGateway.clear(
sessionId: _sessionId,
revision: ++_revision,
);
} catch (error) {
logger.w('|预约查询|副屏清屏失败:$error');
}
}
/// 退出页面时终止识别与副屏任务;生产环境仅解除全局餐段监听。
@override
void onClose() {
_closed = true;
_resultDialogPresented = false;
_requestGeneration++;
_cancelRecognitionRestart();
_stopPrivacyTimer();
unawaited(_recognitionGateway.stopAll());
unawaited(_discardCurrentFaceEvidence('退出预约查询页面'));
unawaited(_clearSubScreen());
unawaited(_quickVerificationRequestController.close());
mealSlotC.removeValueListener(_mealSlotListener);
if (_ownsHourTypeControl) _hourTypeControl.onClose();
super.onClose();
}
}