Files
biz_xfj_cloud/lib/pages/terminal/menu_query/controller.dart
T
2026-07-24 20:41:17 +08:00

328 lines
11 KiB
Dart

import 'dart:async';
import 'package:dio/dio.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';
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 ReservationMenuQueryController extends GetxController {
ReservationMenuQueryController({
ReservationMenuRepository? repository,
ReservationRecognitionGateway? recognitionGateway,
ReservationSubScreenGateway? subScreenGateway,
bool Function()? networkAvailable,
this.privacyDuration = const Duration(seconds: 60),
this.timerTick = const Duration(seconds: 1),
DateTime Function()? now,
}) : _repository = repository ?? HardReservationMenuRepository(),
_recognitionGateway =
recognitionGateway ?? TerminalReservationRecognitionGateway(),
_subScreenGateway =
subScreenGateway ?? TerminalReservationSubScreenGateway(),
_networkAvailable = networkAvailable ?? AppNetworkTool.isAutoNetwork,
_now = now ?? DateTime.now;
final ReservationMenuRepository _repository;
final ReservationRecognitionGateway _recognitionGateway;
final ReservationSubScreenGateway _subScreenGateway;
final bool Function() _networkAvailable;
final Duration privacyDuration;
final Duration timerTick;
final DateTime Function() _now;
final method = ReservationRecognitionMethod.card.obs;
final status = ReservationMenuQueryStatus.idle.obs;
final result = Rxn<ReservationMenuDisplayData>();
final currentPage = 1.obs;
final remainingSeconds = 0.obs;
final errorMessage = ''.obs;
Timer? _privacyTimer;
DateTime? _expiresAt;
int _requestGeneration = 0;
int _revision = 0;
String _sessionId = '';
bool _closed = false;
bool _recognitionLocked = false;
List<ReservationMenuDayData> get currentDays =>
result.value?.pageDays(currentPage.value) ?? const [];
int get totalPages => result.value?.totalPages ?? 1;
@override
void onInit() {
super.onInit();
unawaited(_initialize());
}
Future<void> _initialize() async {
await _subScreenGateway.show();
await _beginSession();
if (_closed) return;
await startRecognition();
}
Future<void> selectMethod(ReservationRecognitionMethod next) async {
if (next == method.value &&
status.value == ReservationMenuQueryStatus.recognizing) {
return;
}
_requestGeneration++;
_stopPrivacyTimer();
result.value = null;
currentPage.value = 1;
errorMessage.value = '';
method.value = next;
if (next == ReservationRecognitionMethod.card) {
await _recognitionGateway.stopFace();
} else {
await _recognitionGateway.stopCard();
}
await startRecognition();
}
Future<void> startRecognition() async {
if (_closed) return;
final generation = ++_requestGeneration;
_recognitionLocked = false;
_stopPrivacyTimer();
result.value = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = ReservationMenuQueryStatus.recognizing;
// 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、
// 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。
if (method.value == ReservationRecognitionMethod.card) {
await _syncSubScreen('loading');
}
Future<void> recognized(String value) async {
if (_closed || generation != _requestGeneration || _recognitionLocked) {
return;
}
// 同一硬件事件可能被串口或原生通道重复投递,只允许首个结果进入查询。
_recognitionLocked = true;
await _recognitionGateway.stopAll();
await _queryIdentity(value, generation);
}
void failed(ReservationRecognitionFailure failure) {
if (_closed || generation != _requestGeneration || _recognitionLocked) {
return;
}
_recognitionLocked = true;
if (failure.type == ReservationRecognitionFailureType.cancelled) {
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,
);
}
} catch (error, stack) {
if (_closed || generation != _requestGeneration) return;
logger.e('|预约查询|启动识别失败error:|$error|stack:|$stack|');
errorMessage.value = '${method.value.title}设备不可用,请检查设备后重试';
status.value = ReservationMenuQueryStatus.deviceError;
await _syncSubScreen('error');
}
}
Future<void> _queryIdentity(String identityValue, int generation) async {
if (!_networkAvailable()) {
errorMessage.value = '预约查询需要联网,请检查网络后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
return;
}
status.value = ReservationMenuQueryStatus.querying;
errorMessage.value = '';
await _syncSubScreen('loading');
try {
final response = await _repository.query(
method: method.value,
identityValue: identityValue,
);
// 新的刷卡或人脸事件已经开始时,旧请求即使成功也不能覆盖新身份。
if (_closed || generation != _requestGeneration) return;
if (response == null) {
throw const ReservationMenuRepositoryException('未获取到预约查询结果');
}
result.value = ReservationMenuDisplayData.fromService(response);
currentPage.value = 1;
status.value = response.days.isEmpty
? ReservationMenuQueryStatus.empty
: ReservationMenuQueryStatus.success;
_startPrivacyTimer();
await _syncSubScreen(
response.days.isEmpty ? 'empty' : 'success',
includeResult: true,
);
} on ReservationMenuIdentityException catch (error) {
if (_closed || generation != _requestGeneration) 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|');
errorMessage.value = '预约查询失败,请检查网络后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
} catch (error, stack) {
if (_closed || generation != _requestGeneration) return;
logger.e('|预约查询|查询失败error:|$error|stack:|$stack|');
errorMessage.value = '暂时无法查询预约,请稍后重试';
status.value = ReservationMenuQueryStatus.networkError;
await _syncSubScreen('error');
}
}
Future<void> changePage(int page) async {
if (result.value == null || page < 1 || page > totalPages) return;
if (page == currentPage.value) return;
currentPage.value = page;
_startPrivacyTimer();
await _syncSubScreen('success', includeResult: true);
}
Future<void> _beginSession() async {
_sessionId = '${_now().microsecondsSinceEpoch}';
_revision = 0;
await _syncSubScreen('idle');
}
void _startPrivacyTimer() {
_stopPrivacyTimer();
_expiresAt = _now().add(privacyDuration);
remainingSeconds.value = privacyDuration.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 {
_requestGeneration++;
_stopPrivacyTimer();
await _recognitionGateway.stopAll();
result.value = null;
currentPage.value = 1;
errorMessage.value = '';
status.value = expired
? ReservationMenuQueryStatus.expired
: ReservationMenuQueryStatus.idle;
await _clearSubScreen();
}
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.map((day) => day.toMap()).toList()
: const [],
expiresAt: _expiresAt?.toIso8601String(),
);
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;
_requestGeneration++;
_stopPrivacyTimer();
unawaited(_recognitionGateway.stopAll());
unawaited(_clearSubScreen());
super.onClose();
}
}