404 lines
14 KiB
Dart
404 lines
14 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|