Files
biz_xfj_cloud/lib/common/widgets/app/reservation_schedule_card.dart
T
2026-07-28 21:34:12 +08:00

459 lines
14 KiB
Dart

import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 主副屏共用预约日期卡,确保日期、餐段和今日标识保持完全一致。
class ReservationScheduleCard extends StatelessWidget {
const ReservationScheduleCard({
super.key,
required this.day,
this.customer = false,
this.verificationEnabled = false,
this.onVerify,
});
/// 当前卡片展示的预约日期数据。
final ReservationMenuDayData day;
/// 是否使用副屏顾客视角的放大比例。
final bool customer;
/// 是否把主屏订单入口置为可核销模式;关闭时仍显示“查询详情”。
final bool verificationEnabled;
/// 用户点击核销入口时回传当前餐段。
final ValueChanged<ReservationMenuMealData>? onVerify;
@override
Widget build(BuildContext context) {
final today = day.isToday();
return Semantics(
container: true,
label: '${day.date} ${day.weekLabel}${today ? ' 今天' : ''},已预约',
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: XfjDesignTokens.surface,
border: Border.all(
color: today ? XfjDesignTokens.primary : XfjDesignTokens.line,
width: today ? 2 : 1,
),
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
boxShadow: today
? [
BoxShadow(
color: XfjDesignTokens.primary.withValues(alpha: 0.12),
blurRadius: 0,
spreadRadius: 3,
),
]
: null,
),
child: Column(
children: [
_ReservationDayHeader(day: day, today: today, customer: customer),
Expanded(
child: Container(
color: today
? XfjDesignTokens.primary.withValues(alpha: 0.03)
: XfjDesignTokens.surface,
child: Column(
children: day.displayMeals
.map(
(meal) => Expanded(
child: _ReservationMealRow(
meal: meal,
customer: customer,
showDivider: meal != day.displayMeals.last,
showOrderAction: !customer &&
meal.reserved &&
meal.orderIds.isNotEmpty,
verificationEnabled: verificationEnabled,
onVerify: onVerify,
),
),
)
.toList(),
),
),
),
],
),
),
);
}
}
class _ReservationDayHeader extends StatelessWidget {
const _ReservationDayHeader({
required this.day,
required this.today,
required this.customer,
});
/// 当前标题展示的预约日期数据。
final ReservationMenuDayData day;
/// 当前日期是否为今天。
final bool today;
/// 是否使用副屏顾客视角的放大比例。
final bool customer;
@override
Widget build(BuildContext context) {
final scale = customer ? 1.14 : 1.0;
return LayoutBuilder(
builder: (context, constraints) {
// 小窗口下单卡外宽仍可能略大于内容宽,提前进入紧凑模式避免标题胶囊溢出。
final compact = constraints.maxWidth < 280;
final effectiveScale = compact ? scale * 0.88 : scale;
return Container(
padding: EdgeInsets.symmetric(
horizontal: (compact ? 8 : 12) * effectiveScale,
vertical: (compact ? 8 : 10) * effectiveScale,
),
decoration: BoxDecoration(
color:
today ? XfjDesignTokens.primary : XfjDesignTokens.primarySoft,
border: Border(
bottom: BorderSide(
color: today
? XfjDesignTokens.primary
: XfjDesignTokens.primary.withValues(alpha: 0.2),
),
),
),
child: Row(
children: [
Text(
day.monthDay,
style: TextStyle(
color: today ? Colors.white : XfjDesignTokens.ink,
fontSize: (compact ? 18 : 21) * effectiveScale,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
SizedBox(width: (compact ? 5 : 8) * effectiveScale),
Text(
day.weekLabel,
style: TextStyle(
color: today ? Colors.white70 : XfjDesignTokens.muted,
fontSize: 13 * effectiveScale,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
padding: EdgeInsets.symmetric(
horizontal: (compact ? 6 : 8) * effectiveScale,
vertical: 5 * effectiveScale,
),
decoration: BoxDecoration(
color: XfjDesignTokens.surface,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!compact) ...[
FIcon(
FAssets.icons.circleCheck,
color: today
? XfjDesignTokens.primary
: XfjDesignTokens.success,
size: 15 * effectiveScale,
),
SizedBox(width: 4 * effectiveScale),
],
Text(
today ? '今天' : '已预约',
style: TextStyle(
color: today
? XfjDesignTokens.primary
: XfjDesignTokens.success,
fontSize: (compact ? 11 : 12) * effectiveScale,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
},
);
}
}
class _ReservationMealRow extends StatelessWidget {
const _ReservationMealRow({
required this.meal,
required this.customer,
required this.showDivider,
required this.showOrderAction,
required this.verificationEnabled,
this.onVerify,
});
/// 当前行展示的预约餐段。
final ReservationMenuMealData meal;
/// 是否使用副屏顾客视角的放大比例。
final bool customer;
/// 是否在当前餐段下方展示分隔线。
final bool showDivider;
/// 是否展示主屏订单详情入口。
final bool showOrderAction;
/// 当前详情入口是否允许继续提交核销。
final bool verificationEnabled;
/// 用户点击核销入口时回传当前餐段。
final ValueChanged<ReservationMenuMealData>? onVerify;
@override
Widget build(BuildContext context) {
final scale = customer ? 1.1 : 1.0;
return LayoutBuilder(
builder: (context, constraints) {
// 真机五列布局下单卡宽度较窄,详情按钮与菜名分行展示,避免菜名被挤成逐字竖排。
final compact = showOrderAction && constraints.maxWidth < 240;
final content = compact
? <Widget>[
<Widget>[
_mealIdentity(scale),
const Spacer(),
_orderAction(scale, compact: true),
].toRow(crossAxisAlignment: CrossAxisAlignment.center),
SizedBox(height: 4 * scale),
_mealContent(scale),
].toColumn(crossAxisAlignment: CrossAxisAlignment.start)
: <Widget>[
_mealIdentity(scale),
SizedBox(width: 6 * scale),
_mealContent(scale).expanded(),
if (showOrderAction) ...[
SizedBox(width: 6 * scale),
_orderAction(scale),
],
].toRow(crossAxisAlignment: CrossAxisAlignment.center);
return content
.padding(
horizontal: (compact ? 8 : 12) * scale,
vertical: compact ? 6 : 7,
)
.decorated(
border: showDivider
? const Border(
bottom: BorderSide(color: XfjDesignTokens.line),
)
: null,
);
},
);
}
/// 餐次列保持固定对齐,但为四字“夜间餐”预留完整单行宽度。
Widget _mealIdentity(double scale) {
final iconRadius = BorderRadius.circular(8 * scale);
final icon = FIcon(
FAssets.icons.utensils,
color: XfjDesignTokens.primary,
size: 15 * scale,
).center().tightSize(28 * scale).decorated(
color: XfjDesignTokens.primarySoft,
border: Border.all(
color: XfjDesignTokens.primary.withValues(alpha: 0.16),
),
borderRadius: iconRadius,
);
final title = Text(
meal.title,
key: ValueKey('reservation-meal-title-${meal.hourType}'),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 14 * scale,
fontWeight: FontWeight.w800,
),
);
// 餐段列按标题实际宽度收缩,只保留上下限,避免固定宽度挤压右侧菜品。
return <Widget>[icon, title]
.toRowSpace(
mainAxisSize: MainAxisSize.min,
space: 5 * scale,
)
.constrained(minWidth: 64 * scale, maxWidth: 94 * scale);
}
Widget _mealContent(double scale) {
if (!meal.reserved) {
return _MealStatusLabel(label: '未预定', scale: scale, muted: true);
}
return Wrap(
spacing: 5 * scale,
runSpacing: 5 * scale,
children: [
if (meal.orderIds.length > 1)
_MealStatusLabel(
label: '${meal.orderIds.length}笔预约',
scale: scale,
),
if (meal.items.isEmpty)
_MealStatusLabel(label: '已预定', scale: scale)
else
...meal.items.map(
(item) => _MealItemLabel(label: item, scale: scale),
),
],
).alignLeft();
}
/// 可见按钮保持紧凑,外层仍保留 48dp 命中区域,兼顾大屏触控和列表密度。
Widget _orderAction(double scale, {bool compact = false}) {
final actionLabel = verificationEnabled ? '核销' : '查询详情';
final radius = BorderRadius.circular(9 * scale);
final label = <Widget>[
if (!compact || verificationEnabled)
FIcon(
verificationEnabled ? FAssets.icons.circleCheck : FAssets.icons.eye,
color: XfjDesignTokens.primary,
size: (compact ? 12 : 14) * scale,
),
Text(
actionLabel,
style: TextStyle(
color: XfjDesignTokens.primary,
fontSize: (compact ? 12 : 13) * scale,
fontWeight: FontWeight.w800,
),
),
].toRowSpace(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
space: 4 * scale,
);
final visual = label
.center()
.constrained(
key: ValueKey('reservation-verification-visual-${meal.hourType}'),
minWidth: (compact
? (verificationEnabled ? 48 : 62)
: (verificationEnabled ? 54 : 76)) *
scale,
maxWidth: (compact
? (verificationEnabled ? 56 : 70)
: (verificationEnabled ? 64 : 92)) *
scale,
height: compact ? 30 : 36,
)
.decorated(
color: XfjDesignTokens.primarySoft,
border: Border.all(
color: XfjDesignTokens.primary.withValues(alpha: 0.28),
),
borderRadius: radius,
);
return Semantics(
button: true,
label: '${meal.title}$actionLabel',
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onVerify == null ? null : () => onVerify!(meal),
borderRadius: radius,
child: visual.center(),
),
),
).constrained(
key: ValueKey('reservation-verification-target-${meal.hourType}'),
minWidth: (compact
? (verificationEnabled ? 54 : 68)
: (verificationEnabled ? 58 : 82)) *
scale,
maxWidth: (compact
? (verificationEnabled ? 62 : 76)
: (verificationEnabled ? 68 : 96)) *
scale,
height: 48,
);
}
}
class _MealItemLabel extends StatelessWidget {
const _MealItemLabel({required this.label, required this.scale});
/// 当前展示的餐品名称。
final String label;
/// 当前主副屏布局使用的缩放比例。
final double scale;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 150 * scale),
padding: EdgeInsets.symmetric(
horizontal: 7 * scale,
vertical: 3 * scale,
),
decoration: BoxDecoration(
color: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 13 * scale,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _MealStatusLabel extends StatelessWidget {
const _MealStatusLabel({
required this.label,
required this.scale,
this.muted = false,
});
/// 当前餐段状态文案。
final String label;
/// 当前主副屏布局使用的缩放比例。
final double scale;
/// 是否使用弱化状态颜色。
final bool muted;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
label,
style: TextStyle(
color: muted ? XfjDesignTokens.muted : XfjDesignTokens.success,
fontSize: 13 * scale,
fontWeight: FontWeight.w700,
),
),
);
}
}