1368 lines
44 KiB
Dart
1368 lines
44 KiB
Dart
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,
|
||
),
|
||
),
|
||
if (state.printStatus != null)
|
||
_SuccessPrintStatusBadge(
|
||
status: state.printStatus!,
|
||
scale: scale,
|
||
),
|
||
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 _SuccessPrintStatusBadge extends StatelessWidget {
|
||
/// 创建与当前打印阶段对应的进度胶囊。
|
||
const _SuccessPrintStatusBadge({required this.status, required this.scale});
|
||
|
||
/// 主屏同步的发票打印阶段。
|
||
final CustomerPrintStatus status;
|
||
|
||
/// 依据副屏分辨率计算的统一缩放比例。
|
||
final double scale;
|
||
|
||
(Color, IconData?, String, bool) get _visual => switch (status) {
|
||
CustomerPrintStatus.preparing => (
|
||
XfjDesignTokens.primary,
|
||
null,
|
||
'发票打印准备中…',
|
||
true
|
||
),
|
||
CustomerPrintStatus.printing => (
|
||
XfjDesignTokens.primary,
|
||
null,
|
||
'发票打印中,请稍候…',
|
||
true
|
||
),
|
||
CustomerPrintStatus.done => (
|
||
XfjDesignTokens.success,
|
||
Icons.print_rounded,
|
||
'请取走您的发票',
|
||
false
|
||
),
|
||
CustomerPrintStatus.timeout => (
|
||
XfjDesignTokens.warning,
|
||
Icons.schedule_rounded,
|
||
'发票仍在打印,可稍后领取',
|
||
false
|
||
),
|
||
CustomerPrintStatus.failed => (
|
||
XfjDesignTokens.warning,
|
||
Icons.error_outline_rounded,
|
||
'发票打印失败,请联系收银员',
|
||
false
|
||
),
|
||
CustomerPrintStatus.offline => (
|
||
XfjDesignTokens.warning,
|
||
Icons.power_off_rounded,
|
||
'打印机未连接,连接后自动打印',
|
||
false
|
||
),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final (color, icon, label, spinning) = _visual;
|
||
return Container(
|
||
key: const ValueKey('customer-success-print-status'),
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 14 * scale,
|
||
vertical: 8 * scale,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.10),
|
||
border: Border.all(color: color.withValues(alpha: 0.32)),
|
||
borderRadius: BorderRadius.circular(26 * scale),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (spinning)
|
||
SizedBox(
|
||
width: 22 * scale,
|
||
height: 22 * scale,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.6,
|
||
color: color,
|
||
),
|
||
)
|
||
else
|
||
Icon(icon, color: color, size: 22 * scale),
|
||
SizedBox(width: 9 * scale),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: _fontSize(24, scale),
|
||
height: 1.15,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 商品面板顶部的结算阶段标签,用图标和文字同时表达当前状态。
|
||
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')}';
|
||
}
|