This commit is contained in:
dzc committed 2026-09-10 14:50:55 +08:00
1 parent 154122a847
commit 2e929c199b
21 files changed
+5517 -85

No files matched your search

@@ -0,0 +1,458 @@
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,
),
),
);
}
}
@@ -303,14 +303,15 @@ class _FaceRecognitionPanelState extends State<FaceRecognitionPanel> {
),
),
),
SizedBox(height: gap),
_FaceRecognitionStatusCard(
status: status,
message: _failure?.message ?? _event?.message,
compact: widget.compact,
),
SizedBox(height: gap),
_FaceRecognitionPrivacyHint(compact: widget.compact),
// 识别进行中不展示“正对摄像头/建议距离”类姿态引导,仅在启动、成功或失败时给出状态卡。
if (status != FaceRecognitionSessionStatus.detecting) ...[
SizedBox(height: gap),
_FaceRecognitionStatusCard(
status: status,
message: _failure?.message ?? _event?.message,
compact: widget.compact,
),
],
if (_failure != null || widget.showCancelAction) ...[
SizedBox(height: gap),
<Widget>[
@@ -364,7 +365,7 @@ class _FaceRecognitionStatusCard extends StatelessWidget {
? '识别成功'
: status == FaceRecognitionSessionStatus.initializing
? '正在启动摄像头'
: '请顾客正对摄像头';
: '人脸识别中';
final icon = failed
? Icons.error_outline_rounded
: success
@@ -386,7 +387,7 @@ class _FaceRecognitionStatusCard extends StatelessWidget {
Text(
message?.trim().isNotEmpty == true
? message!.trim()
: '保持面部完整出现在识别框内',
: '正在准备识别画面',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp),
@@ -403,42 +404,6 @@ class _FaceRecognitionStatusCard extends StatelessWidget {
}
}
/// 人脸画面使用范围和识别距离提示。
class _FaceRecognitionPrivacyHint extends StatelessWidget {
/// 创建紧凑或常规密度的隐私提示。
const _FaceRecognitionPrivacyHint({required this.compact});
/// 是否使用收银工作区紧凑间距。
final bool compact;
/// 构建不会与主状态争夺层级的次要说明。
@override
Widget build(BuildContext context) {
return <Widget>[
Icon(
Icons.face_retouching_natural_rounded,
color: XfjDesignTokens.primary,
size: 22.w,
),
SizedBox(width: 10.w),
Text(
'建议距离 50-80cm,保持面部完整可见',
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp),
).expanded(),
Text(
'画面仅用于本次识别',
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 13.sp),
),
]
.toRow(crossAxisAlignment: CrossAxisAlignment.center)
.padding(horizontal: 14.w, vertical: compact ? 10.h : 12.h)
.decorated(
color: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
);
}
}
/// 人脸面板底部的固定高度操作按钮。
class _FacePanelButton extends StatelessWidget {
/// 创建主操作或描边操作按钮。
+1
View File
@@ -15,6 +15,7 @@ export './app/app_message_dialog.dart';
export './app/app_page.dart';
export './app/app_price.dart';
export './app/app_qr_code_dialog.dart';
export './app/reservation_schedule_card.dart';
export './app/app_radio_hour_type_dialog.dart';
export './app/app_title.dart';
export './app/app_wallet_selector.dart';
@@ -376,6 +376,8 @@ mixin OrderMixin {
sqlOrderDetail.payTime = DateTime.now();
await orderDao.edit(sqlOrderDetail);
if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) {
// 支付结果已落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。
unawaited(sendSubScreenPaySuccess());
if (sqlOrderDetail.payPrice != 0) {
final price = AppPriceTool.fenToYuan(sqlOrderDetail.payPrice);
TtsManage.speak("购买成功总金额$price元");
@@ -513,6 +515,27 @@ mixin OrderMixin {
);
}
/// 尽力通知副屏展示通用支付成功页,不参与主屏订单成功与否的判定。
///
/// 副屏收到后保留商品与金额并展示成功提示,十秒倒计时结束自动清屏回欢迎页;
/// 使用原生单向通道规避双屏插件业务消息不回包的问题,并限制等待时间。
Future<void> sendSubScreenPaySuccess() async {
try {
final delivered = await AppRuntimeTool.sendViceMessage(
SubScreenAction.orderPaySuccess.code,
).timeout(const Duration(seconds: 1));
if (!delivered) {
logger.w('|$_tag|副屏不可用,支付成功消息未投递|');
}
} on TimeoutException {
logger.w('|$_tag|通知副屏支付成功超时,主屏继续完成订单收尾|');
} catch (error, stack) {
logger.e(
'|$_tag|通知副屏支付成功失败error:|$error|stack:|$stack|',
);
}
}
Future<void> sendSubScreen() async {
if (shopCardList.isEmpty) {
await SubScreenPlugin.sendMsgToViceScreen(