From e1d41c53705b08003476ed9129df83d53559e132 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 10 Sep 2026 16:09:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=9D=E6=8C=81=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=9A=84=E8=B4=AD=E4=B9=B0=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E3=80=81=E9=A5=AD=E5=8D=A1=E7=AD=89=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/utils/app/app_setting_tool.dart | 14 +- .../widgets/order/select_pay_type_dialog.dart | 22 +- lib/pages/canteen_product/controller.dart | 2 + lib/pages/supermarket_order/controller.dart | 2 + lib/sub_screen/common/index.dart | 2 + .../models/customer_display_models.dart | 19 + .../utils/customer_display_coordinator.dart | 6 + .../common/widgets/sub_screen_header.dart | 317 ++++++++++++ lib/sub_screen/page/customer_display.dart | 484 ++++-------------- lib/sub_screen/page/customer_welcome.dart | 213 +------- .../page/face_recognition_customer_page.dart | 162 +----- .../page/reservation_query_display.dart | 156 +----- lib/sub_screen/page/sub_app.dart | 128 ++++- 13 files changed, 631 insertions(+), 896 deletions(-) create mode 100644 lib/sub_screen/common/widgets/sub_screen_header.dart diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 96e5b7d..872bdb6 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -133,16 +133,25 @@ abstract class AppSettingTool { } static Future removeSettingCache() async { + _memorySetting = null; await _settingCache.emptyCache(); } + /// setting 单行记录的进程内副本;所有写入路径经 _cacheSetting 同步刷新, + /// 读取优先命中内存,避免每个 HTTP 请求与支付链路反复落盘读文件再解析。 + static SettingEntity? _memorySetting; + static Future _getSetting() async { // 尝试从缓存获取 + final memoryCached = _memorySetting; + if (memoryCached != null) return memoryCached; final fileInfo = await _settingCache.getFileFromCache(_settingKey); if (fileInfo != null) { final content = await fileInfo.file.readAsString(); if (ObjectUtil.isNotEmpty(content)) { - return SettingEntity.fromJson(json.decode(content)); + final setting = SettingEntity.fromJson(json.decode(content)); + _memorySetting = setting; + return setting; } } final app = Get.find(); @@ -167,8 +176,10 @@ abstract class AppSettingTool { throw Exception("|$_tag|_getSetting新增错误"); } addObj.id = addId; + _memorySetting = addObj; return addObj; } + _memorySetting = setting; return setting; } @@ -220,6 +231,7 @@ abstract class AppSettingTool { /// 刷新 setting 单行缓存;调用方必须已经完成数据库写入并持有串行写入队列。 static Future _cacheSetting(SettingEntity setting) async { + _memorySetting = setting; await _settingCache.putFile( _settingKey, Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index a228dd4..b50055e 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -203,9 +203,19 @@ class SelectPayTypeDialogController extends GetxController { } /// 使用弹框打开瞬间的交易快照初始化金额和支付方式。 - void initPayType({required CashierCheckoutSnapshot snapshot}) { + /// + /// [initialUser] 是业务层仍保留的本单人员:取消弹框后再次打开时用来恢复已识别 + /// 人员和钱包展示,免去重新识别。恢复发生在应用默认支付方式之前,线下方式依旧 + /// 按既有规则把人员替换为匿名外部用户。 + void initPayType({ + required CashierCheckoutSnapshot snapshot, + ServiceUser? initialUser, + }) { _closeStatus(); updateCheckoutSnapshot(snapshot); + if (initialUser != null) { + payUser.value = initialUser; + } final defaultPayType = appSetting.defaultPayDisplayType; if (defaultPayType != null) { _applyPayDisplayType(defaultPayType); @@ -223,6 +233,11 @@ class SelectPayTypeDialogController extends GetxController { usePayDisplayTypeList.value = supportPayTypeList; } } + // 恢复的人员内嵌钱包可能滞后,按当前支付方式刷新一次钱包列表; + // 线下或匿名人员会在 _loadWalletListForUser 内被原有条件直接跳过。 + if (initialUser != null) { + unawaited(_getWalletList()); + } } /// 替换当前结算快照,并同步人脸会话创建和支付按钮使用的整数分金额。 @@ -1075,16 +1090,19 @@ typedef CashierCheckoutQuantityChanged = Future Function(String productServiceUniqueCode, int quantity); /// 打开收银结算弹框;空快照不会创建路由,避免无商品时进入支付方式选择。 +/// +/// [initialUser] 为业务层仍保留的本单人员,取消弹框后再次打开时恢复识别结果。 Future selectPayTypeDialog({ bool paymentEnabled = true, String? disabledMessage, required CashierCheckoutSnapshot snapshot, CashierCheckoutQuantityChanged? onQuantityChanged, + ServiceUser? initialUser, }) async { if (snapshot.isEmpty) return; final payTypeC = Get.find(); final context = Get.context!; - payTypeC.initPayType(snapshot: snapshot); + payTypeC.initPayType(snapshot: snapshot, initialUser: initialUser); final snapshotNotifier = ValueNotifier(snapshot); final viewport = MediaQuery.sizeOf(context); final dialogWidth = viewport.width * 0.92; diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 8f28c6c..53c1c2f 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -135,6 +135,8 @@ class CanteenProductController extends GetxController with OrderMixin { await selectPayTypeDialog( snapshot: snapshot, onQuantityChanged: _changeCheckoutQuantity, + // 取消结算后重开时恢复本单已识别人员与钱包展示。 + initialUser: payUser.value, ); } diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart index 619234b..23882dc 100644 --- a/lib/pages/supermarket_order/controller.dart +++ b/lib/pages/supermarket_order/controller.dart @@ -94,6 +94,8 @@ class SupermarketOrderController extends GetxController with OrderMixin { await selectPayTypeDialog( snapshot: snapshot, onQuantityChanged: _changeCheckoutQuantity, + // 取消结算后重开时恢复本单已识别人员与钱包展示。 + initialUser: payUser.value, ); } diff --git a/lib/sub_screen/common/index.dart b/lib/sub_screen/common/index.dart index e68006a..c75f2b1 100644 --- a/lib/sub_screen/common/index.dart +++ b/lib/sub_screen/common/index.dart @@ -11,3 +11,5 @@ export './routers/index.dart'; export './utils/index.dart'; export './values/constants.dart'; + +export './widgets/sub_screen_header.dart'; diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart index 7d64aa3..b97d194 100644 --- a/lib/sub_screen/common/models/customer_display_models.dart +++ b/lib/sub_screen/common/models/customer_display_models.dart @@ -135,6 +135,7 @@ class CustomerDisplayState { required this.totalPrice, required this.amountMealPrice, this.customerName, + this.customerPhone, this.qrCodeData, this.countdownSeconds = 0, this.connected = true, @@ -157,6 +158,7 @@ class CustomerDisplayState { scenario: scenario, businessTitle: '智慧超市', customerName: '张小明', + customerPhone: '13812345678', products: const [ CustomerDisplayProduct( name: '农夫山泉 550ml', @@ -199,6 +201,7 @@ class CustomerDisplayState { final CustomerDisplayScenario scenario; final String businessTitle; final String? customerName; + final String? customerPhone; final List products; final int totalPrice; final int amountMealPrice; @@ -216,6 +219,19 @@ class CustomerDisplayState { return '${value[0]}*${value[value.length - 1]}'; } + /// 副屏永远不展示完整手机号;主屏任何字段异常时整体降级为星号。 + String? get maskedCustomerPhone { + final value = customerPhone?.trim(); + if (value == null || value.isEmpty) return null; + if (value.length >= 11) { + return '${value.substring(0, 3)}****${value.substring(value.length - 4)}'; + } + if (value.length >= 5) { + return '${value.substring(0, 2)}****${value.substring(value.length - 2)}'; + } + return '****'; + } + CustomerDisplayState withScenario( CustomerDisplayScenario next, { String? qrCodeData, @@ -225,6 +241,7 @@ class CustomerDisplayState { scenario: next, businessTitle: businessTitle, customerName: customerName, + customerPhone: customerPhone, products: products, totalPrice: totalPrice, amountMealPrice: amountMealPrice, @@ -240,6 +257,7 @@ class CustomerDisplayState { other.scenario == scenario && other.businessTitle == businessTitle && other.customerName == customerName && + other.customerPhone == customerPhone && listEquals(other.products, products) && other.totalPrice == totalPrice && other.amountMealPrice == amountMealPrice && @@ -253,6 +271,7 @@ class CustomerDisplayState { scenario, businessTitle, customerName, + customerPhone, Object.hashAll(products), totalPrice, amountMealPrice, diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 644ab76..5306e39 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -197,6 +197,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier { if (products.isEmpty) return CustomerDisplayState.idle(); String? customerName; + String? customerPhone; final rawPayUser = data['payUser']; if (rawPayUser != null) { final payUser = _normalizeMap(rawPayUser); @@ -204,12 +205,17 @@ class CustomerDisplayCoordinator extends ChangeNotifier { final username = payUser['username']; if (username != null && username is! String) return null; customerName = username?.trim(); + // ServiceUser.userMobile 随 payUser 整体透传;副屏只保留脱敏展示所需的原文。 + final userMobile = payUser['userMobile']; + if (userMobile != null && userMobile is! String) return null; + customerPhone = userMobile?.trim(); } return CustomerDisplayState( scenario: CustomerDisplayScenario.review, businessTitle: businessTitle, customerName: customerName, + customerPhone: customerPhone, products: List.unmodifiable(products), totalPrice: totalPrice, amountMealPrice: amountMealPrice, diff --git a/lib/sub_screen/common/widgets/sub_screen_header.dart b/lib/sub_screen/common/widgets/sub_screen_header.dart new file mode 100644 index 0000000..1464449 --- /dev/null +++ b/lib/sub_screen/common/widgets/sub_screen_header.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; + +/// 副屏常驻顶部状态栏外壳,与主屏终端外壳(TerminalShell)同构: +/// PJ 品牌 + 当前模式标题常驻顶部,业务页面只负责顶栏下方的工作区。 +/// +/// 副屏 Engine 没有 ScreenUtil 与 GetX,这里沿用副屏页面的手算缩放, +/// 不引入 `.w`/`.h` 或主屏 Controller 依赖。 +class SubScreenShell extends StatelessWidget { + const SubScreenShell({ + super.key, + required this.title, + this.subtitle, + this.trailingBuilder, + required this.child, + }); + + /// 当前副屏模式展示在顶栏的标题。 + final String title; + + /// 标题下方可选的辅助说明,例如预约查询的商户名。 + final String? subtitle; + + /// 按顶栏缩放构建标题右侧的状态胶囊,例如识别状态或顾客信息。 + final List Function(double scale)? trailingBuilder; + + /// 顶栏下方持续保留的业务页面。 + final Widget child; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final scale = subScreenHeaderScale(constraints); + return ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Column( + children: [ + SubScreenHeader( + scale: scale, + title: title, + subtitle: subtitle, + trailing: trailingBuilder?.call(scale) ?? const [], + ), + Expanded(child: child), + ], + ), + ), + ); + }, + ); + } +} + +/// 与副屏业务页面一致的 1920×1080 设计稿缩放,供顶栏及其状态胶囊共用。 +double subScreenHeaderScale(BoxConstraints constraints) { + return math + .min(constraints.maxWidth / 1920, constraints.maxHeight / 1080) + .clamp(0.55, 1.12) + .toDouble(); +} + +/// 副屏统一顶部状态栏:品牌、标题、状态胶囊与时钟一行排布。 +class SubScreenHeader extends StatelessWidget { + const SubScreenHeader({ + super.key, + required this.scale, + required this.title, + this.subtitle, + this.trailing = const [], + }); + + final double scale; + final String title; + final String? subtitle; + final List trailing; + + @override + Widget build(BuildContext context) { + return Container( + // 高度给保底值:小尺寸副屏文字有 0.9 字号下限,纯按 scale 收缩会裁切文字。 + height: subtitle == null + ? math.max(96 * scale, 68) + : math.max(108 * scale, 80), + padding: EdgeInsets.symmetric(horizontal: 28 * scale), + decoration: const BoxDecoration( + color: XfjDesignTokens.surface, + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Text( + 'PJ', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 46 * subScreenTextScale(scale), + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + letterSpacing: -1.8 * scale, + height: 1, + ), + ), + SizedBox(width: 22 * scale), + Container( + width: 1, + height: math.max(44 * scale, 34), + color: XfjDesignTokens.line, + ), + SizedBox(width: 22 * scale), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 32 * subScreenTextScale(scale), + fontWeight: FontWeight.w800, + height: 1.15, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: 4 * scale), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 19 * subScreenTextScale(scale), + height: 1.2, + ), + ), + ], + ], + ), + ), + for (final widget in trailing) ...[ + widget, + SizedBox(width: 12 * scale), + ], + Icon( + Icons.schedule_rounded, + color: XfjDesignTokens.muted, + size: 24 * subScreenTextScale(scale), + ), + SizedBox(width: 8 * scale), + SubScreenHeaderClock(scale: scale), + ], + ), + ); + } +} + +/// 顶栏与主屏同节奏的分钟级时钟。 +class SubScreenHeaderClock extends StatefulWidget { + const SubScreenHeaderClock({super.key, required this.scale}); + + final double scale; + + @override + State createState() => _SubScreenHeaderClockState(); +} + +class _SubScreenHeaderClockState extends State { + late DateTime _now; + Timer? _timer; + + @override + void initState() { + super.initState(); + _now = DateTime.now(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() => _now = DateTime.now()); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hour = _now.hour.toString().padLeft(2, '0'); + final minute = _now.minute.toString().padLeft(2, '0'); + return Text( + '$hour:$minute', + key: const ValueKey('sub-screen-header-clock'), + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 27 * subScreenTextScale(widget.scale), + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} + +/// 顶栏各模式共用的状态胶囊。 +class SubScreenHeaderPill extends StatelessWidget { + const SubScreenHeaderPill({ + super.key, + 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: math.max(52 * scale, 42)), + padding: EdgeInsets.symmetric(horizontal: 16 * scale), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + border: Border.all(color: color.withValues(alpha: 0.22)), + borderRadius: BorderRadius.circular(26 * scale), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 27 * subScreenTextScale(scale)), + SizedBox(width: 8 * scale), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 24 * subScreenTextScale(scale), + height: 1, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// 与结算副屏同策略:布局按缩放收缩,文字保底 0.9 保证远距离可读。 +double subScreenTextScale(double scale) => math.max(scale, 0.9); + +Color subScreenFaceStatusColor(FaceRecognitionDisplayStatus status) => + switch (status) { + FaceRecognitionDisplayStatus.success => XfjDesignTokens.success, + FaceRecognitionDisplayStatus.failed || + FaceRecognitionDisplayStatus.cancelled => + XfjDesignTokens.danger, + _ => XfjDesignTokens.primary, + }; + +IconData subScreenFaceStatusIcon(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 subScreenFaceStatusTitle(FaceRecognitionDisplayStatus status) => + switch (status) { + FaceRecognitionDisplayStatus.initializing => '摄像头启动中', + FaceRecognitionDisplayStatus.detecting => '正在识别', + FaceRecognitionDisplayStatus.success => '识别成功', + FaceRecognitionDisplayStatus.failed => '需要重试', + FaceRecognitionDisplayStatus.cancelled => '识别已取消', + }; + +IconData subScreenReservationScenarioIcon( + ReservationQueryDisplayScenario scenario, +) => + 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 subScreenReservationScenarioColor( + ReservationQueryDisplayScenario scenario, +) => + switch (scenario) { + ReservationQueryDisplayScenario.idle || + ReservationQueryDisplayScenario.recognizing || + ReservationQueryDisplayScenario.loading || + ReservationQueryDisplayScenario.success => + XfjDesignTokens.primary, + ReservationQueryDisplayScenario.empty || + ReservationQueryDisplayScenario.expired => + XfjDesignTokens.warning, + ReservationQueryDisplayScenario.error => XfjDesignTokens.danger, + }; diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index cecc525..6ddba2f 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -106,25 +106,17 @@ class _CustomerDisplayView extends StatelessWidget { child: SafeArea( child: Padding( padding: EdgeInsets.all(padding), - child: Column( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, 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), - ), - ], - ), + flex: 12, + child: _ProductPanel(state: state, scale: scale), + ), + SizedBox(width: gap), + Expanded( + flex: 8, + child: _SettlementPanel(state: state, scale: scale), ), ], ), @@ -136,116 +128,6 @@ class _CustomerDisplayView extends StatelessWidget { } } -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 { /// 创建读取当前顾客屏状态的商品核对面板。 @@ -267,10 +149,10 @@ class _ProductPanel extends StatelessWidget { children: [ Padding( padding: EdgeInsets.fromLTRB( + 16 * scale, 14 * scale, + 16 * scale, 12 * scale, - 14 * scale, - 10 * scale, ), child: Row( children: [ @@ -286,11 +168,11 @@ class _ProductPanel extends StatelessWidget { : '请核对商品信息', style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(34, scale), + fontSize: _fontSize(40, scale), fontWeight: FontWeight.w800, ), ), - SizedBox(height: 2 * scale), + SizedBox(height: 4 * scale), Text( state.products.isEmpty ? '商品加入购物车后将在这里实时展示' @@ -299,7 +181,7 @@ class _ProductPanel extends StatelessWidget { : '本次消费共 ${state.products.length} 项商品', style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(22, scale), + fontSize: _fontSize(26, scale), ), ), ], @@ -330,8 +212,8 @@ class _ProductPanel extends StatelessWidget { Container( width: double.infinity, padding: EdgeInsets.symmetric( - horizontal: 14 * scale, - vertical: 8 * scale, + horizontal: 16 * scale, + vertical: 10 * scale, ), decoration: const BoxDecoration( color: Color(0xFFFAFAFB), @@ -343,7 +225,7 @@ class _ProductPanel extends StatelessWidget { : '商品和金额由收银员实时更新,如有疑问请及时告知收银员', style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(18, scale), + fontSize: _fontSize(22, scale), ), ), ), @@ -365,8 +247,8 @@ class _EmptyProducts extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 92 * scale, - height: 92 * scale, + width: 104 * scale, + height: 104 * scale, alignment: Alignment.center, decoration: const BoxDecoration( color: XfjDesignTokens.primarySoft, @@ -375,24 +257,24 @@ class _EmptyProducts extends StatelessWidget { child: Icon( Icons.shopping_basket_outlined, color: XfjDesignTokens.primary, - size: 46 * scale, + size: 52 * scale, ), ), - SizedBox(height: 18 * scale), + SizedBox(height: 20 * scale), Text( '等待添加商品', style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(30, scale), + fontSize: _fontSize(36, scale), fontWeight: FontWeight.w800, ), ), - SizedBox(height: 6 * scale), + SizedBox(height: 8 * scale), Text( '请将商品交给收银员进行结算', style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(20, scale), + fontSize: _fontSize(24, scale), ), ), ], @@ -413,8 +295,8 @@ class _ProductHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - height: 42 * scale, - padding: EdgeInsets.symmetric(horizontal: 10 * scale), + height: 50 * scale, + padding: EdgeInsets.symmetric(horizontal: 12 * scale), color: const Color(0xFFFAFAFB), child: Row( children: [ @@ -428,72 +310,57 @@ class _ProductHeader extends StatelessWidget { } } -/// 副屏商品列表中的单条商品记录,可选展示套餐组成。 +/// 副屏商品列表中的单条商品记录。 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), + // 单品行按当前副屏可视区容纳 8 条设置高度,字号放大后通过收紧内边距避免溢出。 + constraints: BoxConstraints(minHeight: 94 * scale), child: Padding( padding: - EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 3 * scale), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, + EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale), + child: Row( children: [ - Row( - children: [ - Expanded( - flex: 5, - child: Row( - children: [ - _ProductImage(product: product, scale: scale), - SizedBox(width: 6 * scale), - _ProductTypeBadge( - isCombo: product.isCombo, - scale: scale, + Expanded( + flex: 5, + child: Row( + children: [ + _ProductImage(product: product, scale: scale), + SizedBox(width: 12 * scale), + Expanded( + child: Text( + product.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: _fontSize(34, scale), + fontWeight: FontWeight.w700, ), - 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, - ), - ], + ], + ), + ), + _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), - ], ], ), ), @@ -516,13 +383,13 @@ class _ProductImage extends StatelessWidget { @override Widget build(BuildContext context) { final imageSource = product.imageSource; - final imageSize = 60 * scale; + final imageSize = 76 * scale; final placeholder = _buildPlaceholder(); return Semantics( image: true, label: '${product.name}商品图片', child: ClipRRect( - borderRadius: BorderRadius.circular(7 * scale), + borderRadius: BorderRadius.circular(8 * scale), child: SizedBox( key: ValueKey('customer-product-image-${product.name}'), width: imageSize, @@ -552,7 +419,7 @@ class _ProductImage extends StatelessWidget { shortLabel, style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(22, scale), + fontSize: _fontSize(28, scale), fontWeight: FontWeight.w800, ), ), @@ -561,155 +428,6 @@ class _ProductImage extends StatelessWidget { } } -/// 副屏主商品类型标识,帮助顾客快速区分单品和套餐。 -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, @@ -724,7 +442,7 @@ Widget _tableText( textAlign: align, style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(20, scale), + fontSize: _fontSize(24, scale), fontWeight: FontWeight.w700, ), ), @@ -745,7 +463,7 @@ Widget _valueCell( textAlign: TextAlign.center, style: TextStyle( color: emphasized ? XfjDesignTokens.ink : XfjDesignTokens.muted, - fontSize: _fontSize(26, scale), + fontSize: _fontSize(31, scale), fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], ), @@ -762,7 +480,7 @@ class _SettlementPanel extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.all(16 * scale), + padding: EdgeInsets.all(18 * scale), decoration: _surfaceDecoration(radius: 16 * scale), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -771,7 +489,7 @@ class _SettlementPanel extends StatelessWidget { '实际扣款', style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(20, scale), + fontSize: _fontSize(24, scale), fontWeight: FontWeight.w700, ), ), @@ -781,15 +499,15 @@ class _SettlementPanel extends StatelessWidget { key: const ValueKey('customer-payable-price'), style: TextStyle( color: XfjDesignTokens.primary, - fontSize: _fontSize(64, scale), + fontSize: _fontSize(76, scale), height: 1.05, fontWeight: FontWeight.w900, fontFeatures: const [FontFeature.tabularFigures()], ), ), - SizedBox(height: 12 * scale), + SizedBox(height: 14 * scale), _PriceLine(label: '商品总价', price: state.totalPrice, scale: scale), - SizedBox(height: 7 * scale), + SizedBox(height: 8 * scale), _PriceLine( label: '额外支付', price: state.amountMealPrice, @@ -828,7 +546,7 @@ class _PriceLine extends StatelessWidget { label, style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(22, scale), + fontSize: _fontSize(26, scale), fontWeight: FontWeight.w600, ), ), @@ -836,7 +554,7 @@ class _PriceLine extends StatelessWidget { _money(price), style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(26, scale), + fontSize: _fontSize(31, scale), fontWeight: FontWeight.w800, fontFeatures: const [FontFeature.tabularFigures()], ), @@ -898,31 +616,31 @@ class _PaymentStatePanel extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(34, scale), + fontSize: _fontSize(40, scale), height: 1, fontWeight: FontWeight.w900, ), ), - SizedBox(height: 6 * scale), + SizedBox(height: 8 * scale), Text( _countdown(state.countdownSeconds), key: const ValueKey('customer-payment-countdown'), style: TextStyle( color: XfjDesignTokens.danger, - fontSize: _fontSize(44, scale), + fontSize: _fontSize(50, scale), height: 1, fontWeight: FontWeight.w900, fontFeatures: const [FontFeature.tabularFigures()], ), ), - SizedBox(height: 5 * scale), + SizedBox(height: 7 * scale), Text( '支付完成后请勿重复扫码', maxLines: compactPaymentHint ? 1 : 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(20, scale), + fontSize: _fontSize(24, scale), height: 1.2, ), ), @@ -948,8 +666,8 @@ class _PaymentStatePanel extends StatelessWidget { ), child: [ Container( - width: 86 * scale, - height: 86 * scale, + width: 96 * scale, + height: 96 * scale, alignment: Alignment.center, decoration: BoxDecoration( color: successColor, @@ -958,14 +676,14 @@ class _PaymentStatePanel extends StatelessWidget { child: Icon( Icons.check_rounded, color: Colors.white, - size: 50 * scale, + size: 56 * scale, ), ), Text( '支付成功', style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(52, scale), + fontSize: _fontSize(60, scale), height: 1, fontWeight: FontWeight.w900, ), @@ -974,7 +692,7 @@ class _PaymentStatePanel extends StatelessWidget { '请带好您的商品', style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(27, scale), + fontSize: _fontSize(32, scale), height: 1.15, fontWeight: FontWeight.w700, ), @@ -986,7 +704,7 @@ class _PaymentStatePanel extends StatelessWidget { text: '${state.countdownSeconds}', style: TextStyle( color: successColor, - fontSize: _fontSize(48, scale), + fontSize: _fontSize(54, scale), fontWeight: FontWeight.w900, ), ), @@ -994,7 +712,7 @@ class _PaymentStatePanel extends StatelessWidget { text: ' 秒后自动清屏', style: TextStyle( color: successColor, - fontSize: _fontSize(25, scale), + fontSize: _fontSize(29, scale), fontWeight: FontWeight.w800, ), ), @@ -1029,26 +747,26 @@ class _PaymentStatePanel extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 68 * scale, - height: 68 * scale, + width: 78 * scale, + height: 78 * scale, alignment: Alignment.center, decoration: BoxDecoration( color: visual.color, shape: BoxShape.circle, ), - child: Icon(visual.icon, color: Colors.white, size: 36 * scale), + child: Icon(visual.icon, color: Colors.white, size: 42 * scale), ), - SizedBox(height: 10 * scale), + SizedBox(height: 12 * scale), Text( visual.title, style: TextStyle( color: XfjDesignTokens.ink, - fontSize: _fontSize(36, scale), + fontSize: _fontSize(42, scale), height: 1, fontWeight: FontWeight.w900, ), ), - SizedBox(height: 5 * scale), + SizedBox(height: 7 * scale), Padding( padding: EdgeInsets.symmetric(horizontal: 20 * scale), child: Text( @@ -1058,7 +776,7 @@ class _PaymentStatePanel extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: XfjDesignTokens.muted, - fontSize: _fontSize(22, scale), + fontSize: _fontSize(26, scale), height: 1.2, ), ), @@ -1112,8 +830,8 @@ class _Step extends StatelessWidget { child: Column( children: [ Container( - width: 30 * scale, - height: 30 * scale, + width: 34 * scale, + height: 34 * scale, alignment: Alignment.center, decoration: BoxDecoration( color: active ? XfjDesignTokens.primary : XfjDesignTokens.control, @@ -1123,17 +841,17 @@ class _Step extends StatelessWidget { '$index', style: TextStyle( color: active ? Colors.white : XfjDesignTokens.muted, - fontSize: _fontSize(18, scale), + fontSize: _fontSize(21, scale), fontWeight: FontWeight.w800, ), ), ), - SizedBox(height: 5 * scale), + SizedBox(height: 6 * scale), Text( label, style: TextStyle( color: active ? XfjDesignTokens.ink : XfjDesignTokens.muted, - fontSize: _fontSize(18, scale), + fontSize: _fontSize(22, scale), fontWeight: FontWeight.w600, ), ), @@ -1159,20 +877,20 @@ class _StatusBadge extends StatelessWidget { Widget build(BuildContext context) { final visual = _scenarioVisual(scenario); return Container( - padding: EdgeInsets.symmetric(horizontal: 8 * scale, vertical: 4 * scale), + padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale), decoration: BoxDecoration( color: visual.color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(18 * scale), + borderRadius: BorderRadius.circular(20 * scale), ), child: Row( children: [ - Icon(visual.icon, color: visual.color, size: 20 * scale), - SizedBox(width: 4 * scale), + Icon(visual.icon, color: visual.color, size: 24 * scale), + SizedBox(width: 6 * scale), Text( scenario.title, style: TextStyle( color: visual.color, - fontSize: _fontSize(20, scale), + fontSize: _fontSize(24, scale), fontWeight: FontWeight.w700, ), ), diff --git a/lib/sub_screen/page/customer_welcome.dart b/lib/sub_screen/page/customer_welcome.dart index 6477058..5865639 100644 --- a/lib/sub_screen/page/customer_welcome.dart +++ b/lib/sub_screen/page/customer_welcome.dart @@ -1,67 +1,20 @@ -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 { +/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局; +/// 顶部状态栏由 [SubScreenShell] 统一提供,本页只负责欢迎插图区。 +class CustomerWelcomePage extends StatelessWidget { 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) { @@ -69,15 +22,13 @@ class _CustomerWelcomePageState extends State { backgroundColor: XfjDesignTokens.canvas, body: Stack( children: [ - Positioned.fill( - child: _CustomerWelcomeContent(currentTime: _currentTime), - ), - if (widget.previewMode) + const Positioned.fill(child: _CustomerWelcomeContent()), + if (previewMode) Positioned( top: 16, right: 16, child: SafeArea( - child: _PreviewExitButton(onTap: widget.onExit), + child: _PreviewExitButton(onTap: onExit), ), ), ], @@ -87,9 +38,7 @@ class _CustomerWelcomePageState extends State { } class _CustomerWelcomeContent extends StatelessWidget { - const _CustomerWelcomeContent({required this.currentTime}); - - final DateTime currentTime; + const _CustomerWelcomeContent(); @override Widget build(BuildContext context) { @@ -103,21 +52,9 @@ class _CustomerWelcomeContent extends StatelessWidget { 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), - ), - ), - ], + child: Padding( + padding: EdgeInsets.all(24 * scale), + child: _WelcomeHero(scale: scale), ), ), ); @@ -126,136 +63,6 @@ class _CustomerWelcomeContent extends StatelessWidget { } } -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}); diff --git a/lib/sub_screen/page/face_recognition_customer_page.dart b/lib/sub_screen/page/face_recognition_customer_page.dart index 7a490c4..766cb37 100644 --- a/lib/sub_screen/page/face_recognition_customer_page.dart +++ b/lib/sub_screen/page/face_recognition_customer_page.dart @@ -9,6 +9,7 @@ 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'; +import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart'; /// 顾客副屏人脸页面。视频由副屏 Engine 自己申请 Texture,状态消息不承载任何视频帧或身份数据。 class FaceRecognitionCustomerPage extends StatefulWidget { @@ -103,30 +104,22 @@ class _FaceRecognitionCustomerPageState return SafeArea( child: Padding( padding: EdgeInsets.all(24 * scale), - child: Column( + child: Row( 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), - ), - ], + 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), + ), ], ), ), @@ -139,64 +132,6 @@ class _FaceRecognitionCustomerPageState } } -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, @@ -276,7 +211,7 @@ class _GuidePanel extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _statusColor(state.status); + final color = subScreenFaceStatusColor(state.status); final reservation = state.scene == FaceRecognitionDisplayScene.reservation; return Container( padding: EdgeInsets.all(28 * scale), @@ -292,12 +227,15 @@ class _GuidePanel extends StatelessWidget { color: color.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: - Icon(_statusIcon(state.status), color: color, size: 44 * scale), + child: Icon( + subScreenFaceStatusIcon(state.status), + color: color, + size: 44 * scale, + ), ), SizedBox(height: 22 * scale), Text( - _statusTitle(state.status), + subScreenFaceStatusTitle(state.status), style: TextStyle( color: XfjDesignTokens.ink, fontSize: 34 * scale, @@ -428,41 +366,6 @@ class _AmountCard extends StatelessWidget { } } -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), @@ -470,29 +373,4 @@ BoxDecoration _surfaceDecoration(double radius) => BoxDecoration( 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/reservation_query_display.dart b/lib/sub_screen/page/reservation_query_display.dart index ca7a556..757c60f 100644 --- a/lib/sub_screen/page/reservation_query_display.dart +++ b/lib/sub_screen/page/reservation_query_display.dart @@ -6,6 +6,7 @@ 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'; +import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart'; /// 真实副屏和 Debug 预览共用的预约菜单查询屏。 class ReservationQueryDisplayPage extends StatelessWidget { @@ -102,8 +103,6 @@ class _ReservationQueryDisplayView extends StatelessWidget { padding: EdgeInsets.all(24 * scale), child: Column( children: [ - _ReservationHeader(state: state, scale: scale), - SizedBox(height: 14 * scale), Expanded( child: state.continuousFaceRecognition ? _ContinuousFaceReservationBody( @@ -124,128 +123,6 @@ class _ReservationQueryDisplayView extends StatelessWidget { } } -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 { /// 创建与预约快照同步的持续人脸组合布局。 @@ -547,7 +424,7 @@ class _ReservationStatePanel extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _scenarioColor(state.scenario); + final color = subScreenReservationScenarioColor(state.scenario); final title = switch (state.scenario) { ReservationQueryDisplayScenario.idle => '请刷饭卡或进行人脸识别', ReservationQueryDisplayScenario.recognizing => '正在识别下一位用户', @@ -593,7 +470,7 @@ class _ReservationStatePanel extends StatelessWidget { ), ) : Icon( - _scenarioIcon(state.scenario), + subScreenReservationScenarioIcon(state.scenario), color: color, size: _fontSize(60, scale), ), @@ -680,33 +557,6 @@ class _ReservationFooter extends StatelessWidget { } } -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, diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart index 57ff916..dc2d6fa 100644 --- a/lib/sub_screen/page/sub_app.dart +++ b/lib/sub_screen/page/sub_app.dart @@ -5,7 +5,10 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.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'; @@ -57,21 +60,122 @@ class _SubAppPageState extends State { 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, - ), - }; + // 与主屏终端外壳一致:顶栏常驻并随模式切换标题,页面只负责工作区。 + return SubScreenShell( + title: _modeTitle(), + subtitle: _modeSubtitle(), + trailingBuilder: _modeTrailing, + child: 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, + ), + }, + ); }, ), ), ); } + + String _modeTitle() { + switch (_coordinator.mode) { + case SubDisplayMode.welcome: + return '欢迎光临'; + case SubDisplayMode.settlement: + return _coordinator.customerCoordinator.state.businessTitle; + case SubDisplayMode.reservationQuery: + return '预约查询'; + case SubDisplayMode.faceRecognition: + final faceState = _coordinator.faceRecognitionCoordinator.state; + return faceState?.scene == FaceRecognitionDisplayScene.reservation + ? '本人预约查询' + : '人脸支付'; + } + } + + String? _modeSubtitle() { + if (_coordinator.mode != SubDisplayMode.reservationQuery) return null; + final state = _coordinator.reservationCoordinator.state; + return state.merchantName ?? '请使用饭卡或人脸识别本人预约'; + } + + List _modeTrailing(double scale) { + switch (_coordinator.mode) { + case SubDisplayMode.settlement: + return _customerTrailing(scale); + case SubDisplayMode.reservationQuery: + return _reservationTrailing(scale); + case SubDisplayMode.faceRecognition: + return _faceTrailing(scale); + case SubDisplayMode.welcome: + return const []; + } + } + + /// 已识别到的顾客在顶栏展示脱敏姓名和手机号,副屏永不显示完整个人信息。 + List _customerTrailing(double scale) { + final state = _coordinator.customerCoordinator.state; + final name = state.maskedCustomerName; + final phone = state.maskedCustomerPhone; + if (name == null && phone == null) return const []; + return [ + if (name != null) + SubScreenHeaderPill( + icon: Icons.person_outline_rounded, + label: '$name,您好', + color: XfjDesignTokens.primary, + scale: scale, + ), + if (phone != null) + SubScreenHeaderPill( + icon: Icons.call_outlined, + label: phone, + color: XfjDesignTokens.primary, + scale: scale, + ), + ]; + } + + List _reservationTrailing(double scale) { + final state = _coordinator.reservationCoordinator.state; + final nameParts = [state.maskedName, state.deptName] + .whereType() + .where((value) => value.trim().isNotEmpty) + .toList(); + return [ + if (nameParts.isNotEmpty) + SubScreenHeaderPill( + icon: Icons.person_outline_rounded, + label: nameParts.join(' · '), + color: XfjDesignTokens.primary, + scale: scale, + ), + SubScreenHeaderPill( + icon: subScreenReservationScenarioIcon(state.scenario), + label: state.scenario.title, + color: subScreenReservationScenarioColor(state.scenario), + scale: scale, + ), + ]; + } + + List _faceTrailing(double scale) { + final faceState = _coordinator.faceRecognitionCoordinator.state; + if (faceState == null) return const []; + return [ + SubScreenHeaderPill( + icon: subScreenFaceStatusIcon(faceState.status), + label: subScreenFaceStatusTitle(faceState.status), + color: subScreenFaceStatusColor(faceState.status), + scale: scale, + ), + ]; + } }