feat:核销突出姓名

This commit is contained in:
dzc committed 2026-09-22 10:45:28 +08:00
1 parent cad67b53b7
commit 22c4d4b9b9
3 files changed
+264 -37

No files matched your search

@@ -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: <Widget>[
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: <Widget>[
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 <Widget>[
FIcon(item.icon, color: XfjDesignTokens.primary, size: 18.sp)
FIcon(item.icon, color: XfjDesignTokens.primary, size: 22.sp)
.center()
.tightSize(34.w),
<Widget>[
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: <Widget>[
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();
);
}
/// 构建包含“全部”和逐笔预约的左侧导航,核销模式额外提供复选框。
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -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<void> _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)));
}