副屏
This commit is contained in:
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 {
|
||||
/// 创建主操作或描边操作按钮。
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user