Files
biz_xfj_cloud/lib/pages/terminal/menu_query/view.dart
T
2026-09-20 15:37:40 +08:00

404 lines
14 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:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'controller.dart';
import 'reservation_order_dialog_handle.dart';
import 'widgets/identity_summary.dart';
import 'widgets/query_method_panel.dart';
import 'widgets/reservation_result_panel.dart';
import 'widgets/reservation_verification_dialog.dart';
/// 在 Controller 的识别暂停边界内展示预约详情或核销弹框,保证所有入口使用相同恢复策略。
Future<bool> _showResultDialogWithRecognitionPaused(
BuildContext context, {
required ReservationMenuQueryController controller,
required ReservationMenuDisplayData result,
required ReservationMenuDayData day,
required ReservationMenuMealData meal,
required bool verificationEnabled,
Future<bool> Function(List<int> orderIds)? onConfirm,
}) async {
final acquired = await controller.beginResultDialogPresentation();
if (!acquired) return false;
// 弹框展示期内把取消/确认能力交给快捷键动作层,销毁时自动注销。
final handle = ReservationOrderDialogHandle();
controller.attachResultDialogHandle(handle);
try {
if (!context.mounted) return false;
return await reservationOrderDialog(
context,
result: result,
day: day,
meal: meal,
verificationEnabled: verificationEnabled,
onConfirm: onConfirm,
handle: handle,
);
} finally {
controller.detachResultDialogHandle();
await controller.endResultDialogPresentation();
}
}
/// 查询菜单主屏页面,组合本人识别、餐段预选、预约结果和核销弹框流程。
class ReservationMenuQueryPage extends GetView<ReservationMenuQueryController> {
/// 创建由路由 Binding 提供 Controller 的查询菜单页面。
const ReservationMenuQueryPage({super.key});
/// 分别监听侧栏和结果区实际使用的状态,自动识别开关只触发侧栏局部重建。
@override
Widget build(BuildContext context) {
// 左右工作区各自监听实际使用的 Rx,避免核销倒计时或页码变化重建整张页面。
final workspace = <Widget>[
Obx(() {
final continuousFace = controller.continuousFaceRecognitionActive;
return _querySidebar().width(continuousFace ? 430.w : 300.w);
}),
Obx(
() => ReservationResultPanel(
status: controller.status.value,
method: controller.method.value,
result: controller.result.value,
currentDays: controller.currentDays,
currentPage: controller.currentPage.value,
totalPages: controller.totalPages,
remainingSeconds: controller.remainingSeconds.value,
errorMessage: controller.errorMessage.value,
verificationEnabled: controller.verificationEnabled.value,
verificationDateScope: controller.verificationDateScope.value,
onRetry: controller.startRecognition,
onPageChanged: controller.changePage,
onVerify: (day, meal) => _openVerification(
context,
day: day,
meal: meal,
),
),
).expanded(),
]
.toRowSpace(
space: 12.w,
crossAxisAlignment: CrossAxisAlignment.stretch,
)
.padding(all: 6.w);
return <Widget>[
_QuickVerificationEffect(controller: controller),
_ShortcutFocusGuard(controller: controller),
workspace.expanded(),
].toColumn();
}
/// 构建仅监听识别方式、餐段和身份摘要状态的左侧操作区。
Widget _querySidebar() {
return TerminalSurface(
padding: EdgeInsets.all(12.w),
child: <Widget>[
_QueryTitle(status: controller.status.value),
SizedBox(height: 10.h),
QueryMethodPanel(
methods: controller.methodOrder.toList(),
selected: controller.method.value,
status: controller.status.value,
mealSlotController: controller.mealSlotC,
autoRecognitionEnabled: controller.autoRecognitionEnabled.value,
continuousRecognitionEnabled:
controller.continuousRecognitionEnabled.value,
quickVerificationEnabled: controller.quickVerificationEnabled.value,
allowManualMealPeriodChange:
controller.allowManualMealPeriodChange.value,
mealPeriodReady: controller.initialization,
onSelect: controller.selectMethod,
onStart: controller.startRecognition,
),
if (controller.continuousFaceRecognitionActive) ...[
SizedBox(height: 12.h),
_continuousFacePreview(),
],
SizedBox(height: 12.h),
const Divider(height: 1, color: XfjDesignTokens.line),
SizedBox(height: 12.h),
IdentitySummary(
status: controller.status.value,
method: controller.method.value,
result: controller.result.value,
errorMessage: controller.errorMessage.value,
onRetry: controller.startRecognition,
).expanded(),
_privacyNotice(),
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
);
}
/// 构建持续人脸模式的页面级常驻摄像头;组件只持有 Texture,不接管识别会话。
Widget _continuousFacePreview() {
final presentation = _continuousFacePresentation();
final remaining = controller.remainingSeconds.value;
return <Widget>[
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: FaceLiveCameraPreview(
key: const ValueKey('reservation-live-face-preview'),
statusText: presentation.text,
statusColor: presentation.color,
scale: 1.w,
),
),
),
SizedBox(height: 8.h),
<Widget>[
Icon(
presentation.icon,
color: presentation.color,
size: 20.w,
),
Text(
remaining > 0
? '${presentation.detail},$remaining 秒后识别下一位'
: presentation.detail,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 13.sp,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
).expanded(),
]
.toRowSpace(space: 8.w)
.padding(horizontal: 12.w, vertical: 10.h)
.decorated(
color: presentation.color.withValues(alpha: 0.08),
border: Border.all(
color: presentation.color.withValues(alpha: 0.22),
),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
].toColumn();
}
/// 把查询状态转换为摄像头区域的稳定文字、颜色和图标,结果期不误导为正在识别下一位。
({String text, String detail, Color color, IconData icon})
_continuousFacePresentation() {
return switch (controller.status.value) {
ReservationMenuQueryStatus.querying => (
text: '正在查询菜单',
detail: '身份识别成功,正在读取本人预约',
color: XfjDesignTokens.primary,
icon: Icons.search_rounded,
),
ReservationMenuQueryStatus.success ||
ReservationMenuQueryStatus.empty =>
(
text: '当前结果展示中',
detail: '摄像头保持开启,暂不识别其他人员',
color: XfjDesignTokens.success,
icon: Icons.check_circle_outline_rounded,
),
ReservationMenuQueryStatus.identifyError ||
ReservationMenuQueryStatus.networkError ||
ReservationMenuQueryStatus.deviceError =>
(
text: '持续识别已暂停',
detail: '请按页面提示处理后重新识别',
color: XfjDesignTokens.warning,
icon: Icons.error_outline_rounded,
),
_ => (
text: '持续识别中',
detail: '请下一位用户正对摄像头',
color: XfjDesignTokens.primary,
icon: Icons.face_retouching_natural_rounded,
),
};
}
/// 构建本人识别隐私提示,固定在侧栏底部且不承载业务副作用。
Widget _privacyNotice() {
return <Widget>[
Icon(
Icons.info_outline_rounded,
color: XfjDesignTokens.primary,
size: 19.w,
),
Text(
'饭卡或人脸只用于识别当前用户本人,系统不会提供其他用户的选择入口。',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 13.sp,
height: 1.55,
),
).expanded(),
]
.toRowSpace(space: 8.w, crossAxisAlignment: CrossAxisAlignment.start)
.padding(all: 12.w)
.decorated(
color: XfjDesignTokens.canvas,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
);
}
/// 打开操作员主动点击的详情或核销弹框,提交前仍由 Controller 重新校验当前结果。
Future<void> _openVerification(
BuildContext context, {
required ReservationMenuDayData day,
required ReservationMenuMealData meal,
}) async {
final result = controller.result.value;
if (result == null || !meal.reserved || meal.orderIds.isEmpty) {
return;
}
final verificationEnabled = controller.canVerifyDay(day);
await _showResultDialogWithRecognitionPaused(
context,
controller: controller,
result: result,
day: day,
meal: meal,
verificationEnabled: verificationEnabled,
onConfirm: verificationEnabled
? (selectedOrderIds) =>
controller.confirmVerification(day, meal, selectedOrderIds)
: null,
);
}
}
/// 页面挂载时清理其他页面残留的输入焦点,保证查询菜单不显示软键盘且快捷键不被让路。
class _ShortcutFocusGuard extends StatefulWidget {
/// 创建与查询菜单页面同生命周期的输入焦点守卫。
const _ShortcutFocusGuard({required this.controller});
/// 当前路由 Binding 注册的查询菜单 Controller。
final ReservationMenuQueryController controller;
@override
State<_ShortcutFocusGuard> createState() => _ShortcutFocusGuardState();
}
/// 焦点守卫的状态;终端工作区替换会重建页面,每次挂载都触发一次清理。
class _ShortcutFocusGuardState extends State<_ShortcutFocusGuard> {
@override
void initState() {
super.initState();
// 延迟到帧后执行,避免在构建期间修改焦点树。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.controller.releaseResidualTextInput();
});
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
/// 订阅 Controller 的一次性快速核销事件,并在页面上下文中展示既有核销弹框。
class _QuickVerificationEffect extends StatefulWidget {
/// 创建与当前查询页面 Controller 同生命周期的快速核销事件桥接组件。
const _QuickVerificationEffect({required this.controller});
/// 当前路由 Binding 注册的查询菜单 Controller。
final ReservationMenuQueryController controller;
/// 创建负责订阅和释放事件流的状态对象。
@override
State<_QuickVerificationEffect> createState() =>
_QuickVerificationEffectState();
}
/// 快速核销弹框事件的页面生命周期状态。
class _QuickVerificationEffectState extends State<_QuickVerificationEffect> {
/// Controller 广播流订阅,页面销毁时必须取消,避免旧页面响应后续识别结果。
StreamSubscription<ReservationQuickVerificationRequest>? _subscription;
/// 当前是否已经展示快速核销弹框,防止异常重复事件叠加多个模态层。
bool _presenting = false;
/// 页面挂载后立即订阅一次性快速核销事件。
@override
void initState() {
super.initState();
_subscription =
widget.controller.quickVerificationRequests.listen(_present);
}
/// 使用当前页面上下文展示多选核销弹框,提交仍复用 Controller 的防重复与失效校验。
Future<void> _present(ReservationQuickVerificationRequest request) async {
if (!mounted || _presenting) return;
_presenting = true;
try {
await _showResultDialogWithRecognitionPaused(
context,
controller: widget.controller,
result: request.result,
day: request.day,
meal: request.meal,
verificationEnabled: true,
onConfirm: (selectedOrderIds) => widget.controller.confirmVerification(
request.day,
request.meal,
selectedOrderIds,
),
);
} finally {
_presenting = false;
}
}
/// 取消事件订阅,避免页面退出后继续持有 Controller 或 Navigator 上下文。
@override
void dispose() {
unawaited(_subscription?.cancel());
super.dispose();
}
/// 事件桥接组件不占据布局空间,查询页面视觉结构由相邻工作区负责。
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
class _QueryTitle extends StatelessWidget {
const _QueryTitle({required this.status});
final ReservationMenuQueryStatus status;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
'预约查询',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 22.sp,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
Icon(
Icons.check_circle_rounded,
color: XfjDesignTokens.success,
size: 18.w,
),
SizedBox(width: 5.w),
Text(
'设备在线',
style: TextStyle(
color: XfjDesignTokens.success,
fontSize: 12.sp,
fontWeight: FontWeight.w700,
),
),
],
);
}
}