diff --git a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart index 63118a5..9c2df01 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart @@ -361,18 +361,10 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } - /// 构建用餐人、食堂、日期和餐段四项查询摘要。 + /// 构建摘要区:用餐人以蓝底主卡占据左侧大权重位,食堂、日期和餐段平分剩余宽度。 Widget _summary(FThemeData theme) { final deptName = widget.result.deptName?.trim(); - final information = <({SvgAsset icon, String label, String value})>[ - ( - icon: FAssets.icons.user, - label: '用餐人', - value: [ - widget.result.maskedName, - if (deptName?.isNotEmpty == true) deptName!, - ].join(' · '), - ), + final metas = <({SvgAsset icon, String label, String value})>[ ( icon: FAssets.icons.store, label: '食堂', @@ -389,47 +381,124 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { value: '${widget.meal.title} · ${widget.meal.orderIds.length} 笔预约', ), ]; - return information - .map((item) => _summaryItem(theme, item)) - .toList() - .toRowSpace(space: 10.w) - .padding(horizontal: 24.w, vertical: 14.h) - .decorated(color: XfjDesignTokens.canvas); + return IntrinsicHeight( + // 摘要行处于无界高度的弹框列内,IntrinsicHeight 让 stretch 按最高卡片对齐, + // 避免横轴 Row 直接把无穷高度约束传给子卡片。 + child: [ + Expanded(flex: 17, child: _dinerHero(theme, deptName)), + ...metas.expand( + (meta) => [ + SizedBox(width: XfjDesignTokens.cardGap * 1.5), + Expanded(flex: 10, child: _summaryMetaCard(theme, meta)), + ], + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(horizontal: 24.w, vertical: 12.h) + .decorated(color: XfjDesignTokens.canvas), + ); } - /// 构建单项摘要并对过长内容执行省略,避免挤压相邻信息。 - Widget _summaryItem( + /// 用餐人主卡是摘要区唯一着色卡片:白圈大头像 + 大号姓名 + 身份确认角标,内容整体居中。 + Widget _dinerHero(FThemeData theme, String? deptName) { + final name = widget.result.maskedName; + final hasDept = deptName?.isNotEmpty == true; + return Semantics( + container: true, + label: '用餐人 $name${hasDept ? ',$deptName' : ''}', + child: [ + Container( + width: 52.w, + height: 52.w, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: XfjDesignTokens.surface, + shape: BoxShape.circle, + ), + child: FIcon( + FAssets.icons.user, + color: XfjDesignTokens.primary, + size: 28.sp, + ), + ), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ), + FIcon( + FAssets.icons.circleCheck, + color: XfjDesignTokens.success, + size: 26.sp, + ), + ] + .toRowSpace( + space: 14.w, + mainAxisAlignment: MainAxisAlignment.center, + ) + .padding(horizontal: 16.w, vertical: 12.h) + .decorated( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + ); + } + + /// 单条次要元信息沿用“图标 + 标签 + 值”的白底小卡,内容整体居中对齐。 + Widget _summaryMetaCard( FThemeData theme, ({SvgAsset icon, String label, String value}) item, ) { return [ - FIcon(item.icon, color: XfjDesignTokens.primary, size: 18.sp) + FIcon(item.icon, color: XfjDesignTokens.primary, size: 22.sp) .center() - .tightSize(34.w), - [ - Text( - item.label, - style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), - ), - Text( - item.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w700, + .tightSize(44.w) + .decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(10.w), ), + Flexible( + child: [ + Text( + item.label, + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 2.h), + Text( + item.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ].toColumn( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, ), - ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ), ] - .toRowSpace(space: 8.w) - .padding(horizontal: 10.w, vertical: 8.h) + .toRowSpace( + space: 12.w, + mainAxisAlignment: MainAxisAlignment.center, + ) + .padding(horizontal: 10.w, vertical: 10.h) .decorated( color: XfjDesignTokens.surface, border: Border.all(color: XfjDesignTokens.line), borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - ) - .expanded(); + ); } /// 构建包含“全部”和逐笔预约的左侧导航,核销模式额外提供复选框。 diff --git a/test/pages/goldens/reservation_verification_dialog_preview.png b/test/pages/goldens/reservation_verification_dialog_preview.png new file mode 100644 index 0000000..a2ba9b5 Binary files /dev/null and b/test/pages/goldens/reservation_verification_dialog_preview.png differ diff --git a/test/pages/reservation_verification_dialog_preview_test.dart b/test/pages/reservation_verification_dialog_preview_test.dart new file mode 100644 index 0000000..2061573 --- /dev/null +++ b/test/pages/reservation_verification_dialog_preview_test.dart @@ -0,0 +1,158 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FontLoader; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart'; + +/// 加载系统内置的中文字体,保证 Golden 预览图渲染真实文案而不是测试占位方块。 +Future _loadCjkFont() async { + final file = File('/System/Library/Fonts/Supplemental/Arial Unicode.ttf'); + final bytes = await file.readAsBytes(); + final loader = FontLoader('PreviewCJK') + ..addFont(Future.value(ByteData.view(bytes.buffer))); + await loader.load(); +} + +/// 以终端设计分辨率渲染确认核销弹框,输出顶部摘要区的视觉预览 Golden 图。 +void main() { + // 字体加载是真实异步引擎回调,必须放在 testWidgets 的 fake-async 区之外,否则永远挂起。 + setUpAll(_loadCjkFont); + + testWidgets('确认核销弹框摘要区预览', (tester) async { + final sw = Stopwatch()..start(); + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + + const result = ReservationMenuDisplayData( + maskedName: '邓樟晨', + deptName: '青鸟同文学校', + merchantName: '青鸟同文', + totalDayCount: 1, + totalMealCount: 1, + days: [ + ReservationMenuDayData( + date: '2026-09-22', + meals: [ + ReservationMenuMealData( + hourType: 'BREAKFAST', + title: '早餐', + items: ['鲜肉包', '豆浆', '学生营养早餐套餐'], + orderIds: [900912345678], + orders: [ + ReservationMenuOrderData( + orderId: 900912345678, + items: [ + ReservationMenuOrderItemData( + productId: 101, + productName: '鲜肉包', + productCount: 2, + ), + ReservationMenuOrderItemData( + productId: 102, + productName: '学生营养早餐套餐', + productCount: 1, + spuType: 2, + comboItems: [ + ReservationMenuComboItemData( + childSpuId: 201, + name: '小米粥', + quantity: 1, + price: 200, + ), + ReservationMenuComboItemData( + childSpuId: 202, + name: '茶叶蛋', + quantity: 1, + price: 150, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + debugPrint('[preview] pumping base app'); + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + builder: (_, __) { + return FTheme( + // 与 app.dart 根主题同口径:Forui 预置浅色底 + Xfj 语义令牌覆盖。 + // typography 用默认构造去掉 forui 强制的 Inter 字体, + // 让中文文本继承 MaterialApp 的 PreviewCJK 字体(forui 的 Inter 无 CJK 字形)。 + data: FThemeData.inherit( + colorScheme: FThemes.zinc.light.colorScheme.copyWith( + background: XfjDesignTokens.surface, + foreground: XfjDesignTokens.ink, + primary: XfjDesignTokens.primary, + primaryForeground: Colors.white, + secondary: XfjDesignTokens.surface, + secondaryForeground: XfjDesignTokens.ink, + muted: XfjDesignTokens.canvas, + mutedForeground: XfjDesignTokens.muted, + destructive: XfjDesignTokens.danger, + destructiveForeground: Colors.white, + error: XfjDesignTokens.danger, + errorForeground: Colors.white, + border: XfjDesignTokens.line, + ), + typography: const FTypography(), + ), + child: MaterialApp( + theme: ThemeData(fontFamily: 'PreviewCJK'), + localizationsDelegates: FLocalizations.localizationsDelegates, + supportedLocales: FLocalizations.supportedLocales, + home: Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: Builder( + builder: (context) => Center( + child: FButton.raw( + onPress: () { + reservationOrderDialog( + context, + result: result, + day: result.days.first, + meal: result.days.first.meals.first, + verificationEnabled: true, + ); + }, + child: const Text('打开弹框'), + ), + ), + ), + ), + ), + ); + }, + ), + ); + + debugPrint('[preview] base app pumped ${sw.elapsedMilliseconds}ms'); + + await tester.tap(find.text('打开弹框')); + debugPrint('[preview] tapped ${sw.elapsedMilliseconds}ms'); + // 固定步进推进弹框 180ms 进场动画,避免潜在无限动画让 pumpAndSettle 挂起。 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + debugPrint('[preview] dialog settled ${sw.elapsedMilliseconds}ms'); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/reservation_verification_dialog_preview.png'), + ); + debugPrint('[preview] golden captured ${sw.elapsedMilliseconds}ms'); + }, timeout: const Timeout(Duration(minutes: 2))); +}