副屏
This commit is contained in:
1 parent
154122a847
commit
2e929c199b
21 files changed
+5517
-85
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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(
|
||||
|
||||
@@ -2,6 +2,10 @@ library sub_common;
|
||||
|
||||
export './key/index.dart';
|
||||
|
||||
export './models/customer_display_models.dart';
|
||||
export './models/face_recognition_display_models.dart';
|
||||
export './models/reservation_query_display_models.dart';
|
||||
|
||||
export './routers/index.dart';
|
||||
|
||||
export './utils/index.dart';
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 顾客屏可展示的稳定业务状态,同时用于真实副屏和主屏调试预览。
|
||||
enum CustomerDisplayScenario {
|
||||
idle,
|
||||
review,
|
||||
paying,
|
||||
success,
|
||||
cancelled,
|
||||
timeout,
|
||||
}
|
||||
|
||||
CustomerDisplayScenario customerDisplayScenarioFromName(String? name) {
|
||||
return CustomerDisplayScenario.values.firstWhere(
|
||||
(scenario) => scenario.name == name,
|
||||
orElse: () => CustomerDisplayScenario.review,
|
||||
);
|
||||
}
|
||||
|
||||
extension CustomerDisplayScenarioX on CustomerDisplayScenario {
|
||||
String get title => switch (this) {
|
||||
CustomerDisplayScenario.idle => '待机欢迎',
|
||||
CustomerDisplayScenario.review => '商品核对',
|
||||
CustomerDisplayScenario.paying => '扫码支付',
|
||||
CustomerDisplayScenario.success => '支付成功',
|
||||
CustomerDisplayScenario.cancelled => '支付取消',
|
||||
CustomerDisplayScenario.timeout => '支付超时',
|
||||
};
|
||||
|
||||
String get description => switch (this) {
|
||||
CustomerDisplayScenario.idle => '展示欢迎信息,等待收银员添加商品',
|
||||
CustomerDisplayScenario.review => '展示商品明细、额外支付和实际扣款',
|
||||
CustomerDisplayScenario.paying => '展示支付二维码、倒计时和防重复扫码提示',
|
||||
CustomerDisplayScenario.success => '提示本次支付已完成',
|
||||
CustomerDisplayScenario.cancelled => '提示支付已取消并等待重新发起',
|
||||
CustomerDisplayScenario.timeout => '提示二维码已失效并等待重新发起',
|
||||
};
|
||||
}
|
||||
|
||||
/// 副屏套餐中的单个组成菜品,只承载名称和每套份数,不参与结算金额。
|
||||
@immutable
|
||||
class CustomerDisplayComboItem {
|
||||
/// 创建一条已经过副屏消息校验的套餐组成菜品。
|
||||
const CustomerDisplayComboItem({
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
/// 套餐组成菜品名称,来源于主屏本地每日菜谱快照。
|
||||
final String name;
|
||||
|
||||
/// 每套包含的菜品份数,始终为正整数。
|
||||
final int quantity;
|
||||
|
||||
/// 按全部展示字段判断两条套餐组成是否一致。
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayComboItem &&
|
||||
other.name == name &&
|
||||
other.quantity == quantity;
|
||||
}
|
||||
|
||||
/// 为副屏状态去重提供与相等判断一致的哈希值。
|
||||
@override
|
||||
int get hashCode => Object.hash(name, quantity);
|
||||
}
|
||||
|
||||
/// 副屏购物车中的一个主商品,包含可选的食堂套餐组成。
|
||||
@immutable
|
||||
class CustomerDisplayProduct {
|
||||
/// 创建一条副屏商品展示记录;旧协议商品默认按单品处理。
|
||||
const CustomerDisplayProduct({
|
||||
required this.name,
|
||||
required this.quantityLabel,
|
||||
required this.unitPrice,
|
||||
required this.totalPrice,
|
||||
this.imageSource,
|
||||
this.isCombo = false,
|
||||
this.comboItems = const [],
|
||||
});
|
||||
|
||||
/// 主商品名称。
|
||||
final String name;
|
||||
|
||||
/// 按件数量或称重重量的本地化展示文本。
|
||||
final String quantityLabel;
|
||||
|
||||
/// 主商品单价,单位为分。
|
||||
final int unitPrice;
|
||||
|
||||
/// 当前购物车条目的结算小计,单位为分。
|
||||
final int totalPrice;
|
||||
|
||||
/// 商品图片来源,兼容完整网络地址和服务端返回的 OSS 相对地址;为空时展示名称首字占位。
|
||||
final String? imageSource;
|
||||
|
||||
/// 是否为食堂套餐;旧版副屏消息缺失该字段时为 false。
|
||||
final bool isCombo;
|
||||
|
||||
/// 每套包含的组成菜品;单品或本地明细缺失时为空列表。
|
||||
final List<CustomerDisplayComboItem> comboItems;
|
||||
|
||||
/// 按主商品及套餐组成的全部展示字段判断两条记录是否一致。
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayProduct &&
|
||||
other.name == name &&
|
||||
other.quantityLabel == quantityLabel &&
|
||||
other.unitPrice == unitPrice &&
|
||||
other.totalPrice == totalPrice &&
|
||||
other.imageSource == imageSource &&
|
||||
other.isCombo == isCombo &&
|
||||
listEquals(other.comboItems, comboItems);
|
||||
}
|
||||
|
||||
/// 为副屏状态去重提供与相等判断一致的哈希值。
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
name,
|
||||
quantityLabel,
|
||||
unitPrice,
|
||||
totalPrice,
|
||||
imageSource,
|
||||
isCombo,
|
||||
Object.hashAll(comboItems),
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class CustomerDisplayState {
|
||||
const CustomerDisplayState({
|
||||
required this.scenario,
|
||||
required this.businessTitle,
|
||||
required this.products,
|
||||
required this.totalPrice,
|
||||
required this.amountMealPrice,
|
||||
this.customerName,
|
||||
this.qrCodeData,
|
||||
this.countdownSeconds = 0,
|
||||
this.connected = true,
|
||||
});
|
||||
|
||||
factory CustomerDisplayState.idle() => const CustomerDisplayState(
|
||||
scenario: CustomerDisplayScenario.idle,
|
||||
businessTitle: '智慧收银',
|
||||
products: [],
|
||||
totalPrice: 0,
|
||||
amountMealPrice: 0,
|
||||
);
|
||||
|
||||
/// 演示数据只用于主屏预览,避免调试页面读取真实顾客、订单或支付信息。
|
||||
factory CustomerDisplayState.demo(CustomerDisplayScenario scenario) {
|
||||
if (scenario == CustomerDisplayScenario.idle) {
|
||||
return CustomerDisplayState.idle();
|
||||
}
|
||||
return CustomerDisplayState(
|
||||
scenario: scenario,
|
||||
businessTitle: '智慧超市',
|
||||
customerName: '张小明',
|
||||
products: const [
|
||||
CustomerDisplayProduct(
|
||||
name: '农夫山泉 550ml',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 200,
|
||||
totalPrice: 200,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '纯牛奶 250ml',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 480,
|
||||
totalPrice: 480,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '乐事薯片 原味 70g',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 390,
|
||||
totalPrice: 390,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '香蕉(称重)',
|
||||
quantityLabel: '0.36 kg',
|
||||
unitPrice: 10000,
|
||||
totalPrice: 3600,
|
||||
),
|
||||
],
|
||||
totalPrice: 4670,
|
||||
amountMealPrice: 300,
|
||||
qrCodeData: scenario == CustomerDisplayScenario.paying
|
||||
? 'biz-xfj-customer-preview'
|
||||
: null,
|
||||
countdownSeconds: switch (scenario) {
|
||||
CustomerDisplayScenario.paying => 298,
|
||||
CustomerDisplayScenario.success => 10,
|
||||
_ => 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final CustomerDisplayScenario scenario;
|
||||
final String businessTitle;
|
||||
final String? customerName;
|
||||
final List<CustomerDisplayProduct> products;
|
||||
final int totalPrice;
|
||||
final int amountMealPrice;
|
||||
final String? qrCodeData;
|
||||
final int countdownSeconds;
|
||||
final bool connected;
|
||||
|
||||
int get payablePrice => totalPrice + amountMealPrice;
|
||||
|
||||
String? get maskedCustomerName {
|
||||
final value = customerName?.trim();
|
||||
if (value == null || value.isEmpty) return null;
|
||||
if (value.length == 1) return '*';
|
||||
if (value.length == 2) return '${value[0]}*';
|
||||
return '${value[0]}*${value[value.length - 1]}';
|
||||
}
|
||||
|
||||
CustomerDisplayState withScenario(
|
||||
CustomerDisplayScenario next, {
|
||||
String? qrCodeData,
|
||||
int countdownSeconds = 0,
|
||||
}) {
|
||||
return CustomerDisplayState(
|
||||
scenario: next,
|
||||
businessTitle: businessTitle,
|
||||
customerName: customerName,
|
||||
products: products,
|
||||
totalPrice: totalPrice,
|
||||
amountMealPrice: amountMealPrice,
|
||||
qrCodeData: qrCodeData,
|
||||
countdownSeconds: countdownSeconds,
|
||||
connected: connected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayState &&
|
||||
other.scenario == scenario &&
|
||||
other.businessTitle == businessTitle &&
|
||||
other.customerName == customerName &&
|
||||
listEquals(other.products, products) &&
|
||||
other.totalPrice == totalPrice &&
|
||||
other.amountMealPrice == amountMealPrice &&
|
||||
other.qrCodeData == qrCodeData &&
|
||||
other.countdownSeconds == countdownSeconds &&
|
||||
other.connected == connected;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
scenario,
|
||||
businessTitle,
|
||||
customerName,
|
||||
Object.hashAll(products),
|
||||
totalPrice,
|
||||
amountMealPrice,
|
||||
qrCodeData,
|
||||
countdownSeconds,
|
||||
connected,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum FaceRecognitionDisplayScene { cashier, reservation }
|
||||
|
||||
enum FaceRecognitionDisplayStatus {
|
||||
initializing,
|
||||
detecting,
|
||||
success,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FaceRecognitionDisplayState {
|
||||
const FaceRecognitionDisplayState({
|
||||
required this.sessionId,
|
||||
required this.revision,
|
||||
required this.scene,
|
||||
required this.status,
|
||||
required this.message,
|
||||
this.payableAmount,
|
||||
this.totalAmount,
|
||||
this.discountAmount,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final int revision;
|
||||
final FaceRecognitionDisplayScene scene;
|
||||
final FaceRecognitionDisplayStatus status;
|
||||
final String message;
|
||||
final int? payableAmount;
|
||||
final int? totalAmount;
|
||||
final int? discountAmount;
|
||||
|
||||
FaceRecognitionDisplayState copyWith({
|
||||
required int revision,
|
||||
required FaceRecognitionDisplayStatus status,
|
||||
required String message,
|
||||
}) {
|
||||
return FaceRecognitionDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scene: scene,
|
||||
status: status,
|
||||
message: message,
|
||||
payableAmount: payableAmount,
|
||||
totalAmount: totalAmount,
|
||||
discountAmount: discountAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart';
|
||||
|
||||
/// 预约查询副屏的稳定展示状态,真实副屏和设置预览共用同一份模型。
|
||||
enum ReservationQueryDisplayScenario {
|
||||
/// 普通饭卡或单轮人脸模式尚未开始识别。
|
||||
idle,
|
||||
|
||||
/// 持续人脸模式正在等待并识别下一位用户。
|
||||
recognizing,
|
||||
|
||||
/// 身份已识别,正在请求当前用户的预约菜单。
|
||||
loading,
|
||||
|
||||
/// 已获取并展示当前用户的预约菜单。
|
||||
success,
|
||||
|
||||
/// 身份有效,但当前用户没有可展示的预约。
|
||||
empty,
|
||||
|
||||
/// 识别设备或预约查询出现需要人工恢复的异常。
|
||||
error,
|
||||
|
||||
/// 普通模式的身份结果已按隐私时限清除。
|
||||
expired,
|
||||
}
|
||||
|
||||
/// 将跨 Engine 场景名称转换为副屏枚举,未知预览参数兼容为成功态。
|
||||
ReservationQueryDisplayScenario reservationQueryDisplayScenarioFromName(
|
||||
String? name,
|
||||
) {
|
||||
return ReservationQueryDisplayScenario.values.firstWhere(
|
||||
(scenario) => scenario.name == name,
|
||||
orElse: () => ReservationQueryDisplayScenario.success,
|
||||
);
|
||||
}
|
||||
|
||||
extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario {
|
||||
/// 当前场景用于状态标签的短标题。
|
||||
String get title => switch (this) {
|
||||
ReservationQueryDisplayScenario.idle => '等待识别',
|
||||
ReservationQueryDisplayScenario.recognizing => '持续识别中',
|
||||
ReservationQueryDisplayScenario.loading => '正在查询',
|
||||
ReservationQueryDisplayScenario.success => '查询成功',
|
||||
ReservationQueryDisplayScenario.empty => '暂无预约',
|
||||
ReservationQueryDisplayScenario.error => '查询失败',
|
||||
ReservationQueryDisplayScenario.expired => '隐私清屏',
|
||||
};
|
||||
|
||||
/// 当前场景用于预览和辅助说明的完整描述。
|
||||
String get description => switch (this) {
|
||||
ReservationQueryDisplayScenario.idle => '等待用户刷饭卡或进行人脸识别',
|
||||
ReservationQueryDisplayScenario.recognizing => '摄像头已开启,正在识别下一位用户',
|
||||
ReservationQueryDisplayScenario.loading => '正在识别本人身份并查询预约菜单',
|
||||
ReservationQueryDisplayScenario.success => '展示当前页预约日期、餐段和菜品',
|
||||
ReservationQueryDisplayScenario.empty => '身份识别成功,但当前没有有效预约',
|
||||
ReservationQueryDisplayScenario.error => '识别或网络异常,等待主屏重新发起',
|
||||
ReservationQueryDisplayScenario.expired => '身份与预约内容已按隐私规则清除',
|
||||
};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ReservationQueryDisplayState {
|
||||
/// 创建已通过 Coordinator 校验的不可变副屏展示快照。
|
||||
const ReservationQueryDisplayState({
|
||||
required this.sessionId,
|
||||
required this.revision,
|
||||
required this.scenario,
|
||||
required this.totalDayCount,
|
||||
required this.totalMealCount,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.days,
|
||||
this.maskedName,
|
||||
this.deptName,
|
||||
this.merchantName,
|
||||
this.expiresAt,
|
||||
this.remainingSeconds = 0,
|
||||
this.continuousFaceRecognition = false,
|
||||
});
|
||||
|
||||
factory ReservationQueryDisplayState.idle({
|
||||
String sessionId = '',
|
||||
int revision = 0,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: ReservationQueryDisplayScenario.idle,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 调试数据均为虚构内容,不读取真实用户身份或终端预约。
|
||||
factory ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario scenario, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final current = now ?? DateTime.now();
|
||||
if (scenario == ReservationQueryDisplayScenario.idle ||
|
||||
scenario == ReservationQueryDisplayScenario.recognizing ||
|
||||
scenario == ReservationQueryDisplayScenario.expired) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition:
|
||||
scenario == ReservationQueryDisplayScenario.recognizing,
|
||||
);
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.loading ||
|
||||
scenario == ReservationQueryDisplayScenario.error) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: false,
|
||||
);
|
||||
}
|
||||
|
||||
final days = scenario == ReservationQueryDisplayScenario.success
|
||||
? List<ReservationMenuDayData>.generate(10, (index) {
|
||||
final date = DateTime(current.year, current.month, current.day)
|
||||
.add(Duration(days: index));
|
||||
final dateText =
|
||||
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
return ReservationMenuDayData(
|
||||
date: dateText,
|
||||
meals: const [
|
||||
ReservationMenuMealData(
|
||||
hourType: 'BREAKFAST',
|
||||
title: '早餐',
|
||||
items: ['鲜肉包', '水煮蛋', '纯牛奶'],
|
||||
),
|
||||
ReservationMenuMealData(
|
||||
hourType: 'LUNCH',
|
||||
title: '午餐',
|
||||
items: ['香菇滑鸡', '清炒时蔬', '玉米排骨汤'],
|
||||
),
|
||||
],
|
||||
);
|
||||
})
|
||||
: const <ReservationMenuDayData>[];
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
maskedName: '张*明',
|
||||
deptName: '三年级二班',
|
||||
merchantName: '第一食堂',
|
||||
totalDayCount:
|
||||
scenario == ReservationQueryDisplayScenario.success ? 12 : 0,
|
||||
totalMealCount:
|
||||
scenario == ReservationQueryDisplayScenario.success ? 24 : 0,
|
||||
currentPage: 1,
|
||||
totalPages: scenario == ReservationQueryDisplayScenario.success ? 2 : 1,
|
||||
days: List.unmodifiable(days),
|
||||
expiresAt: current.add(const Duration(seconds: 60)),
|
||||
remainingSeconds: 60,
|
||||
continuousFaceRecognition: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 主屏查询页面会话编号,用于拒绝旧页面迟到消息。
|
||||
final String sessionId;
|
||||
|
||||
/// 当前会话内单调递增的消息版本。
|
||||
final int revision;
|
||||
|
||||
/// 副屏当前展示的查询阶段。
|
||||
final ReservationQueryDisplayScenario scenario;
|
||||
|
||||
/// 服务端返回的脱敏姓名;等待和异常场景为空。
|
||||
final String? maskedName;
|
||||
|
||||
/// 用餐人所属班级或部门;服务端未提供时为空。
|
||||
final String? deptName;
|
||||
|
||||
/// 当前终端绑定的食堂名称;未取得身份结果时为空。
|
||||
final String? merchantName;
|
||||
|
||||
/// 当前身份全部预约日期数量,不等同于当前页卡片数。
|
||||
final int totalDayCount;
|
||||
|
||||
/// 当前身份全部已预约餐次数量。
|
||||
final int totalMealCount;
|
||||
|
||||
/// 当前结果页码,从一开始。
|
||||
final int currentPage;
|
||||
|
||||
/// 当前结果总页数,至少为一。
|
||||
final int totalPages;
|
||||
|
||||
/// 当前页最多十个预约日期,不包含核销订单标识。
|
||||
final List<ReservationMenuDayData> days;
|
||||
|
||||
/// 当前身份结果的绝对过期时间;非结果场景为空。
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// 距离结果清理的剩余秒数,使用等宽数字展示。
|
||||
final int remainingSeconds;
|
||||
|
||||
/// 是否在副屏使用摄像头与预约内容并列的持续人脸布局。
|
||||
final bool continuousFaceRecognition;
|
||||
|
||||
/// 复制计时器需要变化的场景或剩余秒数,其余快照内容保持不变。
|
||||
ReservationQueryDisplayState copyWith({
|
||||
ReservationQueryDisplayScenario? scenario,
|
||||
int? remainingSeconds,
|
||||
bool? continuousFaceRecognition,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario ?? this.scenario,
|
||||
maskedName: maskedName,
|
||||
deptName: deptName,
|
||||
merchantName: merchantName,
|
||||
totalDayCount: totalDayCount,
|
||||
totalMealCount: totalMealCount,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
days: days,
|
||||
expiresAt: expiresAt,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
continuousFaceRecognition:
|
||||
continuousFaceRecognition ?? this.continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ReservationQueryDisplayState &&
|
||||
other.sessionId == sessionId &&
|
||||
other.revision == revision &&
|
||||
other.scenario == scenario &&
|
||||
other.maskedName == maskedName &&
|
||||
other.deptName == deptName &&
|
||||
other.merchantName == merchantName &&
|
||||
other.totalDayCount == totalDayCount &&
|
||||
other.totalMealCount == totalMealCount &&
|
||||
other.currentPage == currentPage &&
|
||||
other.totalPages == totalPages &&
|
||||
listEquals(other.days, days) &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.remainingSeconds == remainingSeconds &&
|
||||
other.continuousFaceRecognition == continuousFaceRecognition;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
sessionId,
|
||||
revision,
|
||||
scenario,
|
||||
maskedName,
|
||||
deptName,
|
||||
merchantName,
|
||||
totalDayCount,
|
||||
totalMealCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
Object.hashAll(days),
|
||||
expiresAt,
|
||||
remainingSeconds,
|
||||
continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart';
|
||||
|
||||
/// 副屏 Engine 不能依赖主屏 GetX/公共导出树,这里只镜像既有协议值用于消息解析。
|
||||
abstract final class _SubScreenProtocol {
|
||||
/// 主屏同步完整购物车快照的动作编码。
|
||||
static const updateCard = 'updateCard';
|
||||
|
||||
/// 主屏请求进入结算展示的动作编码。
|
||||
static const jumpSubPage = 'jumpSubPage';
|
||||
|
||||
/// 主屏请求退出结算展示的动作编码。
|
||||
static const jumpSubBack = 'jumpSubBack';
|
||||
|
||||
/// 主屏确认非聚合码订单已完成支付的动作编码。
|
||||
static const orderPaySuccess = 'orderPaySuccess';
|
||||
|
||||
/// 主屏展示旧版钱包二维码的动作编码。
|
||||
static const qrCodeScan = 'qrCodeScan';
|
||||
|
||||
/// 主屏确认旧版钱包二维码支付成功的动作编码。
|
||||
static const qrCodeScanSuccess = 'qrCodeScanSuccess';
|
||||
|
||||
/// 主屏取消旧版钱包二维码支付的动作编码。
|
||||
static const qrCodeScanCancel = 'qrCodeScanCancel';
|
||||
|
||||
/// 主屏展示支付宝、微信聚合订单码的动作编码。
|
||||
static const aggregateOrderPay = 'aggregateOrderPay';
|
||||
|
||||
/// 主屏确认聚合订单码支付成功的动作编码。
|
||||
static const aggregateOrderPaySuccess = 'aggregateOrderPaySuccess';
|
||||
|
||||
/// 主屏取消或终止聚合订单码支付的动作编码。
|
||||
static const aggregateOrderPayCancel = 'aggregateOrderPayCancel';
|
||||
}
|
||||
|
||||
/// 将插件消息转换为纯展示状态,副屏页面不直接依赖 GetX、主屏 Controller 或路由栈。
|
||||
class CustomerDisplayCoordinator extends ChangeNotifier {
|
||||
/// 创建副屏结算状态协调器;成功保留秒数可注入,以便测试完整清屏生命周期。
|
||||
CustomerDisplayCoordinator({
|
||||
CustomerDisplayState? initialState,
|
||||
this.qrCountdownSeconds = 300,
|
||||
this.successCountdownSeconds = 10,
|
||||
this.timerTick = const Duration(seconds: 1),
|
||||
}) : assert(qrCountdownSeconds > 0),
|
||||
assert(successCountdownSeconds > 0),
|
||||
assert(timerTick > Duration.zero),
|
||||
_state = initialState ?? CustomerDisplayState.idle();
|
||||
|
||||
/// 创建仅供主屏调试预览使用的副屏状态,不读取真实订单和顾客信息。
|
||||
factory CustomerDisplayCoordinator.demo(CustomerDisplayScenario scenario) {
|
||||
final state = CustomerDisplayState.demo(scenario);
|
||||
final coordinator = CustomerDisplayCoordinator(initialState: state);
|
||||
if (scenario == CustomerDisplayScenario.paying) {
|
||||
coordinator._startCountdown();
|
||||
}
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
/// 普通二维码支付的默认有效秒数。
|
||||
final int qrCountdownSeconds;
|
||||
|
||||
/// 支付成功后保留商品、金额和成功提示的秒数,倒计时结束后自动清屏。
|
||||
final int successCountdownSeconds;
|
||||
|
||||
/// 倒计时刷新间隔;生产环境为一秒,测试可注入更短间隔。
|
||||
final Duration timerTick;
|
||||
|
||||
CustomerDisplayState _state;
|
||||
Timer? _countdownTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
CustomerDisplayState get state => _state;
|
||||
|
||||
/// 无效、重复或乱序消息不会清空最近一次有效状态。
|
||||
bool handleAction(String action, [Object? arguments]) {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
if (action == _SubScreenProtocol.jumpSubBack) {
|
||||
// 支付后的旧返回消息可能由主屏三秒延迟任务发出,成功页必须完整保留十秒。
|
||||
if (_state.scenario == CustomerDisplayScenario.success) return false;
|
||||
_stopCountdown();
|
||||
return _replace(CustomerDisplayState.idle());
|
||||
}
|
||||
if (action == _SubScreenProtocol.jumpSubPage) {
|
||||
// 新订单尚未产生商品快照时,不能让跳页消息用上一单商品重新进入核对状态。
|
||||
if (_state.scenario == CustomerDisplayScenario.success) return false;
|
||||
_stopCountdown();
|
||||
// 业务入口会在购物车创建前发送跳页消息;没有商品上下文时继续待机,
|
||||
// 避免副屏提前露出空的结算框架。
|
||||
if (_state.products.isEmpty) return false;
|
||||
return _replace(_state.withScenario(CustomerDisplayScenario.review));
|
||||
}
|
||||
if (action == _SubScreenProtocol.updateCard) {
|
||||
final next = _parseProductState(arguments);
|
||||
if (next == null) return false;
|
||||
// 主屏支付后清空购物车会形成空快照;成功保留期只接受非空的新订单快照。
|
||||
if (_state.scenario == CustomerDisplayScenario.success &&
|
||||
next.products.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_stopCountdown();
|
||||
return _replace(next);
|
||||
}
|
||||
if (action == _SubScreenProtocol.qrCodeScan ||
|
||||
action == _SubScreenProtocol.aggregateOrderPay) {
|
||||
// 清屏后的迟到支付消息没有订单上下文,必须继续展示欢迎页,
|
||||
// 不能重新构造一个无商品、无金额的支付框架。
|
||||
if (_state.products.isEmpty) return false;
|
||||
final isAggregate = action == _SubScreenProtocol.aggregateOrderPay;
|
||||
final qrCodeData = _parseQrCode(
|
||||
arguments,
|
||||
requireAggregateProtocol: isAggregate,
|
||||
);
|
||||
if (qrCodeData == null) return false;
|
||||
final countdownSeconds = isAggregate
|
||||
? _parseAggregateCountdown(arguments)
|
||||
: qrCountdownSeconds;
|
||||
if (countdownSeconds == null) return false;
|
||||
// 插件可能重复投递同一支付消息,不能因此重置顾客正在观察的倒计时。
|
||||
if (_state.scenario == CustomerDisplayScenario.paying &&
|
||||
_state.qrCodeData == qrCodeData &&
|
||||
_state.countdownSeconds > 0) {
|
||||
return false;
|
||||
}
|
||||
final changed = _replace(
|
||||
_state.withScenario(
|
||||
CustomerDisplayScenario.paying,
|
||||
qrCodeData: qrCodeData,
|
||||
countdownSeconds: countdownSeconds,
|
||||
),
|
||||
);
|
||||
if (changed) _startCountdown();
|
||||
return changed;
|
||||
}
|
||||
if (action == _SubScreenProtocol.orderPaySuccess ||
|
||||
action == _SubScreenProtocol.qrCodeScanSuccess ||
|
||||
action == _SubScreenProtocol.aggregateOrderPaySuccess) {
|
||||
if (_state.scenario == CustomerDisplayScenario.success ||
|
||||
_state.products.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final acceptsReviewState = action == _SubScreenProtocol.orderPaySuccess;
|
||||
if (_state.scenario != CustomerDisplayScenario.paying &&
|
||||
(!acceptsReviewState ||
|
||||
_state.scenario != CustomerDisplayScenario.review)) {
|
||||
return false;
|
||||
}
|
||||
_stopCountdown();
|
||||
final changed = _replace(
|
||||
_state.withScenario(
|
||||
CustomerDisplayScenario.success,
|
||||
countdownSeconds: successCountdownSeconds,
|
||||
),
|
||||
);
|
||||
if (changed) _startCountdown();
|
||||
return changed;
|
||||
}
|
||||
if (action == _SubScreenProtocol.qrCodeScanCancel ||
|
||||
action == _SubScreenProtocol.aggregateOrderPayCancel) {
|
||||
if (_state.scenario != CustomerDisplayScenario.paying) return false;
|
||||
_stopCountdown();
|
||||
return _replace(_state.withScenario(CustomerDisplayScenario.cancelled));
|
||||
}
|
||||
} catch (_) {
|
||||
// 平台消息来自另一个 Engine,解析失败时保留最近有效状态即可,不能影响副屏渲染。
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
CustomerDisplayState? _parseProductState(Object? arguments) {
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return null;
|
||||
final rawProducts = data['shopList'];
|
||||
final totalPrice = _nonNegativeInt(data['totalPrice']);
|
||||
final amountMealPrice = _nonNegativeInt(data['amountMealPrice']);
|
||||
final businessTitle = _businessTitle(data['sysType']);
|
||||
if (rawProducts is! List ||
|
||||
totalPrice == null ||
|
||||
amountMealPrice == null ||
|
||||
businessTitle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final products = <CustomerDisplayProduct>[];
|
||||
for (final rawProduct in rawProducts) {
|
||||
final product = _parseProduct(rawProduct);
|
||||
if (product == null) return null;
|
||||
products.add(product);
|
||||
}
|
||||
|
||||
// 空购物车的有效快照等价于业务清屏,欢迎页不保留上一位顾客的信息。
|
||||
if (products.isEmpty) return CustomerDisplayState.idle();
|
||||
|
||||
String? customerName;
|
||||
final rawPayUser = data['payUser'];
|
||||
if (rawPayUser != null) {
|
||||
final payUser = _normalizeMap(rawPayUser);
|
||||
if (payUser == null) return null;
|
||||
final username = payUser['username'];
|
||||
if (username != null && username is! String) return null;
|
||||
customerName = username?.trim();
|
||||
}
|
||||
|
||||
return CustomerDisplayState(
|
||||
scenario: CustomerDisplayScenario.review,
|
||||
businessTitle: businessTitle,
|
||||
customerName: customerName,
|
||||
products: List.unmodifiable(products),
|
||||
totalPrice: totalPrice,
|
||||
amountMealPrice: amountMealPrice,
|
||||
);
|
||||
}
|
||||
|
||||
/// 将单个主屏购物车条目解析为副屏商品,并兼容旧协议缺失套餐字段的情况。
|
||||
CustomerDisplayProduct? _parseProduct(Object? rawProduct) {
|
||||
final data = _normalizeMap(rawProduct);
|
||||
if (data == null) return null;
|
||||
final name = data['productName'];
|
||||
final unitPrice = _nonNegativeInt(data['productPrice']);
|
||||
final totalPrice = _nonNegativeInt(data['totalPrice']);
|
||||
final nature = data['nature'];
|
||||
final rawImageSource = data['productPic'];
|
||||
if (name is! String ||
|
||||
name.trim().isEmpty ||
|
||||
unitPrice == null ||
|
||||
totalPrice == null ||
|
||||
nature is! String ||
|
||||
(rawImageSource != null && rawImageSource is! String)) {
|
||||
return null;
|
||||
}
|
||||
final trimmedImageSource = (rawImageSource as String?)?.trim();
|
||||
|
||||
String quantityLabel;
|
||||
if (nature == 'WEIGHT') {
|
||||
final weight = data['productWidget'];
|
||||
if (weight is! num || !weight.isFinite || weight < 0) return null;
|
||||
quantityLabel = '${weight.toDouble().toStringAsFixed(2)} kg';
|
||||
} else if (nature == 'DEFAULT') {
|
||||
final count = _nonNegativeInt(data['productNumber']);
|
||||
if (count == null) return null;
|
||||
quantityLabel = '$count 件';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawIsCombo = data['isCombo'];
|
||||
if (rawIsCombo != null && rawIsCombo is! bool) return null;
|
||||
final comboItems = <CustomerDisplayComboItem>[];
|
||||
final rawComboItems = data['comboItems'];
|
||||
if (rawComboItems != null) {
|
||||
if (rawComboItems is! List) return null;
|
||||
for (final rawComboItem in rawComboItems) {
|
||||
final comboItem = _parseComboItem(rawComboItem);
|
||||
if (comboItem == null) return null;
|
||||
comboItems.add(comboItem);
|
||||
}
|
||||
}
|
||||
|
||||
return CustomerDisplayProduct(
|
||||
name: name.trim(),
|
||||
quantityLabel: quantityLabel,
|
||||
unitPrice: unitPrice,
|
||||
totalPrice: totalPrice,
|
||||
imageSource:
|
||||
trimmedImageSource?.isEmpty == true ? null : trimmedImageSource,
|
||||
// 套餐组成非空时兼容主屏仅透传明细但缺失标识的过渡消息。
|
||||
isCombo: rawIsCombo == true || comboItems.isNotEmpty,
|
||||
comboItems: List.unmodifiable(comboItems),
|
||||
);
|
||||
}
|
||||
|
||||
/// 校验主屏透传的套餐组成;份数必须为正,避免副屏展示无意义的零份菜品。
|
||||
CustomerDisplayComboItem? _parseComboItem(Object? rawComboItem) {
|
||||
final data = _normalizeMap(rawComboItem);
|
||||
if (data == null) return null;
|
||||
final name = data['name'];
|
||||
final quantity = _nonNegativeInt(data['quantity']);
|
||||
if (name is! String ||
|
||||
name.trim().isEmpty ||
|
||||
quantity == null ||
|
||||
quantity == 0) {
|
||||
return null;
|
||||
}
|
||||
return CustomerDisplayComboItem(
|
||||
name: name.trim(),
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析二维码内容;聚合码校验独立协议,旧动作同时拒绝聚合协议内容反向混入。
|
||||
String? _parseQrCode(
|
||||
Object? arguments, {
|
||||
required bool requireAggregateProtocol,
|
||||
}) {
|
||||
final data = _normalizeMap(arguments);
|
||||
final value = data?['qrCodeData'];
|
||||
if (value is! String || value.trim().isEmpty) return null;
|
||||
if (requireAggregateProtocol) {
|
||||
if (data?['qrCodeType'] != 'AGGREGATE_ORDER_PAY' ||
|
||||
data?['aggregateNo'] is! String ||
|
||||
(data?['aggregateNo'] as String).trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
if (uri == null ||
|
||||
uri.scheme != 'https' ||
|
||||
!uri.path.contains('/aggregate-order-pay/cashier/')) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
if (data?['qrCodeType'] == 'AGGREGATE_ORDER_PAY' ||
|
||||
(uri?.path.contains('/aggregate-order-pay/cashier/') ?? false)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// 按服务端过期时间计算聚合码剩余秒数,拒绝过期或异常时间。
|
||||
int? _parseAggregateCountdown(Object? arguments) {
|
||||
final data = _normalizeMap(arguments);
|
||||
final rawExpireTime = data?['expireTime'];
|
||||
if (rawExpireTime is! String) return null;
|
||||
final expireTime = DateTime.tryParse(rawExpireTime);
|
||||
if (expireTime == null) return null;
|
||||
final milliseconds = expireTime.difference(DateTime.now()).inMilliseconds;
|
||||
if (milliseconds <= 0) return null;
|
||||
final seconds = (milliseconds / Duration.millisecondsPerSecond).ceil();
|
||||
return seconds.clamp(1, qrCountdownSeconds).toInt();
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _normalizeMap(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
try {
|
||||
final normalized = jsonDecode(jsonEncode(value));
|
||||
if (normalized is Map<String, dynamic>) return normalized;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
if (value is! num || !value.isFinite || value < 0) return null;
|
||||
if (value != value.truncateToDouble()) return null;
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
String? _businessTitle(Object? value) {
|
||||
return switch (value) {
|
||||
'SUPERMARKET' => '智慧超市',
|
||||
'CANTEEN' => '智慧食堂',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// 启动支付码或支付成功倒计时;支付码到期进入超时态,成功倒计时到期清除顾客数据。
|
||||
void _startCountdown() {
|
||||
_stopCountdown();
|
||||
final countdownScenario = _state.scenario;
|
||||
if (countdownScenario != CustomerDisplayScenario.paying &&
|
||||
countdownScenario != CustomerDisplayScenario.success) {
|
||||
return;
|
||||
}
|
||||
if (_state.countdownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
_countdownTimer = Timer.periodic(timerTick, (_) {
|
||||
if (_disposed || _state.scenario != countdownScenario) {
|
||||
_stopCountdown();
|
||||
return;
|
||||
}
|
||||
final nextSeconds = _state.countdownSeconds - 1;
|
||||
if (nextSeconds <= 0) {
|
||||
_stopCountdown();
|
||||
final nextState = countdownScenario == CustomerDisplayScenario.success
|
||||
? CustomerDisplayState.idle()
|
||||
: _state.withScenario(CustomerDisplayScenario.timeout);
|
||||
_replace(nextState);
|
||||
return;
|
||||
}
|
||||
_replace(
|
||||
_state.withScenario(
|
||||
countdownScenario,
|
||||
qrCodeData: countdownScenario == CustomerDisplayScenario.paying
|
||||
? _state.qrCodeData
|
||||
: null,
|
||||
countdownSeconds: nextSeconds,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止并释放当前支付码或成功保留计时器。
|
||||
void _stopCountdown() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = null;
|
||||
}
|
||||
|
||||
/// 仅在状态实际变化时通知副屏重建,返回值用于消息去重判断。
|
||||
bool _replace(CustomerDisplayState next) {
|
||||
if (_disposed || next == _state) return false;
|
||||
_state = next;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 释放协调器并取消仍在运行的倒计时,防止副屏 Engine 销毁后继续回调。
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopCountdown();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
|
||||
|
||||
enum FaceRecognitionDisplayTransition {
|
||||
none,
|
||||
show,
|
||||
restoreNow,
|
||||
restoreAfterSuccess
|
||||
}
|
||||
|
||||
class FaceRecognitionActionResult {
|
||||
const FaceRecognitionActionResult(this.accepted, this.transition);
|
||||
|
||||
static const ignored = FaceRecognitionActionResult(
|
||||
false,
|
||||
FaceRecognitionDisplayTransition.none,
|
||||
);
|
||||
|
||||
final bool accepted;
|
||||
final FaceRecognitionDisplayTransition transition;
|
||||
}
|
||||
|
||||
/// 副屏人脸状态只消费版本递增的当前 Session,避免上一位顾客的消息覆盖下一位。
|
||||
class FaceRecognitionCoordinator extends ChangeNotifier {
|
||||
static const _start = 'faceRecognitionStart';
|
||||
static const _detecting = 'faceRecognitionDetecting';
|
||||
static const _success = 'faceRecognitionSuccess';
|
||||
static const _failed = 'faceRecognitionFailed';
|
||||
static const _cancel = 'faceRecognitionCancel';
|
||||
|
||||
FaceRecognitionDisplayState? _state;
|
||||
bool _disposed = false;
|
||||
|
||||
FaceRecognitionDisplayState? get state => _state;
|
||||
|
||||
FaceRecognitionActionResult handleAction(String action, Object? arguments) {
|
||||
if (_disposed) return FaceRecognitionActionResult.ignored;
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return FaceRecognitionActionResult.ignored;
|
||||
final sessionId = data['sessionId'];
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
final message = data['message'];
|
||||
if (sessionId is! String || sessionId.trim().isEmpty || revision == null) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
|
||||
if (action == _start) {
|
||||
final scene = _parseScene(data['scene']);
|
||||
if (scene == null) return FaceRecognitionActionResult.ignored;
|
||||
final current = _state;
|
||||
if (current != null &&
|
||||
current.sessionId != sessionId &&
|
||||
current.status != FaceRecognitionDisplayStatus.success &&
|
||||
current.status != FaceRecognitionDisplayStatus.failed &&
|
||||
current.status != FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
// 取消后不允许同一会话的迟到开始消息重新打开副屏人脸页。
|
||||
if (current != null &&
|
||||
current.sessionId == sessionId &&
|
||||
current.status == FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
if (current != null &&
|
||||
current.sessionId == sessionId &&
|
||||
revision <= current.revision) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
_state = FaceRecognitionDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scene: scene,
|
||||
status: FaceRecognitionDisplayStatus.initializing,
|
||||
message: message is String && message.trim().isNotEmpty
|
||||
? message.trim()
|
||||
: '正在启动摄像头',
|
||||
payableAmount: _nonNegativeInt(data['payableAmount']),
|
||||
totalAmount: _nonNegativeInt(data['totalAmount']),
|
||||
discountAmount: _nonNegativeInt(data['discountAmount']),
|
||||
);
|
||||
notifyListeners();
|
||||
return const FaceRecognitionActionResult(
|
||||
true,
|
||||
FaceRecognitionDisplayTransition.show,
|
||||
);
|
||||
}
|
||||
|
||||
final current = _state;
|
||||
if (current == null ||
|
||||
current.sessionId != sessionId ||
|
||||
revision <= current.revision) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
// 取消是本会话的终态,原生取消过程中迟到的检测事件不得恢复视图。
|
||||
if (current.status == FaceRecognitionDisplayStatus.cancelled) {
|
||||
return FaceRecognitionActionResult.ignored;
|
||||
}
|
||||
final status = switch (action) {
|
||||
_detecting => FaceRecognitionDisplayStatus.detecting,
|
||||
_success => FaceRecognitionDisplayStatus.success,
|
||||
_failed => FaceRecognitionDisplayStatus.failed,
|
||||
_cancel => FaceRecognitionDisplayStatus.cancelled,
|
||||
_ => null,
|
||||
};
|
||||
if (status == null) return FaceRecognitionActionResult.ignored;
|
||||
_state = current.copyWith(
|
||||
revision: revision,
|
||||
status: status,
|
||||
message: message is String && message.trim().isNotEmpty
|
||||
? message.trim()
|
||||
: _defaultMessage(status),
|
||||
);
|
||||
notifyListeners();
|
||||
return FaceRecognitionActionResult(
|
||||
true,
|
||||
switch (status) {
|
||||
FaceRecognitionDisplayStatus.success =>
|
||||
FaceRecognitionDisplayTransition.restoreAfterSuccess,
|
||||
FaceRecognitionDisplayStatus.cancelled =>
|
||||
FaceRecognitionDisplayTransition.restoreNow,
|
||||
_ => FaceRecognitionDisplayTransition.show,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _defaultMessage(FaceRecognitionDisplayStatus status) {
|
||||
return switch (status) {
|
||||
FaceRecognitionDisplayStatus.initializing => '正在启动摄像头',
|
||||
FaceRecognitionDisplayStatus.detecting => '请正对摄像头',
|
||||
FaceRecognitionDisplayStatus.success => '识别成功,请稍候',
|
||||
FaceRecognitionDisplayStatus.failed => '识别未完成,请按主屏提示重试',
|
||||
FaceRecognitionDisplayStatus.cancelled => '已取消人脸识别',
|
||||
};
|
||||
}
|
||||
|
||||
FaceRecognitionDisplayScene? _parseScene(Object? value) {
|
||||
for (final scene in FaceRecognitionDisplayScene.values) {
|
||||
if (scene.name == value) return scene;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object?>? _normalizeMap(Object? value) {
|
||||
if (value is! Map) return null;
|
||||
return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
final parsed = value is int
|
||||
? value
|
||||
: value is num
|
||||
? value.toInt()
|
||||
: int.tryParse('$value');
|
||||
return parsed != null && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
library sub_utils;
|
||||
|
||||
export './customer_display_coordinator.dart';
|
||||
export './face_recognition_coordinator.dart';
|
||||
export './reservation_query_coordinator.dart';
|
||||
export './sub_display_coordinator.dart';
|
||||
export './sub_page_tool.dart';
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
|
||||
|
||||
/// 校验并维护预约查询副屏快照,拒绝重复、乱序、旧会话和格式错误消息。
|
||||
class ReservationQueryCoordinator extends ChangeNotifier {
|
||||
ReservationQueryCoordinator({
|
||||
ReservationQueryDisplayState? initialState,
|
||||
this.timerTick = const Duration(seconds: 1),
|
||||
DateTime Function()? now,
|
||||
}) : assert(timerTick > Duration.zero),
|
||||
_state = initialState ?? ReservationQueryDisplayState.idle(),
|
||||
_now = now ?? DateTime.now {
|
||||
if (_state.expiresAt != null) _startExpiryTimer();
|
||||
}
|
||||
|
||||
factory ReservationQueryCoordinator.demo(
|
||||
ReservationQueryDisplayScenario scenario,
|
||||
) {
|
||||
return ReservationQueryCoordinator(
|
||||
initialState: ReservationQueryDisplayState.demo(scenario),
|
||||
);
|
||||
}
|
||||
|
||||
final Duration timerTick;
|
||||
final DateTime Function() _now;
|
||||
|
||||
ReservationQueryDisplayState _state;
|
||||
Timer? _expiryTimer;
|
||||
String? _activeSessionId;
|
||||
int _lastRevision = -1;
|
||||
bool _disposed = false;
|
||||
|
||||
ReservationQueryDisplayState get state => _state;
|
||||
|
||||
bool handleUpdate(Object? arguments) {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return false;
|
||||
final next = _parseState(data);
|
||||
if (next == null || !_canAccept(next.sessionId, next.revision)) {
|
||||
return false;
|
||||
}
|
||||
_commitEnvelope(next.sessionId, next.revision);
|
||||
if (next.expiresAt != null && !next.expiresAt!.isAfter(_now())) {
|
||||
_stopExpiryTimer();
|
||||
return _replace(_privacyClearedState(next));
|
||||
}
|
||||
final changed = _replace(next);
|
||||
if (next.expiresAt == null) {
|
||||
_stopExpiryTimer();
|
||||
} else {
|
||||
_startExpiryTimer();
|
||||
}
|
||||
return changed;
|
||||
} catch (_) {
|
||||
// 副屏输入不可信,任何解析异常都只丢弃当前消息并保留最近有效快照。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool handleClear(Object? arguments) {
|
||||
if (_disposed) return false;
|
||||
final data = _normalizeMap(arguments);
|
||||
if (data == null) return false;
|
||||
final sessionId = _nonEmptyString(data['sessionId']);
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
if (sessionId == null ||
|
||||
revision == null ||
|
||||
!_canAccept(sessionId, revision)) {
|
||||
return false;
|
||||
}
|
||||
_commitEnvelope(sessionId, revision);
|
||||
_stopExpiryTimer();
|
||||
return _replace(
|
||||
ReservationQueryDisplayState.idle(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ReservationQueryDisplayState? _parseState(Map<String, dynamic> data) {
|
||||
final sessionId = _nonEmptyString(data['sessionId']);
|
||||
final revision = _nonNegativeInt(data['revision']);
|
||||
final scenarioName = _nonEmptyString(data['scenario']);
|
||||
final continuousFaceRecognition =
|
||||
_optionalBool(data['continuousFaceRecognition']);
|
||||
ReservationQueryDisplayScenario? scenario;
|
||||
for (final value in ReservationQueryDisplayScenario.values) {
|
||||
if (value.name == scenarioName) {
|
||||
scenario = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionId == null || revision == null || scenario == null) return null;
|
||||
|
||||
if (scenario == ReservationQueryDisplayScenario.idle ||
|
||||
scenario == ReservationQueryDisplayScenario.recognizing ||
|
||||
scenario == ReservationQueryDisplayScenario.loading ||
|
||||
scenario == ReservationQueryDisplayScenario.error) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario != ReservationQueryDisplayScenario.success &&
|
||||
scenario != ReservationQueryDisplayScenario.empty) {
|
||||
return null;
|
||||
}
|
||||
final maskedName = _nonEmptyString(data['maskedName']);
|
||||
final merchantName = _nonEmptyString(data['merchantName']);
|
||||
final deptName = _optionalString(data['deptName']);
|
||||
final totalDayCount = _nonNegativeInt(data['totalDayCount']);
|
||||
final totalMealCount = _nonNegativeInt(data['totalMealCount']);
|
||||
final currentPage = _positiveInt(data['currentPage']);
|
||||
final totalPages = _positiveInt(data['totalPages']);
|
||||
final expiresAtText = _nonEmptyString(data['expiresAt']);
|
||||
final expiresAt = DateTime.tryParse(expiresAtText ?? '');
|
||||
final rawDays = data['days'];
|
||||
if (maskedName == null ||
|
||||
merchantName == null ||
|
||||
totalDayCount == null ||
|
||||
totalMealCount == null ||
|
||||
currentPage == null ||
|
||||
totalPages == null ||
|
||||
currentPage > totalPages ||
|
||||
expiresAt == null ||
|
||||
rawDays is! List ||
|
||||
rawDays.length > 10) {
|
||||
return null;
|
||||
}
|
||||
final days = <ReservationMenuDayData>[];
|
||||
for (final rawDay in rawDays) {
|
||||
if (rawDay is! Map) return null;
|
||||
days.add(
|
||||
ReservationMenuDayData.fromJson(Map<String, dynamic>.from(rawDay)),
|
||||
);
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.success && days.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.empty && days.isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
final remaining = (expiresAt.difference(_now()).inMilliseconds / 1000)
|
||||
.ceil()
|
||||
.clamp(0, 1 << 30)
|
||||
.toInt();
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario,
|
||||
maskedName: maskedName,
|
||||
deptName: deptName,
|
||||
merchantName: merchantName,
|
||||
totalDayCount: totalDayCount,
|
||||
totalMealCount: totalMealCount,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
days: List.unmodifiable(days),
|
||||
expiresAt: expiresAt,
|
||||
remainingSeconds: remaining,
|
||||
continuousFaceRecognition: continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
bool _canAccept(String sessionId, int revision) {
|
||||
final active = _activeSessionId;
|
||||
if (active == null) return true;
|
||||
if (active == sessionId) return revision > _lastRevision;
|
||||
|
||||
// 主屏会话 ID 使用微秒时间戳,新会话必须大于当前会话,避免离页后的旧消息回流。
|
||||
final activeOrder = int.tryParse(active);
|
||||
final incomingOrder = int.tryParse(sessionId);
|
||||
if (activeOrder != null && incomingOrder != null) {
|
||||
return incomingOrder > activeOrder;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _commitEnvelope(String sessionId, int revision) {
|
||||
_activeSessionId = sessionId;
|
||||
_lastRevision = revision;
|
||||
}
|
||||
|
||||
void _startExpiryTimer() {
|
||||
_stopExpiryTimer();
|
||||
final expiresAt = _state.expiresAt;
|
||||
if (expiresAt == null) return;
|
||||
_expiryTimer = Timer.periodic(timerTick, (_) {
|
||||
if (_disposed) return;
|
||||
final remaining =
|
||||
(expiresAt.difference(_now()).inMilliseconds / 1000).ceil();
|
||||
if (remaining <= 0) {
|
||||
_stopExpiryTimer();
|
||||
_replace(_privacyClearedState(_state));
|
||||
return;
|
||||
}
|
||||
_replace(_state.copyWith(remainingSeconds: remaining));
|
||||
});
|
||||
}
|
||||
|
||||
void _stopExpiryTimer() {
|
||||
_expiryTimer?.cancel();
|
||||
_expiryTimer = null;
|
||||
}
|
||||
|
||||
bool _replace(ReservationQueryDisplayState next) {
|
||||
if (_disposed || next == _state) return false;
|
||||
_state = next;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _normalizeMap(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
try {
|
||||
final normalized = jsonDecode(jsonEncode(value));
|
||||
return normalized is Map<String, dynamic> ? normalized : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _nonEmptyString(Object? value) {
|
||||
if (value is! String || value.trim().isEmpty) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
String? _optionalString(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is! String) throw const FormatException('字符串字段格式错误');
|
||||
final normalized = value.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
|
||||
int? _nonNegativeInt(Object? value) {
|
||||
if (value is! num || !value.isFinite || value < 0) return null;
|
||||
if (value != value.truncateToDouble()) return null;
|
||||
return value.toInt();
|
||||
}
|
||||
|
||||
int? _positiveInt(Object? value) {
|
||||
final result = _nonNegativeInt(value);
|
||||
return result == null || result == 0 ? null : result;
|
||||
}
|
||||
|
||||
/// 读取可选布尔协议字段;旧主屏未发送时按关闭处理,错误类型直接拒绝整条消息。
|
||||
bool _optionalBool(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
throw const FormatException('布尔字段格式错误');
|
||||
}
|
||||
|
||||
/// 清除身份结果;持续人脸保留组合页并等待下一位,其他模式回到普通空闲态。
|
||||
ReservationQueryDisplayState _privacyClearedState(
|
||||
ReservationQueryDisplayState current,
|
||||
) {
|
||||
if (!current.continuousFaceRecognition) {
|
||||
return ReservationQueryDisplayState.idle(
|
||||
sessionId: current.sessionId,
|
||||
revision: current.revision,
|
||||
);
|
||||
}
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: current.sessionId,
|
||||
revision: current.revision,
|
||||
scenario: ReservationQueryDisplayScenario.recognizing,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopExpiryTimer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/customer_display_models.dart';
|
||||
import '../models/face_recognition_display_models.dart';
|
||||
import '../models/reservation_query_display_models.dart';
|
||||
import 'customer_display_coordinator.dart';
|
||||
import 'face_recognition_coordinator.dart';
|
||||
import 'reservation_query_coordinator.dart';
|
||||
|
||||
enum SubDisplayMode { welcome, settlement, reservationQuery, faceRecognition }
|
||||
|
||||
/// 副屏唯一消息入口,负责在欢迎、结算、预约查询和人脸页面之间切换。
|
||||
class SubDisplayCoordinator extends ChangeNotifier {
|
||||
SubDisplayCoordinator({
|
||||
CustomerDisplayCoordinator? customerCoordinator,
|
||||
ReservationQueryCoordinator? reservationCoordinator,
|
||||
FaceRecognitionCoordinator? faceRecognitionCoordinator,
|
||||
}) : customerCoordinator =
|
||||
customerCoordinator ?? CustomerDisplayCoordinator(),
|
||||
reservationCoordinator =
|
||||
reservationCoordinator ?? ReservationQueryCoordinator(),
|
||||
faceRecognitionCoordinator =
|
||||
faceRecognitionCoordinator ?? FaceRecognitionCoordinator() {
|
||||
_lastReservationScenario = this.reservationCoordinator.state.scenario;
|
||||
this.customerCoordinator.addListener(_notifyFromChild);
|
||||
this.reservationCoordinator.addListener(_notifyFromReservation);
|
||||
this.faceRecognitionCoordinator.addListener(_notifyFromChild);
|
||||
}
|
||||
|
||||
static const _reservationUpdate = 'reservationQueryUpdate';
|
||||
static const _reservationClear = 'reservationQueryClear';
|
||||
static const _faceStart = 'faceRecognitionStart';
|
||||
static const _updateCard = 'updateCard';
|
||||
static const _jumpSubPage = 'jumpSubPage';
|
||||
static const _jumpSubBack = 'jumpSubBack';
|
||||
static const _paymentActions = {
|
||||
'orderPaySuccess',
|
||||
'qrCodeScan',
|
||||
'qrCodeScanSuccess',
|
||||
'qrCodeScanCancel',
|
||||
'aggregateOrderPay',
|
||||
'aggregateOrderPaySuccess',
|
||||
'aggregateOrderPayCancel',
|
||||
};
|
||||
|
||||
final CustomerDisplayCoordinator customerCoordinator;
|
||||
final ReservationQueryCoordinator reservationCoordinator;
|
||||
final FaceRecognitionCoordinator faceRecognitionCoordinator;
|
||||
|
||||
SubDisplayMode _mode = SubDisplayMode.welcome;
|
||||
SubDisplayMode _modeBeforeFace = SubDisplayMode.welcome;
|
||||
Timer? _faceRestoreTimer;
|
||||
String? _reservationSessionAtFaceStart;
|
||||
int _reservationRevisionAtFaceStart = -1;
|
||||
bool _reservationSuccessDelayPassed = false;
|
||||
bool _reservationResultReady = false;
|
||||
bool _reservationClearedDuringFace = false;
|
||||
bool _reservationFaceHandoffActive = false;
|
||||
late ReservationQueryDisplayScenario _lastReservationScenario;
|
||||
bool _disposed = false;
|
||||
|
||||
SubDisplayMode get mode => _mode;
|
||||
|
||||
bool handleAction(String action, [Object? arguments]) {
|
||||
if (_disposed) return false;
|
||||
if (action.startsWith('faceRecognition')) {
|
||||
final result = faceRecognitionCoordinator.handleAction(action, arguments);
|
||||
if (!result.accepted) return false;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (_isContinuousReservationFace(faceState)) {
|
||||
// 持续人脸由预约组合页长期持有副屏 Texture,人脸动作只刷新状态和框,
|
||||
// 禁止再切换到旧的独立人脸页面或等待三秒恢复计时器。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_modeBeforeFace = SubDisplayMode.reservationQuery;
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
return true;
|
||||
}
|
||||
switch (result.transition) {
|
||||
case FaceRecognitionDisplayTransition.show:
|
||||
_faceRestoreTimer?.cancel();
|
||||
if (action == _faceStart) {
|
||||
_resetReservationFaceHandoff();
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
_beginReservationFaceHandoff();
|
||||
}
|
||||
}
|
||||
if (_mode != SubDisplayMode.faceRecognition) _modeBeforeFace = _mode;
|
||||
_replaceMode(SubDisplayMode.faceRecognition);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreNow:
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_replaceMode(_modeBeforeFace);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreAfterSuccess:
|
||||
_faceRestoreTimer?.cancel();
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
if (_reservationClearedDuringFace) {
|
||||
_replaceMode(SubDisplayMode.welcome);
|
||||
_resetReservationFaceHandoff();
|
||||
break;
|
||||
}
|
||||
// 预约查询必须在人脸成功提示展示满 1 秒且查询拿到最终结果后再恢复,
|
||||
// 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = _hasNewReservationTerminalState();
|
||||
_faceRestoreTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (_disposed) return;
|
||||
_reservationSuccessDelayPassed = true;
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
});
|
||||
} else {
|
||||
_resetReservationFaceHandoff();
|
||||
_faceRestoreTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (!_disposed) _replaceMode(_modeBeforeFace);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.none:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action == _reservationUpdate) {
|
||||
final accepted = reservationCoordinator.handleUpdate(arguments);
|
||||
if (accepted) {
|
||||
_reservationClearedDuringFace = false;
|
||||
if (reservationCoordinator.state.continuousFaceRecognition) {
|
||||
// 该快照必须先于原生 faceStart 落地;即使消息顺序偶发反转,也强制恢复组合页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_modeBeforeFace = SubDisplayMode.reservationQuery;
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
} else {
|
||||
_showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
_handleReservationUpdateDuringFace();
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (action == _reservationClear) {
|
||||
final accepted = reservationCoordinator.handleClear(arguments);
|
||||
if (accepted) {
|
||||
_showOrRememberMode(SubDisplayMode.welcome);
|
||||
_handleReservationClearDuringFace();
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (action == _jumpSubBack) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (action == _jumpSubPage) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (action == _updateCard) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
if (_paymentActions.contains(action)) {
|
||||
final changed = customerCoordinator.handleAction(action, arguments);
|
||||
final target = customerCoordinator.state.products.isEmpty
|
||||
? SubDisplayMode.welcome
|
||||
: SubDisplayMode.settlement;
|
||||
final modeChanged = _showOrRememberMode(target);
|
||||
return changed || modeChanged;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _replaceMode(SubDisplayMode next) {
|
||||
if (_mode == next) return false;
|
||||
_mode = next;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 判断当前人脸动作是否属于已启用组合页的预约持续识别会话。
|
||||
bool _isContinuousReservationFace(FaceRecognitionDisplayState? faceState) {
|
||||
return faceState?.scene == FaceRecognitionDisplayScene.reservation &&
|
||||
reservationCoordinator.state.continuousFaceRecognition;
|
||||
}
|
||||
|
||||
void _beginReservationFaceHandoff() {
|
||||
final reservationState = reservationCoordinator.state;
|
||||
_reservationSessionAtFaceStart = reservationState.sessionId;
|
||||
_reservationRevisionAtFaceStart = reservationState.revision;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
_reservationFaceHandoffActive = true;
|
||||
}
|
||||
|
||||
void _handleReservationUpdateDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
if (!_hasNewReservationTerminalState()) return;
|
||||
_reservationResultReady = true;
|
||||
if (faceState?.status == FaceRecognitionDisplayStatus.failed) {
|
||||
// 可重试的人脸失败不会产生预约结果;只有业务已收口为 error 时才离开人脸页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_restoreReservationAfterFace();
|
||||
return;
|
||||
}
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
}
|
||||
|
||||
void _handleReservationClearDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
_resetReservationFaceHandoff(preserveClear: true);
|
||||
_reservationClearedDuringFace = true;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
// 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_replaceMode(SubDisplayMode.welcome);
|
||||
_resetReservationFaceHandoff();
|
||||
}
|
||||
|
||||
bool _hasNewReservationTerminalState() {
|
||||
if (!_reservationFaceHandoffActive) return false;
|
||||
final state = reservationCoordinator.state;
|
||||
final isNewer = state.sessionId != _reservationSessionAtFaceStart ||
|
||||
state.revision > _reservationRevisionAtFaceStart;
|
||||
if (!isNewer) return false;
|
||||
return state.scenario == ReservationQueryDisplayScenario.success ||
|
||||
state.scenario == ReservationQueryDisplayScenario.empty ||
|
||||
state.scenario == ReservationQueryDisplayScenario.error;
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFaceIfReady() {
|
||||
if (!_reservationSuccessDelayPassed || !_reservationResultReady) return;
|
||||
_restoreReservationAfterFace();
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFace() {
|
||||
_faceRestoreTimer?.cancel();
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
_resetReservationFaceHandoff();
|
||||
}
|
||||
|
||||
void _resetReservationFaceHandoff({bool preserveClear = false}) {
|
||||
_reservationSessionAtFaceStart = null;
|
||||
_reservationRevisionAtFaceStart = -1;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
_reservationFaceHandoffActive = false;
|
||||
if (!preserveClear) _reservationClearedDuringFace = false;
|
||||
}
|
||||
|
||||
/// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。
|
||||
/// 这样 success 的 3 秒提示和 failed 的重试引导不会被金额或查询消息提前顶掉。
|
||||
bool _showOrRememberMode(SubDisplayMode next) {
|
||||
if (_mode == SubDisplayMode.faceRecognition) {
|
||||
final changed = _modeBeforeFace != next;
|
||||
_modeBeforeFace = next;
|
||||
return changed;
|
||||
}
|
||||
return _replaceMode(next);
|
||||
}
|
||||
|
||||
/// 转发子协调器刷新,并在支付成功倒计时清空商品后同步恢复欢迎模式。
|
||||
void _notifyFromChild() {
|
||||
if (_disposed) return;
|
||||
final customerState = customerCoordinator.state;
|
||||
final customerCleared = customerState.products.isEmpty &&
|
||||
customerState.scenario == CustomerDisplayScenario.idle &&
|
||||
(_mode == SubDisplayMode.settlement ||
|
||||
_modeBeforeFace == SubDisplayMode.settlement);
|
||||
if (customerCleared) {
|
||||
final modeChanged = _showOrRememberMode(SubDisplayMode.welcome);
|
||||
if (!modeChanged) notifyListeners();
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _notifyFromReservation() {
|
||||
if (_disposed) return;
|
||||
final previous = _lastReservationScenario;
|
||||
final current = reservationCoordinator.state.scenario;
|
||||
_lastReservationScenario = current;
|
||||
|
||||
if (previous != ReservationQueryDisplayScenario.idle &&
|
||||
current == ReservationQueryDisplayScenario.idle) {
|
||||
final modeBeforeTransition = _mode;
|
||||
// 预约结果可由副屏自己的隐私计时器清空;顶层必须同步回欢迎页,
|
||||
// 否则 Coordinator 已无身份数据但仍会停留在预约查询框架。
|
||||
_showOrRememberMode(SubDisplayMode.welcome);
|
||||
_handleReservationClearDuringFace();
|
||||
if (_mode == modeBeforeTransition) notifyListeners();
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
customerCoordinator.removeListener(_notifyFromChild);
|
||||
reservationCoordinator.removeListener(_notifyFromReservation);
|
||||
faceRecognitionCoordinator.removeListener(_notifyFromChild);
|
||||
customerCoordinator.dispose();
|
||||
reservationCoordinator.dispose();
|
||||
faceRecognitionCoordinator.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,659 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
|
||||
/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局和时钟生命周期。
|
||||
class CustomerWelcomePage extends StatefulWidget {
|
||||
const CustomerWelcomePage({
|
||||
super.key,
|
||||
this.previewMode = false,
|
||||
this.onExit,
|
||||
this.now,
|
||||
this.clockTick = const Duration(minutes: 1),
|
||||
});
|
||||
|
||||
final bool previewMode;
|
||||
final VoidCallback? onExit;
|
||||
final DateTime Function()? now;
|
||||
final Duration clockTick;
|
||||
|
||||
@override
|
||||
State<CustomerWelcomePage> createState() => _CustomerWelcomePageState();
|
||||
}
|
||||
|
||||
class _CustomerWelcomePageState extends State<CustomerWelcomePage> {
|
||||
late DateTime _currentTime;
|
||||
Timer? _clockTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentTime = _readNow();
|
||||
_startClock();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomerWelcomePage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.clockTick != widget.clockTick ||
|
||||
oldWidget.now != widget.now) {
|
||||
_currentTime = _readNow();
|
||||
_startClock();
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _readNow() => widget.now?.call() ?? DateTime.now();
|
||||
|
||||
void _startClock() {
|
||||
_clockTimer?.cancel();
|
||||
final tick = widget.clockTick > Duration.zero
|
||||
? widget.clockTick
|
||||
: const Duration(minutes: 1);
|
||||
_clockTimer = Timer.periodic(tick, (_) {
|
||||
if (mounted) setState(() => _currentTime = _readNow());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clockTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: XfjDesignTokens.canvas,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _CustomerWelcomeContent(currentTime: _currentTime),
|
||||
),
|
||||
if (widget.previewMode)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
child: _PreviewExitButton(onTap: widget.onExit),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomerWelcomeContent extends StatelessWidget {
|
||||
const _CustomerWelcomeContent({required this.currentTime});
|
||||
|
||||
final DateTime currentTime;
|
||||
|
||||
@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: const ValueKey('customer-welcome-page'),
|
||||
color: XfjDesignTokens.canvas,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_WelcomeHeader(currentTime: currentTime, scale: scale),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24 * scale,
|
||||
0,
|
||||
24 * scale,
|
||||
24 * scale,
|
||||
),
|
||||
child: _WelcomeHero(scale: scale),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeHeader extends StatelessWidget {
|
||||
const _WelcomeHeader({required this.currentTime, required this.scale});
|
||||
|
||||
final DateTime currentTime;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hour = currentTime.hour.toString().padLeft(2, '0');
|
||||
final minute = currentTime.minute.toString().padLeft(2, '0');
|
||||
return SizedBox(
|
||||
height: 80 * scale,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32 * scale),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'ZAX',
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF0865E8),
|
||||
fontSize: 36 * scale,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: -2 * scale,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20 * scale),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 36 * scale,
|
||||
color: XfjDesignTokens.line,
|
||||
),
|
||||
SizedBox(width: 20 * scale),
|
||||
Text(
|
||||
'欢迎光临',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 24 * scale,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2 * scale,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 28 * scale),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16 * scale,
|
||||
vertical: 9 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
border: Border.all(
|
||||
color: XfjDesignTokens.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Text(
|
||||
'智慧收银',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_ConnectionStatus(scale: scale),
|
||||
SizedBox(width: 20 * scale),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28 * scale,
|
||||
color: XfjDesignTokens.line,
|
||||
),
|
||||
SizedBox(width: 20 * scale),
|
||||
Text(
|
||||
'$hour:$minute',
|
||||
key: const ValueKey('customer-welcome-clock'),
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 20 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionStatus extends StatelessWidget {
|
||||
const _ConnectionStatus({required this.scale});
|
||||
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: '设备已连接',
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10 * scale,
|
||||
height: 10 * scale,
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.success,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: XfjDesignTokens.success.withValues(alpha: 0.12),
|
||||
spreadRadius: 5 * scale,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10 * scale),
|
||||
Text(
|
||||
'设备已连接',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.success,
|
||||
fontSize: 18 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeHero extends StatelessWidget {
|
||||
const _WelcomeHero({required this.scale});
|
||||
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(24 * scale);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.surface,
|
||||
border: Border.all(color: XfjDesignTokens.line),
|
||||
borderRadius: radius,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14111827),
|
||||
blurRadius: 56,
|
||||
offset: Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_WelcomeAssetImage(
|
||||
key: const ValueKey('customer-welcome-hero'),
|
||||
asset:
|
||||
'assets/images/customer_welcome/abstract-welcome-hero.webp',
|
||||
semanticsLabel: '蓝色、薄荷绿与暖黄色卡通伙伴挥手欢迎',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
left: 32 * scale,
|
||||
top: 32 * scale,
|
||||
child: _ReadyCard(scale: scale),
|
||||
),
|
||||
Positioned(
|
||||
right: 32 * scale,
|
||||
top: 32 * scale,
|
||||
child: _SyncBadge(scale: scale),
|
||||
),
|
||||
Positioned(
|
||||
left: 32 * scale,
|
||||
bottom: 32 * scale,
|
||||
child: Row(
|
||||
children: [
|
||||
_WelcomeInfoCard(
|
||||
imageKey: const ValueKey('customer-welcome-flow'),
|
||||
asset: 'assets/images/customer_welcome/abstract-flow.webp',
|
||||
semanticsLabel: '卡通伙伴与空白卡片组成的轻量信息流插图',
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
title: '内容实时更新',
|
||||
detail: '新的信息会自动显示',
|
||||
scale: scale,
|
||||
),
|
||||
SizedBox(width: 16 * scale),
|
||||
_WelcomeInfoCard(
|
||||
imageKey: const ValueKey('customer-welcome-care'),
|
||||
asset: 'assets/images/customer_welcome/abstract-care.webp',
|
||||
semanticsLabel: '卡通伙伴拥抱爱心盾牌的安心守护插图',
|
||||
icon: Icons.shield_outlined,
|
||||
title: '安心使用',
|
||||
detail: '个人信息会妥善保护',
|
||||
scale: scale,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadyCard extends StatelessWidget {
|
||||
const _ReadyCard({required this.scale});
|
||||
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 504 * scale,
|
||||
padding: EdgeInsets.all(24 * scale),
|
||||
decoration: _floatingDecoration(radius: 20 * scale),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: XfjDesignTokens.success,
|
||||
size: 19 * scale,
|
||||
),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
'终端服务已就绪',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.success,
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16 * scale),
|
||||
Text(
|
||||
'欢迎使用',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 48 * scale,
|
||||
height: 1.05,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1.7 * scale,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
Text(
|
||||
'请稍候,接下来的内容会在这里清晰展示。',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: 18 * scale,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20 * scale),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16 * scale,
|
||||
vertical: 12 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10 * scale,
|
||||
height: 10 * scale,
|
||||
decoration: const BoxDecoration(
|
||||
color: XfjDesignTokens.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12 * scale),
|
||||
Text(
|
||||
'正在等待服务开始',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncBadge extends StatelessWidget {
|
||||
const _SyncBadge({required this.scale});
|
||||
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16 * scale,
|
||||
vertical: 10 * scale,
|
||||
),
|
||||
decoration: _floatingDecoration(
|
||||
radius: 24 * scale,
|
||||
shadow: const BoxShadow(
|
||||
color: Color(0x0F111827),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wifi_rounded,
|
||||
color: XfjDesignTokens.success,
|
||||
size: 19 * scale,
|
||||
),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
'内容将自动同步',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.success,
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeInfoCard extends StatelessWidget {
|
||||
const _WelcomeInfoCard({
|
||||
required this.imageKey,
|
||||
required this.asset,
|
||||
required this.semanticsLabel,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.detail,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
final Key imageKey;
|
||||
final String asset;
|
||||
final String semanticsLabel;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String detail;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(16 * scale);
|
||||
return Container(
|
||||
width: 248 * scale,
|
||||
decoration: _floatingDecoration(radius: 16 * scale),
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 120 * scale,
|
||||
child: _WelcomeAssetImage(
|
||||
key: imageKey,
|
||||
asset: asset,
|
||||
semanticsLabel: semanticsLabel,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: XfjDesignTokens.surface,
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36 * scale,
|
||||
height: 36 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(10 * scale),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 20 * scale,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12 * scale),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4 * scale),
|
||||
Text(
|
||||
detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: 12 * scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeAssetImage extends StatelessWidget {
|
||||
const _WelcomeAssetImage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.semanticsLabel,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
final String asset;
|
||||
final String semanticsLabel;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image.asset(
|
||||
asset,
|
||||
fit: fit,
|
||||
semanticLabel: semanticsLabel,
|
||||
errorBuilder: (context, error, stackTrace) => ColoredBox(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 40,
|
||||
semanticLabel: '欢迎插图暂不可用',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewExitButton extends StatelessWidget {
|
||||
const _PreviewExitButton({required this.onTap});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '退出欢迎副屏预览',
|
||||
child: Material(
|
||||
color: const Color(0xD9111827),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxDecoration _floatingDecoration({
|
||||
required double radius,
|
||||
BoxShadow shadow = const BoxShadow(
|
||||
color: Color(0x1F111827),
|
||||
blurRadius: 48,
|
||||
offset: Offset(0, 18),
|
||||
),
|
||||
}) {
|
||||
return BoxDecoration(
|
||||
color: const Color(0xF0FFFFFF),
|
||||
border: Border.all(color: const Color(0xE6FFFFFF)),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
boxShadow: [shadow],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart';
|
||||
import 'package:pj_supermarket_cloud/common/widgets/face/face_recognition_preview_layer.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/utils/face_recognition_coordinator.dart';
|
||||
|
||||
/// 顾客副屏人脸页面。视频由副屏 Engine 自己申请 Texture,状态消息不承载任何视频帧或身份数据。
|
||||
class FaceRecognitionCustomerPage extends StatefulWidget {
|
||||
const FaceRecognitionCustomerPage({
|
||||
super.key,
|
||||
required this.coordinator,
|
||||
});
|
||||
|
||||
final FaceRecognitionCoordinator coordinator;
|
||||
|
||||
@override
|
||||
State<FaceRecognitionCustomerPage> createState() =>
|
||||
_FaceRecognitionCustomerPageState();
|
||||
}
|
||||
|
||||
class _FaceRecognitionCustomerPageState
|
||||
extends State<FaceRecognitionCustomerPage> {
|
||||
FaceRecognitionPreviewTexture? _texture;
|
||||
FaceRecognitionSessionEvent? _nativeEvent;
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _eventSubscription;
|
||||
String? _previewError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = FaceDiscernPlugs.sessionStream.listen((event) {
|
||||
final state = widget.coordinator.state;
|
||||
if (!mounted || state == null || event.sessionId != state.sessionId) {
|
||||
return;
|
||||
}
|
||||
final shouldReplace =
|
||||
_nativeEvent?.status == FaceRecognitionSessionStatus.failed &&
|
||||
(event.status == FaceRecognitionSessionStatus.initializing ||
|
||||
event.status == FaceRecognitionSessionStatus.detecting);
|
||||
setState(() => _nativeEvent = event);
|
||||
if (shouldReplace) {
|
||||
unawaited(_initializeTexture(replace: true));
|
||||
}
|
||||
});
|
||||
unawaited(_initializeTexture());
|
||||
}
|
||||
|
||||
Future<void> _initializeTexture({bool replace = false}) async {
|
||||
try {
|
||||
if (replace) {
|
||||
final current = _texture;
|
||||
_texture = null;
|
||||
if (current != null) {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(current.textureId);
|
||||
}
|
||||
}
|
||||
final texture = await FaceDiscernPlugs.createPreviewTexture();
|
||||
if (!mounted) {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(texture.textureId);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_texture = texture;
|
||||
_previewError = null;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _previewError = '摄像头画面暂不可用,请按主屏提示操作');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
final texture = _texture;
|
||||
if (texture != null) {
|
||||
unawaited(FaceDiscernPlugs.releasePreviewTexture(texture.textureId));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.coordinator,
|
||||
builder: (context, _) {
|
||||
final state = widget.coordinator.state;
|
||||
if (state == null) return const SizedBox.shrink();
|
||||
return Scaffold(
|
||||
backgroundColor: XfjDesignTokens.canvas,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scale = math
|
||||
.min(
|
||||
constraints.maxWidth / 1920, constraints.maxHeight / 1080)
|
||||
.clamp(0.55, 1.12)
|
||||
.toDouble();
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24 * scale),
|
||||
child: Column(
|
||||
children: [
|
||||
_FaceHeader(state: state, scale: scale),
|
||||
SizedBox(height: 16 * scale),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 13,
|
||||
child: _PreviewPanel(
|
||||
texture: _texture,
|
||||
nativeEvent: _nativeEvent,
|
||||
error: _previewError,
|
||||
scale: scale,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16 * scale),
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: _GuidePanel(state: state, scale: scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FaceHeader extends StatelessWidget {
|
||||
const _FaceHeader({required this.state, required this.scale});
|
||||
|
||||
final FaceRecognitionDisplayState state;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reservation = state.scene == FaceRecognitionDisplayScene.reservation;
|
||||
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,
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primary,
|
||||
borderRadius: BorderRadius.circular(13 * scale),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.face_retouching_natural_rounded,
|
||||
color: Colors.white,
|
||||
size: 28 * scale,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14 * scale),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
reservation ? '本人预约查询' : '人脸支付',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 24 * scale,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
reservation ? '仅用于查询当前顾客本人的预约信息' : '请顾客面向摄像头完成身份识别',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: 14 * scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
_StatusPill(status: state.status, scale: scale),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewPanel extends StatelessWidget {
|
||||
const _PreviewPanel({
|
||||
required this.texture,
|
||||
required this.nativeEvent,
|
||||
required this.error,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
final FaceRecognitionPreviewTexture? texture;
|
||||
final FaceRecognitionSessionEvent? nativeEvent;
|
||||
final String? error;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF102635),
|
||||
borderRadius: BorderRadius.circular(16 * scale),
|
||||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (texture != null)
|
||||
FaceRecognitionPreviewLayer(
|
||||
texture: texture!,
|
||||
event: nativeEvent,
|
||||
labelScale: scale,
|
||||
)
|
||||
else
|
||||
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||
if (error != null)
|
||||
Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(18 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.68),
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Text(
|
||||
error!,
|
||||
style: TextStyle(color: Colors.white, fontSize: 18 * scale),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 18 * scale,
|
||||
bottom: 18 * scale,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14 * scale,
|
||||
vertical: 9 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(10 * scale),
|
||||
),
|
||||
child: Text(
|
||||
'实时画面仅用于本次识别,不在副屏展示身份信息',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14 * scale),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GuidePanel extends StatelessWidget {
|
||||
const _GuidePanel({required this.state, required this.scale});
|
||||
|
||||
final FaceRecognitionDisplayState state;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _statusColor(state.status);
|
||||
final reservation = state.scene == FaceRecognitionDisplayScene.reservation;
|
||||
return Container(
|
||||
padding: EdgeInsets.all(28 * scale),
|
||||
decoration: _surfaceDecoration(16 * scale),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 78 * scale,
|
||||
height: 78 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child:
|
||||
Icon(_statusIcon(state.status), color: color, size: 44 * scale),
|
||||
),
|
||||
SizedBox(height: 22 * scale),
|
||||
Text(
|
||||
_statusTitle(state.status),
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 34 * scale,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10 * scale),
|
||||
Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: 20 * scale,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 28 * scale),
|
||||
_GuideItem(number: '1', text: '保持距离约 50-80cm', scale: scale),
|
||||
SizedBox(height: 12 * scale),
|
||||
_GuideItem(number: '2', text: '面部完整出现在蓝色识别框内', scale: scale),
|
||||
SizedBox(height: 12 * scale),
|
||||
_GuideItem(number: '3', text: '请勿快速转头或遮挡面部', scale: scale),
|
||||
const Spacer(),
|
||||
if (!reservation && state.payableAmount != null)
|
||||
_AmountCard(state: state, scale: scale)
|
||||
else
|
||||
Container(
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.control,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Text(
|
||||
reservation ? '识别成功后将自动查询本人预约菜单' : '识别成功后请继续留意主屏支付结果',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GuideItem extends StatelessWidget {
|
||||
const _GuideItem(
|
||||
{required this.number, required this.text, required this.scale});
|
||||
|
||||
final String number;
|
||||
final String text;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34 * scale,
|
||||
height: 34 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
number,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12 * scale),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: 17 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountCard extends StatelessWidget {
|
||||
const _AmountCard({required this.state, required this.scale});
|
||||
|
||||
final FaceRecognitionDisplayState state;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(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: [
|
||||
Text(
|
||||
'应付金额',
|
||||
style:
|
||||
TextStyle(color: XfjDesignTokens.muted, fontSize: 17 * scale),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_money(state.payableAmount!),
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontSize: 30 * scale,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
const _StatusPill({required this.status, required this.scale});
|
||||
|
||||
final FaceRecognitionDisplayStatus status;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _statusColor(status);
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: 48 * scale),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.09),
|
||||
border: Border.all(color: color.withValues(alpha: 0.24)),
|
||||
borderRadius: BorderRadius.circular(24 * scale),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_statusIcon(status), color: color, size: 22 * scale),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
_statusTitle(status),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxDecoration _surfaceDecoration(double radius) => BoxDecoration(
|
||||
color: XfjDesignTokens.surface,
|
||||
border: Border.all(color: XfjDesignTokens.line),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||||
);
|
||||
|
||||
Color _statusColor(FaceRecognitionDisplayStatus status) => switch (status) {
|
||||
FaceRecognitionDisplayStatus.success => XfjDesignTokens.success,
|
||||
FaceRecognitionDisplayStatus.failed ||
|
||||
FaceRecognitionDisplayStatus.cancelled =>
|
||||
XfjDesignTokens.danger,
|
||||
_ => XfjDesignTokens.primary,
|
||||
};
|
||||
|
||||
IconData _statusIcon(FaceRecognitionDisplayStatus status) => switch (status) {
|
||||
FaceRecognitionDisplayStatus.success => Icons.check_circle_rounded,
|
||||
FaceRecognitionDisplayStatus.failed => Icons.error_outline_rounded,
|
||||
FaceRecognitionDisplayStatus.cancelled => Icons.cancel_outlined,
|
||||
FaceRecognitionDisplayStatus.initializing => Icons.videocam_rounded,
|
||||
FaceRecognitionDisplayStatus.detecting =>
|
||||
Icons.face_retouching_natural_rounded,
|
||||
};
|
||||
|
||||
String _statusTitle(FaceRecognitionDisplayStatus status) => switch (status) {
|
||||
FaceRecognitionDisplayStatus.initializing => '摄像头启动中',
|
||||
FaceRecognitionDisplayStatus.detecting => '正在识别',
|
||||
FaceRecognitionDisplayStatus.success => '识别成功',
|
||||
FaceRecognitionDisplayStatus.failed => '需要重试',
|
||||
FaceRecognitionDisplayStatus.cancelled => '识别已取消',
|
||||
};
|
||||
|
||||
String _money(int fen) => '¥ ${(fen / 100).toStringAsFixed(2)}';
|
||||
@@ -1,4 +1,8 @@
|
||||
library sub_page;
|
||||
|
||||
export './customer_display.dart';
|
||||
export './customer_welcome.dart';
|
||||
export './face_recognition_customer_page.dart';
|
||||
export './reservation_query_display.dart';
|
||||
export './sub_app.dart';
|
||||
export './sub_product.dart';
|
||||
@@ -0,0 +1,725 @@
|
||||
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(
|
||||
// 字号保底后两行文字在小尺寸副屏上会超过按比例缩放的 82 高,
|
||||
// 与首页副屏一致给头部一个最低高度,避免标题和副标题被裁切。
|
||||
height: math.max(82 * scale, 56),
|
||||
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: _fontSize(28, scale),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14 * scale),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'预约查询',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(28, scale),
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
state.merchantName ?? '请使用饭卡或人脸识别本人预约',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(18, scale),
|
||||
height: 1.3,
|
||||
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: _fontSize(21, scale)),
|
||||
SizedBox(width: 7 * scale),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: _fontSize(18, 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: _fontSize(36, scale),
|
||||
),
|
||||
<Widget>[
|
||||
Text(
|
||||
presentation.title,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(28, scale),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5 * scale),
|
||||
Text(
|
||||
countdown > 0
|
||||
? '${presentation.detail},$countdown 秒后识别下一位'
|
||||
: presentation.detail,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(18, 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: _fontSize(22, scale),
|
||||
),
|
||||
Text(
|
||||
'实时画面仅用于本人识别,不保存影像',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(18, 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: _fontSize(20, 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: _fontSize(22, scale)),
|
||||
SizedBox(width: 7 * scale),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(20, 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: 120 * scale,
|
||||
height: 120 * scale,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: state.scenario == ReservationQueryDisplayScenario.loading
|
||||
? SizedBox(
|
||||
width: 52 * scale,
|
||||
height: 52 * scale,
|
||||
child: CircularProgressIndicator(
|
||||
color: color,
|
||||
strokeWidth: 6 * scale,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_scenarioIcon(state.scenario),
|
||||
color: color,
|
||||
size: _fontSize(60, scale),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 26 * scale),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontSize: _fontSize(38, scale),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(21, 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: _fontSize(21, scale),
|
||||
),
|
||||
SizedBox(width: 8 * scale),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.continuousFaceRecognition
|
||||
? '持续识别已开启,仅展示当前用户本人的有效预约'
|
||||
: '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: _fontSize(18, scale),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.remainingSeconds > 0) ...[
|
||||
Icon(
|
||||
Icons.timer_outlined,
|
||||
color: XfjDesignTokens.warning,
|
||||
size: _fontSize(21, scale),
|
||||
),
|
||||
SizedBox(width: 6 * scale),
|
||||
Text(
|
||||
'${state.remainingSeconds} 秒后清屏',
|
||||
key: const ValueKey('reservation-privacy-countdown'),
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.warning,
|
||||
fontSize: _fontSize(20, 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 将文字缩放与布局缩放分离,小尺寸副屏仍保留远距离可读的最低字号。
|
||||
///
|
||||
/// 与首页副屏(customer_display)保持同一策略:布局元素继续按 [scale] 收缩,
|
||||
/// 文字和成对图标的字号下限为 0.9,避免小分辨率副屏上文字缩到无法辨认。
|
||||
double _fontSize(double designSize, double scale) {
|
||||
return designSize * math.max(scale, 0.9);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/index.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/index.dart';
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/customer_display.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/face_recognition_customer_page.dart';
|
||||
import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_display.dart';
|
||||
|
||||
class SubAppPage extends StatefulWidget {
|
||||
const SubAppPage({super.key});
|
||||
@@ -17,56 +19,59 @@ class SubAppPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SubAppPageState extends State<SubAppPage> {
|
||||
late StreamSubscription _streamSubscription;
|
||||
late final SubDisplayCoordinator _coordinator;
|
||||
late final StreamSubscription _streamSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_streamSubscription = SubScreenPlugin.viceStream.listen((event) async {
|
||||
final action = SubScreenAction.values.fromCode(event.method);
|
||||
if (action == null) return;
|
||||
SubPageToool.switchJump(action, arguments: event.arguments);
|
||||
if (action == SubScreenAction.updateCard &&
|
||||
SubNavigatorKey.currentRouteName != SubRouteNames.subProduct) {
|
||||
SubPageToool.switchJump(
|
||||
SubScreenAction.jumpSubPage,
|
||||
arguments: event.arguments,
|
||||
);
|
||||
} else {
|
||||
SubPageToool.switchJump(action);
|
||||
}
|
||||
_coordinator = SubDisplayCoordinator();
|
||||
// 插件只保留一个副屏监听,避免页面切换后出现重复消费和状态乱序。
|
||||
_streamSubscription = SubScreenPlugin.viceStream.listen((event) {
|
||||
_coordinator.handleAction(event.method, event.arguments);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_streamSubscription.cancel();
|
||||
_coordinator.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return ScreenUtilInit(
|
||||
designSize: const Size(1920, 1080),
|
||||
minTextAdapt: true,
|
||||
splitScreenMode: true,
|
||||
builder: (_, child) {
|
||||
return MaterialApp(
|
||||
navigatorKey: SubNavigatorKey.navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routes: {
|
||||
SubRouteNames.subProduct: (context) => const SubProductPage(),
|
||||
return MaterialApp(
|
||||
title: '顾客显示屏',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: XfjDesignTokens.canvas,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: XfjDesignTokens.primary,
|
||||
surface: XfjDesignTokens.surface,
|
||||
),
|
||||
),
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light,
|
||||
child: AnimatedBuilder(
|
||||
animation: _coordinator,
|
||||
builder: (context, _) {
|
||||
return switch (_coordinator.mode) {
|
||||
SubDisplayMode.welcome => const CustomerWelcomePage(),
|
||||
SubDisplayMode.settlement => CustomerDisplayPage(
|
||||
coordinator: _coordinator.customerCoordinator,
|
||||
),
|
||||
SubDisplayMode.reservationQuery => ReservationQueryDisplayPage(
|
||||
coordinator: _coordinator.reservationCoordinator,
|
||||
),
|
||||
SubDisplayMode.faceRecognition => FaceRecognitionCustomerPage(
|
||||
coordinator: _coordinator.faceRecognitionCoordinator,
|
||||
),
|
||||
};
|
||||
},
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Text(
|
||||
'欢迎使用',
|
||||
style: theme.typography.xl6,
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user