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

No files matched your search

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 {
/// 创建主操作或描边操作按钮。
+1
View File
@@ -15,6 +15,7 @@ export './app/app_message_dialog.dart';
export './app/app_page.dart';
export './app/app_price.dart';
export './app/app_qr_code_dialog.dart';
export './app/reservation_schedule_card.dart';
export './app/app_radio_hour_type_dialog.dart';
export './app/app_title.dart';
export './app/app_wallet_selector.dart';
@@ -376,6 +376,8 @@ mixin OrderMixin {
sqlOrderDetail.payTime = DateTime.now();
await orderDao.edit(sqlOrderDetail);
if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) {
// 支付结果已落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。
unawaited(sendSubScreenPaySuccess());
if (sqlOrderDetail.payPrice != 0) {
final price = AppPriceTool.fenToYuan(sqlOrderDetail.payPrice);
TtsManage.speak("购买成功总金额$price元");
@@ -513,6 +515,27 @@ mixin OrderMixin {
);
}
/// 尽力通知副屏展示通用支付成功页,不参与主屏订单成功与否的判定。
///
/// 副屏收到后保留商品与金额并展示成功提示,十秒倒计时结束自动清屏回欢迎页;
/// 使用原生单向通道规避双屏插件业务消息不回包的问题,并限制等待时间。
Future<void> sendSubScreenPaySuccess() async {
try {
final delivered = await AppRuntimeTool.sendViceMessage(
SubScreenAction.orderPaySuccess.code,
).timeout(const Duration(seconds: 1));
if (!delivered) {
logger.w('|$_tag|副屏不可用,支付成功消息未投递|');
}
} on TimeoutException {
logger.w('|$_tag|通知副屏支付成功超时,主屏继续完成订单收尾|');
} catch (error, stack) {
logger.e(
'|$_tag|通知副屏支付成功失败error:|$error|stack:|$stack|',
);
}
}
Future<void> sendSubScreen() async {
if (shopCardList.isEmpty) {
await SubScreenPlugin.sendMsgToViceScreen(
+4
View File
@@ -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();
}
}
+4
View File
@@ -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
+659
View File
@@ -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)}';
+4
View File
@@ -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);
}
+44 -39
View File
@@ -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(),
),
);
},
),
),
);
}
}