Files
biz_xfj_cloud/lib/sub_screen/page/customer_display.dart
T
2026-07-24 15:27:20 +08:00

893 lines
26 KiB
Dart

import 'dart:math' as math;
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/sub_screen/common/models/customer_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.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, _) => _CustomerDisplayView(
state: coordinator.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: 76 * scale,
padding: EdgeInsets.symmetric(horizontal: 22 * 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: 24 * scale,
fontWeight: FontWeight.w900,
),
),
),
SizedBox(width: 14 * scale),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.businessTitle,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 22 * scale,
fontWeight: FontWeight.w800,
),
),
Text(
'顾客显示屏',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * scale,
fontWeight: FontWeight.w500,
),
),
],
),
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: 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(
children: [
Icon(icon, color: color, size: 20 * scale),
SizedBox(width: 8 * scale),
Text(
label,
style: TextStyle(
color: color,
fontSize: 15 * scale,
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) {
return Container(
decoration: _surfaceDecoration(radius: 16 * scale),
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
22 * scale,
18 * scale,
22 * scale,
14 * scale,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.products.isEmpty ? '欢迎光临' : '请核对商品信息',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 24 * scale,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 4 * scale),
Text(
state.products.isEmpty
? '商品加入购物车后将在这里实时展示'
: '本次消费共 ${state.products.length} 项商品',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 15 * 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: 22 * scale,
vertical: 12 * scale,
),
decoration: const BoxDecoration(
color: Color(0xFFFAFAFB),
border: Border(top: BorderSide(color: XfjDesignTokens.line)),
),
child: Text(
'商品和金额由收银员实时更新,如有疑问请及时告知收银员',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * 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: 24 * scale,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 6 * scale),
Text(
'请将商品交给收银员进行结算',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 16 * scale,
),
),
],
),
);
}
}
class _ProductHeader extends StatelessWidget {
const _ProductHeader({required this.scale});
final double scale;
@override
Widget build(BuildContext context) {
return Container(
height: 48 * scale,
padding: EdgeInsets.symmetric(horizontal: 22 * 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 Container(
constraints: BoxConstraints(minHeight: 74 * scale),
padding:
EdgeInsets.symmetric(horizontal: 22 * scale, vertical: 10 * scale),
child: Row(
children: [
Expanded(
flex: 5,
child: Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 18 * scale,
fontWeight: FontWeight.w700,
),
),
),
_valueCell(product.quantityLabel, 2, scale),
_valueCell(_money(product.unitPrice), 2, scale),
_valueCell(
_money(product.totalPrice),
2,
scale,
emphasized: true,
),
],
),
);
}
}
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: 14 * 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: 17 * 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(20 * scale),
decoration: _surfaceDecoration(radius: 16 * scale),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'实际扣款',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 15 * scale,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 6 * scale),
Text(
_money(state.payablePrice),
key: const ValueKey('customer-payable-price'),
style: TextStyle(
color: XfjDesignTokens.primary,
fontSize: 54 * 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: 16 * scale,
),
),
Text(
_money(price),
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 18 * 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,
);
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(
'请扫码支付',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 26 * scale,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 10 * scale),
Text(
_countdown(state.countdownSeconds),
key: const ValueKey('customer-payment-countdown'),
style: TextStyle(
color: XfjDesignTokens.danger,
fontSize: 34 * scale,
fontWeight: FontWeight.w900,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
SizedBox(height: 8 * scale),
Text(
'支付完成后请勿重复扫码',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * scale,
height: 1.4,
),
),
],
),
),
],
);
},
);
}
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: 76 * scale,
height: 76 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: visual.color,
shape: BoxShape.circle,
),
child: Icon(visual.icon, color: Colors.white, size: 40 * scale),
),
SizedBox(height: 16 * scale),
Text(
visual.title,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 28 * scale,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 7 * scale),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20 * scale),
child: Text(
visual.description,
textAlign: TextAlign.center,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 15 * scale,
height: 1.45,
),
),
),
],
),
);
}
}
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: 14 * scale,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(height: 5 * scale),
Text(
label,
style: TextStyle(
color: active ? XfjDesignTokens.ink : XfjDesignTokens.muted,
fontSize: 13 * 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: 12 * scale, vertical: 7 * 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: 17 * scale),
SizedBox(width: 6 * scale),
Text(
scenario.title,
style: TextStyle(
color: visual.color,
fontSize: 14 * 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)}';
String _countdown(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}