1305 lines
52 KiB
Dart
1305 lines
52 KiB
Dart
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();
|
||
}
|
||
}
|