From 2e929c199b76bd80deabb1d5aff0cecfb4ab1129 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 10 Sep 2026 14:50:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=AF=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customer_welcome/abstract-flow.webp | Bin 0 -> 14882 bytes .../abstract-welcome-hero.webp | Bin 0 -> 33328 bytes .../app/reservation_schedule_card.dart | 458 ++++++ .../widgets/face/face_recognition_panel.dart | 57 +- lib/common/widgets/index.dart | 1 + .../widgets/order/mixin/order_mixin.dart | 23 + lib/sub_screen/common/index.dart | 4 + .../models/customer_display_models.dart | 263 ++++ .../face_recognition_display_models.dart | 51 + .../reservation_query_display_models.dart | 282 ++++ .../utils/customer_display_coordinator.dart | 423 ++++++ .../utils/face_recognition_coordinator.dart | 163 +++ lib/sub_screen/common/utils/index.dart | 4 + .../utils/reservation_query_coordinator.dart | 300 ++++ .../common/utils/sub_display_coordinator.dart | 335 +++++ lib/sub_screen/page/customer_display.dart | 1269 +++++++++++++++++ lib/sub_screen/page/customer_welcome.dart | 659 +++++++++ .../page/face_recognition_customer_page.dart | 498 +++++++ lib/sub_screen/page/index.dart | 4 + .../page/reservation_query_display.dart | 725 ++++++++++ lib/sub_screen/page/sub_app.dart | 83 +- 21 files changed, 5517 insertions(+), 85 deletions(-) create mode 100644 assets/images/customer_welcome/abstract-flow.webp create mode 100644 assets/images/customer_welcome/abstract-welcome-hero.webp create mode 100644 lib/common/widgets/app/reservation_schedule_card.dart create mode 100644 lib/sub_screen/common/models/customer_display_models.dart create mode 100644 lib/sub_screen/common/models/face_recognition_display_models.dart create mode 100644 lib/sub_screen/common/models/reservation_query_display_models.dart create mode 100644 lib/sub_screen/common/utils/customer_display_coordinator.dart create mode 100644 lib/sub_screen/common/utils/face_recognition_coordinator.dart create mode 100644 lib/sub_screen/common/utils/reservation_query_coordinator.dart create mode 100644 lib/sub_screen/common/utils/sub_display_coordinator.dart create mode 100644 lib/sub_screen/page/customer_display.dart create mode 100644 lib/sub_screen/page/customer_welcome.dart create mode 100644 lib/sub_screen/page/face_recognition_customer_page.dart create mode 100644 lib/sub_screen/page/reservation_query_display.dart diff --git a/assets/images/customer_welcome/abstract-flow.webp b/assets/images/customer_welcome/abstract-flow.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b6829660a1f5e3c6056cde9f000943761a8059c GIT binary patch literal 14882 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 U&`_Evo)t;2h3@`!EaFH204JC>jQ{`u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a53316d5946136245ff2d88646ce7445edcd26d1 GIT binary patch literal 33328 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

;X|F4`E)lqgdB0y$U6V8(2skLuwx5 z2V#I3Z7@;{h$Jkg81$TegzJS^HGzAValhMt!00HKS z<~&DEqSd-502delKT)Ju000CA7)ZU+j%dm{jKB%QwN?iW9%Y+f^mIClf0S}VOME^A zv81AvCgP%s^?UoI6IY1KaP|lD8S(?(NSP@lC zu&{)UZtUHj@PMPcsB{?1yg(z#G;8=KC^TVKcd5Vt02;e-!bT~KFDSrp9QO z!@YUW->A9Vw`n~*0nh<2{;%Z>BerU}q69_dCboxt7=YS8O8^r>B;C)A<6A9&j>uoo z(dqAxA(zfl+czLtO|Hf`=e-6;y0B};V%-tbPAsqFFEBe55Yeds#vKZ-+yMGj_!d83 zOV@8idv^+9>ilR)^Y;j)3T{aevGuwzyMV7ZqLF7gy2uSd&*Ou`#Dn0_)0k%84}EhT>}48_Ag{fIS;R}u^RxT zkYhj(C=0=drN9Ch@Bi|T(d)eu6ZaQ|RhR=7Y8hG-$!N^V-R*? 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(), - ), - ); - }, + ), + ), ); } }