Files
biz_xfj_cloud/lib/sub_screen/page/customer_display.dart
T
dzc 8cfbbd768e feat:"收银"页面副屏的支付成功状态。
feat:"收银"弹框增加重新识别功能。
2026-08-14 18:41:13 +08:00

1270 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<int>(
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: <Widget>[
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')}';
}