Files
biz_xfj_cloud/lib/sub_screen/page/reservation_query_display.dart
T

555 lines
17 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: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: _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 _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.loading => '正在查询本人预约',
ReservationQueryDisplayScenario.empty => '当前没有有效预约',
ReservationQueryDisplayScenario.error => '暂时无法查询预约',
ReservationQueryDisplayScenario.expired => '预约信息已清除',
ReservationQueryDisplayScenario.success => '预约查询成功',
};
final description = switch (state.scenario) {
ReservationQueryDisplayScenario.idle => '识别成功后,这里将展示当前学生本人的预约菜单',
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(
'仅展示当前学生本人的有效预约,页面不会显示卡号、手机号或订单号',
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.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.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,
);
}