Files
biz_xfj_cloud/lib/sub_screen/page/reservation_query_display.dart
T
2026-08-09 14:45:18 +08:00

714 lines
23 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';
/// 真实副屏和 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(
height: 82 * scale,
padding: EdgeInsets.symmetric(horizontal: 22 * scale),
decoration: _surfaceDecoration(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: 27 * scale,
),
),
SizedBox(width: 14 * scale),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'预约查询',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 24 * scale,
fontWeight: FontWeight.w900,
),
),
Text(
state.merchantName ?? '请使用饭卡或人脸识别本人预约',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * scale,
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: _scenarioIcon(state.scenario),
label: state.scenario.title,
color: _scenarioColor(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: 20 * scale),
SizedBox(width: 7 * scale),
Text(
label,
style: TextStyle(
color: color,
fontSize: 15 * 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: 34 * scale,
),
<Widget>[
Text(
presentation.title,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 25 * scale,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 5 * scale),
Text(
countdown > 0
? '${presentation.detail},$countdown 秒后识别下一位'
: presentation.detail,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 16 * 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: 21 * scale,
),
Text(
'实时画面仅用于本人识别,不保存影像',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * 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 _ReservationStatePanel(state: state, scale: scale);
}
return Container(
padding: EdgeInsets.all(14 * scale),
decoration: _surfaceDecoration(16 * scale),
child: Column(
children: [
_SummaryBar(state: state, 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: 17 * 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: 21 * scale),
SizedBox(width: 7 * scale),
Text(
label,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 17 * 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 _ReservationStatePanel extends StatelessWidget {
const _ReservationStatePanel({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
final color = _scenarioColor(state.scenario);
final title = switch (state.scenario) {
ReservationQueryDisplayScenario.idle => '请刷饭卡或进行人脸识别',
ReservationQueryDisplayScenario.recognizing => '正在识别下一位用户',
ReservationQueryDisplayScenario.loading => '正在查询本人预约',
ReservationQueryDisplayScenario.empty => '当前没有有效预约',
ReservationQueryDisplayScenario.error => '暂时无法查询预约',
ReservationQueryDisplayScenario.expired => '预约信息已清除',
ReservationQueryDisplayScenario.success => '预约查询成功',
};
final description = switch (state.scenario) {
ReservationQueryDisplayScenario.idle => '识别成功后,这里将展示当前用户本人的预约菜单',
ReservationQueryDisplayScenario.recognizing => '请正对摄像头,识别成功后将展示本人预约菜单',
ReservationQueryDisplayScenario.loading => '请稍候,不要重复刷卡或离开识别区域',
ReservationQueryDisplayScenario.empty => '当前食堂今天及未来日期暂无可展示的已支付预约',
ReservationQueryDisplayScenario.error => '请在主屏检查网络或识别设备后重新尝试',
ReservationQueryDisplayScenario.expired => '为保护用户隐私,请重新刷卡或进行人脸识别',
ReservationQueryDisplayScenario.success => '',
};
return Container(
alignment: Alignment.center,
decoration: _surfaceDecoration(16 * scale),
child: Semantics(
liveRegion: state.scenario == ReservationQueryDisplayScenario.error,
label: '$title,$description',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 108 * scale,
height: 108 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: state.scenario == ReservationQueryDisplayScenario.loading
? SizedBox(
width: 48 * scale,
height: 48 * scale,
child: CircularProgressIndicator(
color: color,
strokeWidth: 5 * scale,
),
)
: Icon(
_scenarioIcon(state.scenario),
color: color,
size: 56 * scale,
),
),
SizedBox(height: 24 * scale),
Text(
title,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 34 * scale,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 10 * scale),
Text(
description,
textAlign: TextAlign.center,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 18 * scale,
height: 1.5,
),
),
],
),
),
);
}
}
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: _surfaceDecoration(14 * scale),
child: Row(
children: [
Icon(
Icons.privacy_tip_outlined,
color: XfjDesignTokens.primary,
size: 20 * scale,
),
SizedBox(width: 8 * scale),
Expanded(
child: Text(
state.continuousFaceRecognition
? '持续识别已开启,仅展示当前用户本人的有效预约'
: '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * scale,
fontWeight: FontWeight.w600,
),
),
),
if (state.remainingSeconds > 0) ...[
Icon(
Icons.timer_outlined,
color: XfjDesignTokens.warning,
size: 20 * scale,
),
SizedBox(width: 6 * scale),
Text(
'${state.remainingSeconds} 秒后清屏',
key: const ValueKey('reservation-privacy-countdown'),
style: TextStyle(
color: XfjDesignTokens.warning,
fontSize: 15 * scale,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
],
),
);
}
}
IconData _scenarioIcon(ReservationQueryDisplayScenario scenario) {
return switch (scenario) {
ReservationQueryDisplayScenario.idle => Icons.badge_outlined,
ReservationQueryDisplayScenario.recognizing =>
Icons.face_retouching_natural_rounded,
ReservationQueryDisplayScenario.loading => Icons.search_rounded,
ReservationQueryDisplayScenario.success => Icons.check_circle_outline,
ReservationQueryDisplayScenario.empty => Icons.event_busy_outlined,
ReservationQueryDisplayScenario.error => Icons.error_outline_rounded,
ReservationQueryDisplayScenario.expired => Icons.lock_clock_outlined,
};
}
Color _scenarioColor(ReservationQueryDisplayScenario scenario) {
return switch (scenario) {
ReservationQueryDisplayScenario.idle ||
ReservationQueryDisplayScenario.recognizing ||
ReservationQueryDisplayScenario.loading ||
ReservationQueryDisplayScenario.success =>
XfjDesignTokens.primary,
ReservationQueryDisplayScenario.empty ||
ReservationQueryDisplayScenario.expired =>
XfjDesignTokens.warning,
ReservationQueryDisplayScenario.error => XfjDesignTokens.danger,
};
}
BoxDecoration _surfaceDecoration(double radius) {
return BoxDecoration(
color: XfjDesignTokens.surface,
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(radius),
boxShadow: XfjDesignTokens.surfaceShadow,
);
}