diff --git a/assets/images/customer_welcome/abstract-flow.webp b/assets/images/customer_welcome/abstract-flow.webp new file mode 100644 index 0000000..1b68296 Binary files /dev/null and b/assets/images/customer_welcome/abstract-flow.webp differ diff --git a/assets/images/customer_welcome/abstract-welcome-hero.webp b/assets/images/customer_welcome/abstract-welcome-hero.webp new file mode 100644 index 0000000..a53316d Binary files /dev/null and b/assets/images/customer_welcome/abstract-welcome-hero.webp differ diff --git a/lib/common/widgets/app/reservation_schedule_card.dart b/lib/common/widgets/app/reservation_schedule_card.dart new file mode 100644 index 0000000..9b012a3 --- /dev/null +++ b/lib/common/widgets/app/reservation_schedule_card.dart @@ -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? 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? 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 + ? [ + [ + _mealIdentity(scale), + const Spacer(), + _orderAction(scale, compact: true), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + SizedBox(height: 4 * scale), + _mealContent(scale), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start) + : [ + _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 [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 = [ + 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, + ), + ), + ); + } +} diff --git a/lib/common/widgets/face/face_recognition_panel.dart b/lib/common/widgets/face/face_recognition_panel.dart index 1163ff5..2f848f4 100644 --- a/lib/common/widgets/face/face_recognition_panel.dart +++ b/lib/common/widgets/face/face_recognition_panel.dart @@ -303,14 +303,15 @@ class _FaceRecognitionPanelState extends State { ), ), ), - 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), [ @@ -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 [ - 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 { /// 创建主操作或描边操作按钮。 diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 060d715..4eaf0b6 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -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'; diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index e178a41..b055363 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.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 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 sendSubScreen() async { if (shopCardList.isEmpty) { await SubScreenPlugin.sendMsgToViceScreen( diff --git a/lib/sub_screen/common/index.dart b/lib/sub_screen/common/index.dart index 095647d..e68006a 100644 --- a/lib/sub_screen/common/index.dart +++ b/lib/sub_screen/common/index.dart @@ -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'; diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart new file mode 100644 index 0000000..7d64aa3 --- /dev/null +++ b/lib/sub_screen/common/models/customer_display_models.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 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 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, + ); +} diff --git a/lib/sub_screen/common/models/face_recognition_display_models.dart b/lib/sub_screen/common/models/face_recognition_display_models.dart new file mode 100644 index 0000000..f5331d4 --- /dev/null +++ b/lib/sub_screen/common/models/face_recognition_display_models.dart @@ -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, + ); + } +} diff --git a/lib/sub_screen/common/models/reservation_query_display_models.dart b/lib/sub_screen/common/models/reservation_query_display_models.dart new file mode 100644 index 0000000..5595aba --- /dev/null +++ b/lib/sub_screen/common/models/reservation_query_display_models.dart @@ -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.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 []; + 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 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, + ); +} diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart new file mode 100644 index 0000000..644ab76 --- /dev/null +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -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 = []; + 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 = []; + 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? _normalizeMap(Object? value) { + if (value == null) return null; + if (value is Map) return value; + try { + final normalized = jsonDecode(jsonEncode(value)); + if (normalized is Map) 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(); + } +} diff --git a/lib/sub_screen/common/utils/face_recognition_coordinator.dart b/lib/sub_screen/common/utils/face_recognition_coordinator.dart new file mode 100644 index 0000000..0bb496d --- /dev/null +++ b/lib/sub_screen/common/utils/face_recognition_coordinator.dart @@ -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? _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(); + } +} diff --git a/lib/sub_screen/common/utils/index.dart b/lib/sub_screen/common/utils/index.dart index e6859c3..9ece9a3 100644 --- a/lib/sub_screen/common/utils/index.dart +++ b/lib/sub_screen/common/utils/index.dart @@ -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'; diff --git a/lib/sub_screen/common/utils/reservation_query_coordinator.dart b/lib/sub_screen/common/utils/reservation_query_coordinator.dart new file mode 100644 index 0000000..64d419b --- /dev/null +++ b/lib/sub_screen/common/utils/reservation_query_coordinator.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 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 = []; + for (final rawDay in rawDays) { + if (rawDay is! Map) return null; + days.add( + ReservationMenuDayData.fromJson(Map.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? _normalizeMap(Object? value) { + if (value == null) return null; + if (value is Map) return value; + try { + final normalized = jsonDecode(jsonEncode(value)); + return normalized is Map ? 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(); + } +} diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart new file mode 100644 index 0000000..9f03f95 --- /dev/null +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -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(); + } +} diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart new file mode 100644 index 0000000..cecc525 --- /dev/null +++ b/lib/sub_screen/page/customer_display.dart @@ -0,0 +1,1269 @@ +import 'dart:math' as math; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/common/values/constants.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; + +/// 真实副屏和模拟器预览共用的顾客结算屏。 +class CustomerDisplayPage extends StatelessWidget { + const CustomerDisplayPage({ + super.key, + required this.coordinator, + this.previewMode = false, + this.onExit, + }); + + final CustomerDisplayCoordinator 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, _) { + final state = coordinator.state; + if (state.scenario == CustomerDisplayScenario.idle) { + return const CustomerWelcomePage(); + } + return _CustomerDisplayView(state: 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 _CustomerDisplayView extends StatelessWidget { + const _CustomerDisplayView({required this.state}); + + final CustomerDisplayState 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(); + final padding = 24.0 * scale; + final gap = 16.0 * scale; + return ColoredBox( + key: ValueKey('customer-scenario-${state.scenario.name}'), + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Padding( + padding: EdgeInsets.all(padding), + child: Column( + children: [ + _Header(state: state, scale: scale), + SizedBox(height: gap), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 12, + child: _ProductPanel(state: state, scale: scale), + ), + SizedBox(width: gap), + Expanded( + flex: 8, + child: _SettlementPanel(state: state, scale: scale), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.state, required this.scale}); + + final CustomerDisplayState state; + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + height: 82 * scale, + padding: EdgeInsets.symmetric(horizontal: 18 * scale), + decoration: _surfaceDecoration(radius: 16 * scale), + child: Row( + children: [ + Container( + width: 46 * scale, + height: 46 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primary, + borderRadius: BorderRadius.circular(13 * scale), + ), + child: Text( + 'Z', + style: TextStyle( + color: Colors.white, + fontSize: _fontSize(28, scale), + height: 1, + fontWeight: FontWeight.w900, + ), + ), + ), + SizedBox(width: 10 * scale), + Text( + state.businessTitle, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(28, scale), + height: 1, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + if (state.maskedCustomerName != null) ...[ + _HeaderPill( + icon: Icons.person_outline_rounded, + label: '${state.maskedCustomerName},您好', + color: XfjDesignTokens.primary, + scale: scale, + ), + SizedBox(width: 10 * scale), + ], + _HeaderPill( + icon: state.connected + ? Icons.desktop_windows_outlined + : Icons.desktop_access_disabled_outlined, + label: state.connected ? '设备已连接' : '设备未连接', + color: state.connected + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + 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: 42 * scale), + padding: EdgeInsets.symmetric(horizontal: 10 * 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( + children: [ + Icon(icon, color: color, size: 22 * scale), + SizedBox(width: 5 * scale), + Text( + label, + style: TextStyle( + color: color, + fontSize: _fontSize(18, scale), + height: 1, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// 副屏商品核对面板,集中展示商品明细、同步状态和顾客提示。 +class _ProductPanel extends StatelessWidget { + /// 创建读取当前顾客屏状态的商品核对面板。 + const _ProductPanel({required this.state, required this.scale}); + + /// 副屏协调器产出的当前结算展示状态。 + final CustomerDisplayState state; + + /// 依据副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建适合远距离核对的商品区域,并在商品较多时保留纵向滚动能力。 + @override + Widget build(BuildContext context) { + final isPaymentSuccess = state.scenario == CustomerDisplayScenario.success; + return Container( + decoration: _surfaceDecoration(radius: 16 * scale), + child: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + 14 * scale, + 12 * scale, + 14 * scale, + 10 * scale, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.products.isEmpty + ? '欢迎光临' + : isPaymentSuccess + ? '本次购买商品' + : '请核对商品信息', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(34, scale), + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 2 * scale), + Text( + state.products.isEmpty + ? '商品加入购物车后将在这里实时展示' + : isPaymentSuccess + ? '支付已完成,${state.countdownSeconds} 秒后自动清屏' + : '本次消费共 ${state.products.length} 项商品', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(22, scale), + ), + ), + ], + ), + ), + _StatusBadge(scenario: state.scenario, scale: scale), + ], + ), + ), + Divider(height: 1, color: XfjDesignTokens.line), + if (state.products.isEmpty) + Expanded(child: _EmptyProducts(scale: scale)) + else ...[ + _ProductHeader(scale: scale), + Expanded( + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: state.products.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: XfjDesignTokens.line), + itemBuilder: (context, index) => _ProductRow( + product: state.products[index], + scale: scale, + ), + ), + ), + ], + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: 14 * scale, + vertical: 8 * scale, + ), + decoration: const BoxDecoration( + color: Color(0xFFFAFAFB), + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Text( + isPaymentSuccess + ? '本次支付已完成,请带好您的商品,感谢您的使用' + : '商品和金额由收银员实时更新,如有疑问请及时告知收银员', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(18, scale), + ), + ), + ), + ], + ), + ); + } +} + +class _EmptyProducts extends StatelessWidget { + const _EmptyProducts({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 92 * scale, + height: 92 * scale, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + shape: BoxShape.circle, + ), + child: Icon( + Icons.shopping_basket_outlined, + color: XfjDesignTokens.primary, + size: 46 * scale, + ), + ), + SizedBox(height: 18 * scale), + Text( + '等待添加商品', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(30, scale), + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6 * scale), + Text( + '请将商品交给收银员进行结算', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(20, scale), + ), + ), + ], + ), + ); + } +} + +/// 副屏商品列表表头,与商品行共享相同的列宽比例。 +class _ProductHeader extends StatelessWidget { + /// 创建当前分辨率下的商品列表表头。 + const _ProductHeader({required this.scale}); + + /// 依据副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建商品、数量或重量、单价和小计四列表头。 + @override + Widget build(BuildContext context) { + return Container( + height: 42 * scale, + padding: EdgeInsets.symmetric(horizontal: 10 * scale), + color: const Color(0xFFFAFAFB), + child: Row( + children: [ + _tableText('商品', 5, scale, align: TextAlign.left), + _tableText('数量 / 重量', 2, scale), + _tableText('单价', 2, scale), + _tableText('小计', 2, scale), + ], + ), + ); + } +} + +/// 副屏商品列表中的单条商品记录,可选展示套餐组成。 +class _ProductRow extends StatelessWidget { + /// 创建一条可同时展示主商品和套餐组成的副屏商品行。 + const _ProductRow({required this.product, required this.scale}); + + /// 当前主商品及其可选套餐组成。 + final CustomerDisplayProduct product; + + /// 基于副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建主商品表格行,并在套餐下方追加不参与计价的组成明细。 + @override + Widget build(BuildContext context) { + return ConstrainedBox( + // 单品行按当前副屏可视区容纳 10 条设置高度,字号放大后通过收紧内边距避免溢出。 + constraints: BoxConstraints(minHeight: 72 * scale), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 3 * scale), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + flex: 5, + child: Row( + children: [ + _ProductImage(product: product, scale: scale), + SizedBox(width: 6 * scale), + _ProductTypeBadge( + isCombo: product.isCombo, + scale: scale, + ), + SizedBox(width: 5 * scale), + Expanded( + child: Text( + product.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(28, scale), + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + _valueCell(product.quantityLabel, 2, scale), + _valueCell(_money(product.unitPrice), 2, scale), + _valueCell( + _money(product.totalPrice), + 2, + scale, + emphasized: true, + ), + ], + ), + if (product.isCombo && product.comboItems.isNotEmpty) ...[ + SizedBox(height: 6 * scale), + _ComboContents(product: product, scale: scale), + ], + ], + ), + ), + ); + } +} + +/// 商品行左侧的固定尺寸图片,网络加载与失败时均保持行高稳定。 +class _ProductImage extends StatelessWidget { + /// 创建使用当前商品图片来源的顾客屏缩略图。 + const _ProductImage({required this.product, required this.scale}); + + /// 当前商品,用于读取图片来源、占位首字和无障碍说明。 + final CustomerDisplayProduct product; + + /// 依据副屏分辨率计算的几何缩放比例。 + final double scale; + + /// 构建真实商品图片;副屏 Engine 不依赖磁盘缓存插件,使用 Flutter 原生网络图片缓存。 + @override + Widget build(BuildContext context) { + final imageSource = product.imageSource; + final imageSize = 60 * scale; + final placeholder = _buildPlaceholder(); + return Semantics( + image: true, + label: '${product.name}商品图片', + child: ClipRRect( + borderRadius: BorderRadius.circular(7 * scale), + child: SizedBox( + key: ValueKey('customer-product-image-${product.name}'), + width: imageSize, + height: imageSize, + child: imageSource == null + ? placeholder + : Image.network( + _productImageUrl(imageSource), + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) => + loadingProgress == null ? child : placeholder, + errorBuilder: (context, error, stackTrace) => placeholder, + ), + ), + ), + ); + } + + /// 为无图片、加载中和加载失败场景生成一致的名称首字占位。 + Widget _buildPlaceholder() { + final trimmedName = product.name.trim(); + final shortLabel = trimmedName.isEmpty ? '商' : trimmedName.substring(0, 1); + return ColoredBox( + color: XfjDesignTokens.control, + child: Center( + child: Text( + shortLabel, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(22, scale), + fontWeight: FontWeight.w800, + ), + ), + ), + ); + } +} + +/// 副屏主商品类型标识,帮助顾客快速区分单品和套餐。 +class _ProductTypeBadge extends StatelessWidget { + /// 创建商品类型标识。 + const _ProductTypeBadge({required this.isCombo, required this.scale}); + + /// 是否展示为套餐标识。 + final bool isCombo; + + /// 基于副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建紧凑且不抢占商品名称空间的类型标识。 + @override + Widget build(BuildContext context) { + final foreground = + isCombo ? XfjDesignTokens.primary : XfjDesignTokens.muted; + final background = + isCombo ? XfjDesignTokens.primarySoft : XfjDesignTokens.control; + return DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(4 * scale), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 5 * scale, + vertical: 2 * scale, + ), + child: Text( + isCombo ? '套餐' : '单品', + style: TextStyle( + color: foreground, + fontSize: _fontSize(18, scale), + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +/// 套餐主商品下方的组成明细带,展示每套菜品名称和份数但不重复展示金额。 +class _ComboContents extends StatelessWidget { + /// 创建指定套餐的组成明细带。 + const _ComboContents({required this.product, required this.scale}); + + /// 当前套餐主商品,组成列表非空时才会创建本组件。 + final CustomerDisplayProduct product; + + /// 基于副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建套餐汇总说明及逐项组成,长名称在当前行内截断以保持列宽稳定。 + @override + Widget build(BuildContext context) { + final totalQuantity = product.comboItems.fold( + 0, + (total, item) => total + item.quantity, + ); + return Container( + key: ValueKey('combo-details-${product.name}'), + padding: EdgeInsets.fromLTRB( + 10 * scale, + 6 * scale, + 10 * scale, + 7 * scale, + ), + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border( + left: BorderSide(color: XfjDesignTokens.primary, width: 3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.layers_outlined, + size: 18 * scale, + color: XfjDesignTokens.primary, + ), + SizedBox(width: 5 * scale), + Expanded( + child: Text( + '套餐内含 · 每套 ${product.comboItems.length} 样 / $totalQuantity 份', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: _fontSize(20, scale), + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 8 * scale), + Text( + '明细不单独计价', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(18, scale), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + for (final item in product.comboItems) + Padding( + padding: EdgeInsets.only(top: 5 * scale), + child: Row( + children: [ + Icon( + Icons.subdirectory_arrow_right_rounded, + size: 17 * scale, + color: XfjDesignTokens.muted, + ), + SizedBox(width: 5 * scale), + Expanded( + child: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(21, scale), + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 8 * scale), + Text( + '×${item.quantity}', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(21, scale), + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// 按商品表格的列宽比例渲染表头文本。 +Widget _tableText( + String value, + int flex, + double scale, { + TextAlign align = TextAlign.center, +}) { + return Expanded( + flex: flex, + child: Text( + value, + textAlign: align, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(20, scale), + fontWeight: FontWeight.w700, + ), + ), + ); +} + +/// 按商品表格的列宽比例渲染数量或金额,并保持数字宽度稳定。 +Widget _valueCell( + String value, + int flex, + double scale, { + bool emphasized = false, +}) { + return Expanded( + flex: flex, + child: Text( + value, + textAlign: TextAlign.center, + style: TextStyle( + color: emphasized ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontSize: _fontSize(26, scale), + fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); +} + +class _SettlementPanel extends StatelessWidget { + const _SettlementPanel({required this.state, required this.scale}); + + final CustomerDisplayState state; + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16 * scale), + decoration: _surfaceDecoration(radius: 16 * scale), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '实际扣款', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(20, scale), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6 * scale), + Text( + _money(state.payablePrice), + key: const ValueKey('customer-payable-price'), + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: _fontSize(64, scale), + height: 1.05, + fontWeight: FontWeight.w900, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + SizedBox(height: 12 * scale), + _PriceLine(label: '商品总价', price: state.totalPrice, scale: scale), + SizedBox(height: 7 * scale), + _PriceLine( + label: '额外支付', + price: state.amountMealPrice, + scale: scale, + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 14 * scale), + child: Divider(height: 1, color: XfjDesignTokens.line), + ), + Expanded(child: _PaymentStatePanel(state: state, scale: scale)), + SizedBox(height: 14 * scale), + _CheckoutSteps(current: state.scenario, scale: scale), + ], + ), + ); + } +} + +class _PriceLine extends StatelessWidget { + const _PriceLine({ + required this.label, + required this.price, + required this.scale, + }); + + final String label; + final int price; + final double scale; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(22, scale), + fontWeight: FontWeight.w600, + ), + ), + Text( + _money(price), + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(26, scale), + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +/// 副屏结算状态主体,分别展示支付码、支付结果和顾客下一步指引。 +class _PaymentStatePanel extends StatelessWidget { + /// 创建读取当前结算状态的右侧展示区域。 + const _PaymentStatePanel({required this.state, required this.scale}); + + /// 副屏协调器提供的订单金额、支付场景和倒计时快照。 + final CustomerDisplayState state; + + /// 依据副屏分辨率计算的几何缩放比例。 + final double scale; + + /// 根据支付场景构建二维码、成功页或取消和超时提示。 + @override + Widget build(BuildContext context) { + if (state.scenario == CustomerDisplayScenario.paying && + state.qrCodeData != null) { + return LayoutBuilder( + builder: (context, constraints) { + final qrSize = math.min( + constraints.maxWidth * 0.54, + constraints.maxHeight * 0.6, + ); + // 主屏调试预览可能只有 800×600;仅在极矮区域收拢辅助说明,金额、倒计时和主提示仍保持大字号。 + final compactPaymentHint = constraints.maxHeight < 180; + return Row( + children: [ + Container( + padding: EdgeInsets.all(10 * scale), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(14 * scale), + ), + child: QrImageView( + data: state.qrCodeData!, + version: QrVersions.auto, + size: qrSize, + padding: EdgeInsets.zero, + ), + ), + SizedBox(width: 18 * scale), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '请扫码支付', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(34, scale), + height: 1, + fontWeight: FontWeight.w900, + ), + ), + SizedBox(height: 6 * scale), + Text( + _countdown(state.countdownSeconds), + key: const ValueKey('customer-payment-countdown'), + style: TextStyle( + color: XfjDesignTokens.danger, + fontSize: _fontSize(44, scale), + height: 1, + fontWeight: FontWeight.w900, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + SizedBox(height: 5 * scale), + Text( + '支付完成后请勿重复扫码', + maxLines: compactPaymentHint ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(20, scale), + height: 1.2, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } + + if (state.scenario == CustomerDisplayScenario.success) { + final successColor = XfjDesignTokens.success; + return Semantics( + label: '支付成功,${state.countdownSeconds} 秒后自动清屏', + child: DecoratedBox( + key: const ValueKey('customer-payment-success'), + decoration: BoxDecoration( + color: successColor.withValues(alpha: 0.07), + border: Border.all(color: successColor.withValues(alpha: 0.24)), + borderRadius: BorderRadius.circular(16 * scale), + ), + child: [ + Container( + width: 86 * scale, + height: 86 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: successColor, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 50 * scale, + ), + ), + Text( + '支付成功', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(52, scale), + height: 1, + fontWeight: FontWeight.w900, + ), + ), + Text( + '请带好您的商品', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(27, scale), + height: 1.15, + fontWeight: FontWeight.w700, + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${state.countdownSeconds}', + style: TextStyle( + color: successColor, + fontSize: _fontSize(48, scale), + fontWeight: FontWeight.w900, + ), + ), + TextSpan( + text: ' 秒后自动清屏', + style: TextStyle( + color: successColor, + fontSize: _fontSize(25, scale), + fontWeight: FontWeight.w800, + ), + ), + ], + ), + key: const ValueKey('customer-success-countdown'), + textAlign: TextAlign.center, + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ] + .toColumnSpace( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + space: 12 * scale, + ) + .padding(horizontal: 18 * scale, vertical: 14 * scale), + ), + ); + } + + final visual = _scenarioVisual(state.scenario); + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.06), + border: Border.all(color: visual.color.withValues(alpha: 0.2)), + borderRadius: BorderRadius.circular(16 * scale), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 68 * scale, + height: 68 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: visual.color, + shape: BoxShape.circle, + ), + child: Icon(visual.icon, color: Colors.white, size: 36 * scale), + ), + SizedBox(height: 10 * scale), + Text( + visual.title, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(36, scale), + height: 1, + fontWeight: FontWeight.w900, + ), + ), + SizedBox(height: 5 * scale), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20 * scale), + child: Text( + visual.description, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: _fontSize(22, scale), + height: 1.2, + ), + ), + ), + ], + ), + ); + } +} + +class _CheckoutSteps extends StatelessWidget { + const _CheckoutSteps({required this.current, required this.scale}); + + final CustomerDisplayScenario current; + final double scale; + + @override + Widget build(BuildContext context) { + final activeStep = switch (current) { + CustomerDisplayScenario.idle => 0, + CustomerDisplayScenario.review => 1, + CustomerDisplayScenario.paying => 2, + _ => 3, + }; + return Row( + children: [ + _Step(index: 1, label: '核对商品', active: activeStep >= 1, scale: scale), + _Step(index: 2, label: '完成支付', active: activeStep >= 2, scale: scale), + _Step(index: 3, label: '带走商品', active: activeStep >= 3, scale: scale), + ], + ); + } +} + +class _Step extends StatelessWidget { + const _Step({ + required this.index, + required this.label, + required this.active, + required this.scale, + }); + + final int index; + final String label; + final bool active; + final double scale; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + children: [ + Container( + width: 30 * scale, + height: 30 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? XfjDesignTokens.primary : XfjDesignTokens.control, + shape: BoxShape.circle, + ), + child: Text( + '$index', + style: TextStyle( + color: active ? Colors.white : XfjDesignTokens.muted, + fontSize: _fontSize(18, scale), + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(height: 5 * scale), + Text( + label, + style: TextStyle( + color: active ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontSize: _fontSize(18, scale), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +/// 商品面板顶部的结算阶段标签,用图标和文字同时表达当前状态。 +class _StatusBadge extends StatelessWidget { + /// 创建与当前顾客屏场景对应的状态标签。 + const _StatusBadge({required this.scenario, required this.scale}); + + /// 当前结算场景,决定标签的文案、图标和语义颜色。 + final CustomerDisplayScenario scenario; + + /// 依据副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建紧凑但可远距离识别的商品核对状态标签。 + @override + Widget build(BuildContext context) { + final visual = _scenarioVisual(scenario); + return Container( + padding: EdgeInsets.symmetric(horizontal: 8 * scale, vertical: 4 * scale), + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18 * scale), + ), + child: Row( + children: [ + Icon(visual.icon, color: visual.color, size: 20 * scale), + SizedBox(width: 4 * scale), + Text( + scenario.title, + style: TextStyle( + color: visual.color, + fontSize: _fontSize(20, scale), + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _ScenarioVisual { + const _ScenarioVisual({ + required this.title, + required this.description, + required this.icon, + required this.color, + }); + + final String title; + final String description; + final IconData icon; + final Color color; +} + +_ScenarioVisual _scenarioVisual(CustomerDisplayScenario scenario) { + return switch (scenario) { + CustomerDisplayScenario.idle => const _ScenarioVisual( + title: '欢迎使用', + description: '请将商品交给收银员,结算信息会实时显示在这里', + icon: Icons.waving_hand_outlined, + color: XfjDesignTokens.primary, + ), + CustomerDisplayScenario.review => const _ScenarioVisual( + title: '请核对商品', + description: '请确认商品、数量和金额,确认无误后由收银员发起支付', + icon: Icons.receipt_long_outlined, + color: XfjDesignTokens.primary, + ), + CustomerDisplayScenario.paying => const _ScenarioVisual( + title: '请扫码支付', + description: '请在倒计时结束前完成支付', + icon: Icons.qr_code_2_rounded, + color: XfjDesignTokens.primary, + ), + CustomerDisplayScenario.success => const _ScenarioVisual( + title: '支付成功', + description: '本次支付已完成,请带好商品,感谢您的使用', + icon: Icons.check_rounded, + color: XfjDesignTokens.success, + ), + CustomerDisplayScenario.cancelled => const _ScenarioVisual( + title: '支付已取消', + description: '本次支付没有完成,请等待收银员重新发起支付', + icon: Icons.close_rounded, + color: XfjDesignTokens.danger, + ), + CustomerDisplayScenario.timeout => const _ScenarioVisual( + title: '支付已超时', + description: '二维码已经失效,请等待收银员重新发起支付', + icon: Icons.schedule_rounded, + color: XfjDesignTokens.warning, + ), + }; +} + +BoxDecoration _surfaceDecoration({required double radius}) { + return BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(radius), + boxShadow: XfjDesignTokens.surfaceShadow, + ); +} + +String _money(int cents) => '¥ ${(cents / 100).toStringAsFixed(2)}'; + +/// 将文字缩放与布局缩放分离,小尺寸副屏仍保留远距离可读的最低字号。 +double _fontSize(double designSize, double scale) { + return designSize * math.max(scale, 0.9); +} + +/// 将主屏传来的完整地址或 OSS 相对路径转换为副屏可加载的网络地址。 +String _productImageUrl(String source) { + if (RegExp(r'^https?://', caseSensitive: false).hasMatch(source)) { + return source; + } + final separator = source.startsWith('/') || source.startsWith('?') ? '' : '/'; + return '${Constants.ossUrl}$separator$source'; +} + +String _countdown(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; +} diff --git a/lib/sub_screen/page/customer_welcome.dart b/lib/sub_screen/page/customer_welcome.dart new file mode 100644 index 0000000..6477058 --- /dev/null +++ b/lib/sub_screen/page/customer_welcome.dart @@ -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 createState() => _CustomerWelcomePageState(); +} + +class _CustomerWelcomePageState extends State { + 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], + ); +} diff --git a/lib/sub_screen/page/face_recognition_customer_page.dart b/lib/sub_screen/page/face_recognition_customer_page.dart new file mode 100644 index 0000000..7a490c4 --- /dev/null +++ b/lib/sub_screen/page/face_recognition_customer_page.dart @@ -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 createState() => + _FaceRecognitionCustomerPageState(); +} + +class _FaceRecognitionCustomerPageState + extends State { + FaceRecognitionPreviewTexture? _texture; + FaceRecognitionSessionEvent? _nativeEvent; + StreamSubscription? _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 _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)}'; diff --git a/lib/sub_screen/page/index.dart b/lib/sub_screen/page/index.dart index 34523a0..377f43a 100644 --- a/lib/sub_screen/page/index.dart +++ b/lib/sub_screen/page/index.dart @@ -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'; diff --git a/lib/sub_screen/page/reservation_query_display.dart b/lib/sub_screen/page/reservation_query_display.dart new file mode 100644 index 0000000..ca7a556 --- /dev/null +++ b/lib/sub_screen/page/reservation_query_display.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() + .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 = [ + 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), + [ + Icon( + presentation.icon, + color: presentation.color, + size: _fontSize(36, scale), + ), + [ + 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(), + [ + 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 [ + 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 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); +} diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart index 3b72df8..57ff916 100644 --- a/lib/sub_screen/page/sub_app.dart +++ b/lib/sub_screen/page/sub_app.dart @@ -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 { - 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(), - ), - ); - }, + ), + ), ); } }