Files
biz_xfj_cloud/lib/sub_screen/page/reservation_query_display.dart
T
2026-10-09 09:32:08 +08:00

605 lines
19 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:math' as math;
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/utils/reservation_query_coordinator.dart';
import 'package:pj_supermarket_cloud/sub_screen/widgets/reservation_display_metrics.dart';
import 'package:pj_supermarket_cloud/sub_screen/widgets/reservation_query_state_panel.dart';
import 'package:pj_supermarket_cloud/sub_screen/widgets/reservation_redeem_hint_bar.dart';
/// 真实副屏和 Debug 预览共用的预约菜单查询屏。
class ReservationQueryDisplayPage extends StatelessWidget {
const ReservationQueryDisplayPage({
super.key,
required this.coordinator,
this.previewMode = false,
this.onExit,
});
final ReservationQueryCoordinator coordinator;
final bool previewMode;
final VoidCallback? onExit;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: XfjDesignTokens.canvas,
body: Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: coordinator,
builder: (context, _) => _ReservationQueryDisplayView(
state: coordinator.state,
),
),
),
if (previewMode)
Positioned(
top: 16,
right: 16,
child: SafeArea(
child: Semantics(
button: true,
label: '退出预约查询屏预览',
child: Material(
color: const Color(0xD9111827),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onExit,
borderRadius: BorderRadius.circular(12),
child: const SizedBox(
width: 118,
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.close_fullscreen_rounded,
color: Colors.white,
size: 20,
),
SizedBox(width: 8),
Text(
'退出预览',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
),
),
],
),
);
}
}
class _ReservationQueryDisplayView extends StatelessWidget {
const _ReservationQueryDisplayView({required this.state});
final ReservationQueryDisplayState state;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final scale = math
.min(constraints.maxWidth / 1920, constraints.maxHeight / 1080)
.clamp(0.55, 1.12)
.toDouble();
return ColoredBox(
key: ValueKey('reservation-scenario-${state.scenario.name}'),
color: XfjDesignTokens.canvas,
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24 * scale),
child: Column(
children: [
_ReservationHeader(state: state, scale: scale),
SizedBox(height: 14 * scale),
Expanded(
child: state.continuousFaceRecognition
? _ContinuousFaceReservationBody(
state: state,
scale: scale,
)
: _ReservationBody(state: state, scale: scale),
),
SizedBox(height: 12 * scale),
_ReservationFooter(state: state, scale: scale),
],
),
),
),
);
},
);
}
}
class _ReservationHeader extends StatelessWidget {
const _ReservationHeader({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
return Container(
// 字号保底后两行文字在小尺寸副屏上会超过按比例缩放的 82 高,
// 与首页副屏一致给头部一个最低高度,避免标题和副标题被裁切。
height: math.max(82 * scale, 56),
padding: EdgeInsets.symmetric(horizontal: 22 * scale),
decoration: reservationSurfaceDecoration(16 * scale),
child: Row(
children: [
Container(
width: 48 * scale,
height: 48 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: XfjDesignTokens.primary,
borderRadius: BorderRadius.circular(13 * scale),
),
child: Icon(
Icons.calendar_month_rounded,
color: Colors.white,
size: reservationFontSize(28, scale),
),
),
SizedBox(width: 14 * scale),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'预约查询',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: reservationFontSize(28, scale),
height: 1.2,
fontWeight: FontWeight.w900,
),
),
Text(
state.merchantName ?? '请使用饭卡或人脸识别本人预约',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: reservationFontSize(18, scale),
height: 1.3,
fontWeight: FontWeight.w500,
),
),
],
),
const Spacer(),
if (state.maskedName != null) ...[
_HeaderPill(
icon: Icons.person_outline_rounded,
label: [state.maskedName, state.deptName]
.whereType<String>()
.where((value) => value.trim().isNotEmpty)
.join(' · '),
color: XfjDesignTokens.primary,
scale: scale,
),
SizedBox(width: 10 * scale),
],
_HeaderPill(
icon: reservationScenarioIcon(state.scenario),
label: state.scenario.title,
color: reservationScenarioColor(state.scenario),
scale: scale,
),
],
),
);
}
}
class _HeaderPill extends StatelessWidget {
const _HeaderPill({
required this.icon,
required this.label,
required this.color,
required this.scale,
});
final IconData icon;
final String label;
final Color color;
final double scale;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(minHeight: 44 * scale),
padding: EdgeInsets.symmetric(horizontal: 14 * scale),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
border: Border.all(color: color.withValues(alpha: 0.22)),
borderRadius: BorderRadius.circular(22 * scale),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: reservationFontSize(21, scale)),
SizedBox(width: 7 * scale),
Text(
label,
style: TextStyle(
color: color,
fontSize: reservationFontSize(18, scale),
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// 持续人脸副屏组合区,左侧长期保留实时预览,右侧按状态切换等待或预约菜单。
class _ContinuousFaceReservationBody extends StatelessWidget {
/// 创建与预约快照同步的持续人脸组合布局。
const _ContinuousFaceReservationBody({
required this.state,
required this.scale,
});
/// 当前主屏发送并经 Coordinator 校验的预约展示快照。
final ReservationQueryDisplayState state;
/// 基于副屏实际尺寸计算的统一缩放系数。
final double scale;
/// 构建固定宽度摄像头区域和弹性预约结果区域,状态变化不会销毁 Texture。
@override
Widget build(BuildContext context) {
final presentation = _continuousFacePresentation(state.scenario);
final countdown = state.remainingSeconds;
final cameraPanel = <Widget>[
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(12 * scale),
child: FaceLiveCameraPreview(
key: const ValueKey('sub-reservation-live-face-preview'),
statusText: presentation.text,
statusColor: presentation.color,
scale: scale,
),
),
),
SizedBox(height: 18 * scale),
<Widget>[
Icon(
presentation.icon,
color: presentation.color,
size: reservationFontSize(36, scale),
),
<Widget>[
Text(
presentation.title,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: reservationFontSize(28, scale),
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 5 * scale),
Text(
countdown > 0
? '${presentation.detail},$countdown 秒后识别下一位'
: presentation.detail,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: reservationFontSize(18, scale),
height: 1.45,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(),
].toRowSpace(space: 14 * scale).padding(all: 16 * scale).decorated(
color: presentation.color.withValues(alpha: 0.08),
border: Border.all(
color: presentation.color.withValues(alpha: 0.22),
),
borderRadius: BorderRadius.circular(12 * scale),
),
const Spacer(),
<Widget>[
Icon(
Icons.privacy_tip_outlined,
color: XfjDesignTokens.primary,
size: reservationFontSize(22, scale),
),
Text(
'实时画面仅用于本人识别,不保存影像',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: reservationFontSize(18, scale),
fontWeight: FontWeight.w600,
),
).expanded(),
].toRowSpace(space: 9 * scale),
]
.toColumn(crossAxisAlignment: CrossAxisAlignment.stretch)
.padding(all: 16 * scale)
.decorated(
color: XfjDesignTokens.surface,
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(16 * scale),
boxShadow: XfjDesignTokens.surfaceShadow,
);
return <Widget>[
cameraPanel.expanded(flex: 3),
_ReservationBody(state: state, scale: scale).expanded(flex: 8),
].toRowSpace(
space: 14 * scale,
crossAxisAlignment: CrossAxisAlignment.stretch,
);
}
/// 把预约阶段转换为摄像头区域的独立状态,菜单展示期明确暂停下一位识别。
({
String text,
String title,
String detail,
Color color,
IconData icon,
}) _continuousFacePresentation(ReservationQueryDisplayScenario scenario) {
return switch (scenario) {
ReservationQueryDisplayScenario.loading => (
text: '正在查询菜单',
title: '身份识别成功',
detail: '正在读取本人预约菜单',
color: XfjDesignTokens.primary,
icon: Icons.search_rounded,
),
ReservationQueryDisplayScenario.success ||
ReservationQueryDisplayScenario.empty =>
(
text: '当前结果展示中',
title: '预约结果展示中',
detail: '摄像头保持开启,暂不识别其他人员',
color: XfjDesignTokens.success,
icon: Icons.check_circle_outline_rounded,
),
ReservationQueryDisplayScenario.error => (
text: '持续识别已暂停',
title: '识别暂时中断',
detail: '请等待工作人员处理后重新识别',
color: XfjDesignTokens.warning,
icon: Icons.error_outline_rounded,
),
_ => (
text: '持续识别中',
title: '请正对摄像头',
detail: '正在等待并识别下一位用户',
color: XfjDesignTokens.primary,
icon: Icons.face_retouching_natural_rounded,
),
};
}
}
class _ReservationBody extends StatelessWidget {
const _ReservationBody({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
if (state.scenario != ReservationQueryDisplayScenario.success) {
return ReservationQueryStatePanel(state: state, scale: scale);
}
return Container(
padding: EdgeInsets.all(14 * scale),
decoration: reservationSurfaceDecoration(16 * scale),
child: Column(
children: [
_SummaryBar(state: state, scale: scale),
// 键盘模式成功页加一条核销引导,绿色系呼应键盘图上的“核销”键帽。
if (state.keyboardHintsEnabled) ...[
SizedBox(height: 10 * scale),
ReservationRedeemHintBar(scale: scale),
],
SizedBox(height: 12 * scale),
Expanded(
child: _ReservationGrid(days: state.days, scale: scale),
),
],
),
);
}
}
class _SummaryBar extends StatelessWidget {
const _SummaryBar({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
return Container(
height: 58 * scale,
padding: EdgeInsets.symmetric(horizontal: 18 * scale),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
border: Border.all(
color: XfjDesignTokens.primary.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(12 * scale),
),
child: Row(
children: [
_SummaryItem(
icon: Icons.event_available_outlined,
label: '共 ${state.totalDayCount} 个预约日',
scale: scale,
),
SizedBox(width: 24 * scale),
_SummaryItem(
icon: Icons.restaurant_menu_rounded,
label: '${state.totalMealCount} 个餐次',
scale: scale,
),
const Spacer(),
if (state.totalPages > 1)
Text(
'第 ${state.currentPage} / ${state.totalPages} 页',
key: const ValueKey('reservation-page-indicator'),
style: TextStyle(
color: XfjDesignTokens.primary,
fontSize: reservationFontSize(20, scale),
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}
class _SummaryItem extends StatelessWidget {
const _SummaryItem({
required this.icon,
required this.label,
required this.scale,
});
final IconData icon;
final String label;
final double scale;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: XfjDesignTokens.primary, size: reservationFontSize(22, scale)),
SizedBox(width: 7 * scale),
Text(
label,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: reservationFontSize(20, scale),
fontWeight: FontWeight.w800,
),
),
],
);
}
}
class _ReservationGrid extends StatelessWidget {
const _ReservationGrid({required this.days, required this.scale});
final List<ReservationMenuDayData> days;
final double scale;
@override
Widget build(BuildContext context) {
final rows = days.length <= 5 ? 1 : 2;
return Column(
children: List.generate(rows, (rowIndex) {
final start = rowIndex * 5;
final rowDays = days.skip(start).take(5).toList();
return Expanded(
child: Padding(
padding: EdgeInsets.only(top: rowIndex == 0 ? 0 : 10 * scale),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(5, (columnIndex) {
final day =
columnIndex < rowDays.length ? rowDays[columnIndex] : null;
return Expanded(
child: Padding(
padding: EdgeInsets.only(
left: columnIndex == 0 ? 0 : 5 * scale,
right: columnIndex == 4 ? 0 : 5 * scale,
),
child: day == null
? const SizedBox.shrink()
: ReservationScheduleCard(day: day, customer: true),
),
);
}),
),
),
);
}),
);
}
}
class _ReservationFooter extends StatelessWidget {
const _ReservationFooter({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
return Container(
height: 54 * scale,
padding: EdgeInsets.symmetric(horizontal: 18 * scale),
decoration: reservationSurfaceDecoration(14 * scale),
child: Row(
children: [
Icon(
Icons.privacy_tip_outlined,
color: XfjDesignTokens.primary,
size: reservationFontSize(21, scale),
),
SizedBox(width: 8 * scale),
Expanded(
child: Text(
state.continuousFaceRecognition
? '持续识别已开启,仅展示当前用户本人的有效预约'
: '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: reservationFontSize(18, scale),
fontWeight: FontWeight.w600,
),
),
),
if (state.remainingSeconds > 0) ...[
Icon(
Icons.timer_outlined,
color: XfjDesignTokens.warning,
size: reservationFontSize(21, scale),
),
SizedBox(width: 6 * scale),
Text(
'${state.remainingSeconds} 秒后清屏',
key: const ValueKey('reservation-privacy-countdown'),
style: TextStyle(
color: XfjDesignTokens.warning,
fontSize: reservationFontSize(20, scale),
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
],
),
);
}
}