feat:保持当前订单的购买人员、饭卡等信息

This commit is contained in:
dzc committed 2026-09-10 16:09:24 +08:00
1 parent a81a79b69f
commit e1d41c5370
13 files changed
+631 -896

No files matched your search

+13 -1
View File
@@ -133,16 +133,25 @@ abstract class AppSettingTool {
}
static Future<void> removeSettingCache() async {
_memorySetting = null;
await _settingCache.emptyCache();
}
/// setting 单行记录的进程内副本;所有写入路径经 _cacheSetting 同步刷新,
/// 读取优先命中内存,避免每个 HTTP 请求与支付链路反复落盘读文件再解析。
static SettingEntity? _memorySetting;
static Future<SettingEntity> _getSetting() async {
// 尝试从缓存获取
final memoryCached = _memorySetting;
if (memoryCached != null) return memoryCached;
final fileInfo = await _settingCache.getFileFromCache(_settingKey);
if (fileInfo != null) {
final content = await fileInfo.file.readAsString();
if (ObjectUtil.isNotEmpty(content)) {
return SettingEntity.fromJson(json.decode(content));
final setting = SettingEntity.fromJson(json.decode(content));
_memorySetting = setting;
return setting;
}
}
final app = Get.find<AppControl>();
@@ -167,8 +176,10 @@ abstract class AppSettingTool {
throw Exception("|$_tag|_getSetting新增错误");
}
addObj.id = addId;
_memorySetting = addObj;
return addObj;
}
_memorySetting = setting;
return setting;
}
@@ -220,6 +231,7 @@ abstract class AppSettingTool {
/// 刷新 setting 单行缓存;调用方必须已经完成数据库写入并持有串行写入队列。
static Future<void> _cacheSetting(SettingEntity setting) async {
_memorySetting = setting;
await _settingCache.putFile(
_settingKey,
Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))),
@@ -203,9 +203,19 @@ class SelectPayTypeDialogController extends GetxController {
}
/// 使用弹框打开瞬间的交易快照初始化金额和支付方式。
void initPayType({required CashierCheckoutSnapshot snapshot}) {
///
/// [initialUser] 是业务层仍保留的本单人员:取消弹框后再次打开时用来恢复已识别
/// 人员和钱包展示,免去重新识别。恢复发生在应用默认支付方式之前,线下方式依旧
/// 按既有规则把人员替换为匿名外部用户。
void initPayType({
required CashierCheckoutSnapshot snapshot,
ServiceUser? initialUser,
}) {
_closeStatus();
updateCheckoutSnapshot(snapshot);
if (initialUser != null) {
payUser.value = initialUser;
}
final defaultPayType = appSetting.defaultPayDisplayType;
if (defaultPayType != null) {
_applyPayDisplayType(defaultPayType);
@@ -223,6 +233,11 @@ class SelectPayTypeDialogController extends GetxController {
usePayDisplayTypeList.value = supportPayTypeList;
}
}
// 恢复的人员内嵌钱包可能滞后,按当前支付方式刷新一次钱包列表;
// 线下或匿名人员会在 _loadWalletListForUser 内被原有条件直接跳过。
if (initialUser != null) {
unawaited(_getWalletList());
}
}
/// 替换当前结算快照,并同步人脸会话创建和支付按钮使用的整数分金额。
@@ -1075,16 +1090,19 @@ typedef CashierCheckoutQuantityChanged = Future<CashierCheckoutSnapshot?>
Function(String productServiceUniqueCode, int quantity);
/// 打开收银结算弹框;空快照不会创建路由,避免无商品时进入支付方式选择。
///
/// [initialUser] 为业务层仍保留的本单人员,取消弹框后再次打开时恢复识别结果。
Future<void> selectPayTypeDialog({
bool paymentEnabled = true,
String? disabledMessage,
required CashierCheckoutSnapshot snapshot,
CashierCheckoutQuantityChanged? onQuantityChanged,
ServiceUser? initialUser,
}) async {
if (snapshot.isEmpty) return;
final payTypeC = Get.find<SelectPayTypeDialogController>();
final context = Get.context!;
payTypeC.initPayType(snapshot: snapshot);
payTypeC.initPayType(snapshot: snapshot, initialUser: initialUser);
final snapshotNotifier = ValueNotifier<CashierCheckoutSnapshot>(snapshot);
final viewport = MediaQuery.sizeOf(context);
final dialogWidth = viewport.width * 0.92;
@@ -135,6 +135,8 @@ class CanteenProductController extends GetxController with OrderMixin {
await selectPayTypeDialog(
snapshot: snapshot,
onQuantityChanged: _changeCheckoutQuantity,
// 取消结算后重开时恢复本单已识别人员与钱包展示。
initialUser: payUser.value,
);
}
@@ -94,6 +94,8 @@ class SupermarketOrderController extends GetxController with OrderMixin {
await selectPayTypeDialog(
snapshot: snapshot,
onQuantityChanged: _changeCheckoutQuantity,
// 取消结算后重开时恢复本单已识别人员与钱包展示。
initialUser: payUser.value,
);
}
+2
View File
@@ -11,3 +11,5 @@ export './routers/index.dart';
export './utils/index.dart';
export './values/constants.dart';
export './widgets/sub_screen_header.dart';
@@ -135,6 +135,7 @@ class CustomerDisplayState {
required this.totalPrice,
required this.amountMealPrice,
this.customerName,
this.customerPhone,
this.qrCodeData,
this.countdownSeconds = 0,
this.connected = true,
@@ -157,6 +158,7 @@ class CustomerDisplayState {
scenario: scenario,
businessTitle: '智慧超市',
customerName: '张小明',
customerPhone: '13812345678',
products: const [
CustomerDisplayProduct(
name: '农夫山泉 550ml',
@@ -199,6 +201,7 @@ class CustomerDisplayState {
final CustomerDisplayScenario scenario;
final String businessTitle;
final String? customerName;
final String? customerPhone;
final List<CustomerDisplayProduct> products;
final int totalPrice;
final int amountMealPrice;
@@ -216,6 +219,19 @@ class CustomerDisplayState {
return '${value[0]}*${value[value.length - 1]}';
}
/// 副屏永远不展示完整手机号;主屏任何字段异常时整体降级为星号。
String? get maskedCustomerPhone {
final value = customerPhone?.trim();
if (value == null || value.isEmpty) return null;
if (value.length >= 11) {
return '${value.substring(0, 3)}****${value.substring(value.length - 4)}';
}
if (value.length >= 5) {
return '${value.substring(0, 2)}****${value.substring(value.length - 2)}';
}
return '****';
}
CustomerDisplayState withScenario(
CustomerDisplayScenario next, {
String? qrCodeData,
@@ -225,6 +241,7 @@ class CustomerDisplayState {
scenario: next,
businessTitle: businessTitle,
customerName: customerName,
customerPhone: customerPhone,
products: products,
totalPrice: totalPrice,
amountMealPrice: amountMealPrice,
@@ -240,6 +257,7 @@ class CustomerDisplayState {
other.scenario == scenario &&
other.businessTitle == businessTitle &&
other.customerName == customerName &&
other.customerPhone == customerPhone &&
listEquals(other.products, products) &&
other.totalPrice == totalPrice &&
other.amountMealPrice == amountMealPrice &&
@@ -253,6 +271,7 @@ class CustomerDisplayState {
scenario,
businessTitle,
customerName,
customerPhone,
Object.hashAll(products),
totalPrice,
amountMealPrice,
@@ -197,6 +197,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier {
if (products.isEmpty) return CustomerDisplayState.idle();
String? customerName;
String? customerPhone;
final rawPayUser = data['payUser'];
if (rawPayUser != null) {
final payUser = _normalizeMap(rawPayUser);
@@ -204,12 +205,17 @@ class CustomerDisplayCoordinator extends ChangeNotifier {
final username = payUser['username'];
if (username != null && username is! String) return null;
customerName = username?.trim();
// ServiceUser.userMobile 随 payUser 整体透传;副屏只保留脱敏展示所需的原文。
final userMobile = payUser['userMobile'];
if (userMobile != null && userMobile is! String) return null;
customerPhone = userMobile?.trim();
}
return CustomerDisplayState(
scenario: CustomerDisplayScenario.review,
businessTitle: businessTitle,
customerName: customerName,
customerPhone: customerPhone,
products: List.unmodifiable(products),
totalPrice: totalPrice,
amountMealPrice: amountMealPrice,
@@ -0,0 +1,317 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
/// 副屏常驻顶部状态栏外壳,与主屏终端外壳(TerminalShell)同构:
/// PJ 品牌 + 当前模式标题常驻顶部,业务页面只负责顶栏下方的工作区。
///
/// 副屏 Engine 没有 ScreenUtil 与 GetX,这里沿用副屏页面的手算缩放,
/// 不引入 `.w`/`.h` 或主屏 Controller 依赖。
class SubScreenShell extends StatelessWidget {
const SubScreenShell({
super.key,
required this.title,
this.subtitle,
this.trailingBuilder,
required this.child,
});
/// 当前副屏模式展示在顶栏的标题。
final String title;
/// 标题下方可选的辅助说明,例如预约查询的商户名。
final String? subtitle;
/// 按顶栏缩放构建标题右侧的状态胶囊,例如识别状态或顾客信息。
final List<Widget> Function(double scale)? trailingBuilder;
/// 顶栏下方持续保留的业务页面。
final Widget child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final scale = subScreenHeaderScale(constraints);
return ColoredBox(
color: XfjDesignTokens.canvas,
child: SafeArea(
child: Column(
children: [
SubScreenHeader(
scale: scale,
title: title,
subtitle: subtitle,
trailing: trailingBuilder?.call(scale) ?? const [],
),
Expanded(child: child),
],
),
),
);
},
);
}
}
/// 与副屏业务页面一致的 1920×1080 设计稿缩放,供顶栏及其状态胶囊共用。
double subScreenHeaderScale(BoxConstraints constraints) {
return math
.min(constraints.maxWidth / 1920, constraints.maxHeight / 1080)
.clamp(0.55, 1.12)
.toDouble();
}
/// 副屏统一顶部状态栏:品牌、标题、状态胶囊与时钟一行排布。
class SubScreenHeader extends StatelessWidget {
const SubScreenHeader({
super.key,
required this.scale,
required this.title,
this.subtitle,
this.trailing = const [],
});
final double scale;
final String title;
final String? subtitle;
final List<Widget> trailing;
@override
Widget build(BuildContext context) {
return Container(
// 高度给保底值:小尺寸副屏文字有 0.9 字号下限,纯按 scale 收缩会裁切文字。
height: subtitle == null
? math.max(96 * scale, 68)
: math.max(108 * scale, 80),
padding: EdgeInsets.symmetric(horizontal: 28 * scale),
decoration: const BoxDecoration(
color: XfjDesignTokens.surface,
border: Border(bottom: BorderSide(color: XfjDesignTokens.line)),
),
child: Row(
children: [
Text(
'PJ',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 46 * subScreenTextScale(scale),
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
letterSpacing: -1.8 * scale,
height: 1,
),
),
SizedBox(width: 22 * scale),
Container(
width: 1,
height: math.max(44 * scale, 34),
color: XfjDesignTokens.line,
),
SizedBox(width: 22 * scale),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 32 * subScreenTextScale(scale),
fontWeight: FontWeight.w800,
height: 1.15,
),
),
if (subtitle != null) ...[
SizedBox(height: 4 * scale),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 19 * subScreenTextScale(scale),
height: 1.2,
),
),
],
],
),
),
for (final widget in trailing) ...[
widget,
SizedBox(width: 12 * scale),
],
Icon(
Icons.schedule_rounded,
color: XfjDesignTokens.muted,
size: 24 * subScreenTextScale(scale),
),
SizedBox(width: 8 * scale),
SubScreenHeaderClock(scale: scale),
],
),
);
}
}
/// 顶栏与主屏同节奏的分钟级时钟。
class SubScreenHeaderClock extends StatefulWidget {
const SubScreenHeaderClock({super.key, required this.scale});
final double scale;
@override
State<SubScreenHeaderClock> createState() => _SubScreenHeaderClockState();
}
class _SubScreenHeaderClockState extends State<SubScreenHeaderClock> {
late DateTime _now;
Timer? _timer;
@override
void initState() {
super.initState();
_now = DateTime.now();
_timer = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() => _now = DateTime.now());
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final hour = _now.hour.toString().padLeft(2, '0');
final minute = _now.minute.toString().padLeft(2, '0');
return Text(
'$hour:$minute',
key: const ValueKey('sub-screen-header-clock'),
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 27 * subScreenTextScale(widget.scale),
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
}
/// 顶栏各模式共用的状态胶囊。
class SubScreenHeaderPill extends StatelessWidget {
const SubScreenHeaderPill({
super.key,
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: math.max(52 * scale, 42)),
padding: EdgeInsets.symmetric(horizontal: 16 * scale),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
border: Border.all(color: color.withValues(alpha: 0.22)),
borderRadius: BorderRadius.circular(26 * scale),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 27 * subScreenTextScale(scale)),
SizedBox(width: 8 * scale),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 24 * subScreenTextScale(scale),
height: 1,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// 与结算副屏同策略:布局按缩放收缩,文字保底 0.9 保证远距离可读。
double subScreenTextScale(double scale) => math.max(scale, 0.9);
Color subScreenFaceStatusColor(FaceRecognitionDisplayStatus status) =>
switch (status) {
FaceRecognitionDisplayStatus.success => XfjDesignTokens.success,
FaceRecognitionDisplayStatus.failed ||
FaceRecognitionDisplayStatus.cancelled =>
XfjDesignTokens.danger,
_ => XfjDesignTokens.primary,
};
IconData subScreenFaceStatusIcon(FaceRecognitionDisplayStatus status) =>
switch (status) {
FaceRecognitionDisplayStatus.success => Icons.check_circle_rounded,
FaceRecognitionDisplayStatus.failed => Icons.error_outline_rounded,
FaceRecognitionDisplayStatus.cancelled => Icons.cancel_outlined,
FaceRecognitionDisplayStatus.initializing => Icons.videocam_rounded,
FaceRecognitionDisplayStatus.detecting =>
Icons.face_retouching_natural_rounded,
};
String subScreenFaceStatusTitle(FaceRecognitionDisplayStatus status) =>
switch (status) {
FaceRecognitionDisplayStatus.initializing => '摄像头启动中',
FaceRecognitionDisplayStatus.detecting => '正在识别',
FaceRecognitionDisplayStatus.success => '识别成功',
FaceRecognitionDisplayStatus.failed => '需要重试',
FaceRecognitionDisplayStatus.cancelled => '识别已取消',
};
IconData subScreenReservationScenarioIcon(
ReservationQueryDisplayScenario scenario,
) =>
switch (scenario) {
ReservationQueryDisplayScenario.idle => Icons.badge_outlined,
ReservationQueryDisplayScenario.recognizing =>
Icons.face_retouching_natural_rounded,
ReservationQueryDisplayScenario.loading => Icons.search_rounded,
ReservationQueryDisplayScenario.success => Icons.check_circle_outline,
ReservationQueryDisplayScenario.empty => Icons.event_busy_outlined,
ReservationQueryDisplayScenario.error => Icons.error_outline_rounded,
ReservationQueryDisplayScenario.expired => Icons.lock_clock_outlined,
};
Color subScreenReservationScenarioColor(
ReservationQueryDisplayScenario scenario,
) =>
switch (scenario) {
ReservationQueryDisplayScenario.idle ||
ReservationQueryDisplayScenario.recognizing ||
ReservationQueryDisplayScenario.loading ||
ReservationQueryDisplayScenario.success =>
XfjDesignTokens.primary,
ReservationQueryDisplayScenario.empty ||
ReservationQueryDisplayScenario.expired =>
XfjDesignTokens.warning,
ReservationQueryDisplayScenario.error => XfjDesignTokens.danger,
};
+101 -383
View File
@@ -106,25 +106,17 @@ class _CustomerDisplayView extends StatelessWidget {
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(padding),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
),
],
),
flex: 12,
child: _ProductPanel(state: state, scale: scale),
),
SizedBox(width: gap),
Expanded(
flex: 8,
child: _SettlementPanel(state: state, scale: scale),
),
],
),
@@ -136,116 +128,6 @@ class _CustomerDisplayView extends StatelessWidget {
}
}
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 {
/// 创建读取当前顾客屏状态的商品核对面板。
@@ -267,10 +149,10 @@ class _ProductPanel extends StatelessWidget {
children: [
Padding(
padding: EdgeInsets.fromLTRB(
16 * scale,
14 * scale,
16 * scale,
12 * scale,
14 * scale,
10 * scale,
),
child: Row(
children: [
@@ -286,11 +168,11 @@ class _ProductPanel extends StatelessWidget {
: '请核对商品信息',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(34, scale),
fontSize: _fontSize(40, scale),
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 2 * scale),
SizedBox(height: 4 * scale),
Text(
state.products.isEmpty
? '商品加入购物车后将在这里实时展示'
@@ -299,7 +181,7 @@ class _ProductPanel extends StatelessWidget {
: '本次消费共 ${state.products.length} 项商品',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(22, scale),
fontSize: _fontSize(26, scale),
),
),
],
@@ -330,8 +212,8 @@ class _ProductPanel extends StatelessWidget {
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: 14 * scale,
vertical: 8 * scale,
horizontal: 16 * scale,
vertical: 10 * scale,
),
decoration: const BoxDecoration(
color: Color(0xFFFAFAFB),
@@ -343,7 +225,7 @@ class _ProductPanel extends StatelessWidget {
: '商品和金额由收银员实时更新,如有疑问请及时告知收银员',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(18, scale),
fontSize: _fontSize(22, scale),
),
),
),
@@ -365,8 +247,8 @@ class _EmptyProducts extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 92 * scale,
height: 92 * scale,
width: 104 * scale,
height: 104 * scale,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: XfjDesignTokens.primarySoft,
@@ -375,24 +257,24 @@ class _EmptyProducts extends StatelessWidget {
child: Icon(
Icons.shopping_basket_outlined,
color: XfjDesignTokens.primary,
size: 46 * scale,
size: 52 * scale,
),
),
SizedBox(height: 18 * scale),
SizedBox(height: 20 * scale),
Text(
'等待添加商品',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(30, scale),
fontSize: _fontSize(36, scale),
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 6 * scale),
SizedBox(height: 8 * scale),
Text(
'请将商品交给收银员进行结算',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(20, scale),
fontSize: _fontSize(24, scale),
),
),
],
@@ -413,8 +295,8 @@ class _ProductHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 42 * scale,
padding: EdgeInsets.symmetric(horizontal: 10 * scale),
height: 50 * scale,
padding: EdgeInsets.symmetric(horizontal: 12 * scale),
color: const Color(0xFFFAFAFB),
child: Row(
children: [
@@ -428,72 +310,57 @@ class _ProductHeader extends StatelessWidget {
}
}
/// 副屏商品列表中的单条商品记录,可选展示套餐组成。
/// 副屏商品列表中的单条商品记录。
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),
// 单品行按当前副屏可视区容纳 8 条设置高度,字号放大后通过收紧内边距避免溢出。
constraints: BoxConstraints(minHeight: 94 * scale),
child: Padding(
padding:
EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 3 * scale),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale),
child: Row(
children: [
Row(
children: [
Expanded(
flex: 5,
child: Row(
children: [
_ProductImage(product: product, scale: scale),
SizedBox(width: 6 * scale),
_ProductTypeBadge(
isCombo: product.isCombo,
scale: scale,
Expanded(
flex: 5,
child: Row(
children: [
_ProductImage(product: product, scale: scale),
SizedBox(width: 12 * scale),
Expanded(
child: Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(34, scale),
fontWeight: FontWeight.w700,
),
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,
),
],
],
),
),
_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),
],
],
),
),
@@ -516,13 +383,13 @@ class _ProductImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final imageSource = product.imageSource;
final imageSize = 60 * scale;
final imageSize = 76 * scale;
final placeholder = _buildPlaceholder();
return Semantics(
image: true,
label: '${product.name}商品图片',
child: ClipRRect(
borderRadius: BorderRadius.circular(7 * scale),
borderRadius: BorderRadius.circular(8 * scale),
child: SizedBox(
key: ValueKey('customer-product-image-${product.name}'),
width: imageSize,
@@ -552,7 +419,7 @@ class _ProductImage extends StatelessWidget {
shortLabel,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(22, scale),
fontSize: _fontSize(28, scale),
fontWeight: FontWeight.w800,
),
),
@@ -561,155 +428,6 @@ class _ProductImage extends StatelessWidget {
}
}
/// 副屏主商品类型标识,帮助顾客快速区分单品和套餐。
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,
@@ -724,7 +442,7 @@ Widget _tableText(
textAlign: align,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(20, scale),
fontSize: _fontSize(24, scale),
fontWeight: FontWeight.w700,
),
),
@@ -745,7 +463,7 @@ Widget _valueCell(
textAlign: TextAlign.center,
style: TextStyle(
color: emphasized ? XfjDesignTokens.ink : XfjDesignTokens.muted,
fontSize: _fontSize(26, scale),
fontSize: _fontSize(31, scale),
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
@@ -762,7 +480,7 @@ class _SettlementPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16 * scale),
padding: EdgeInsets.all(18 * scale),
decoration: _surfaceDecoration(radius: 16 * scale),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -771,7 +489,7 @@ class _SettlementPanel extends StatelessWidget {
'实际扣款',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(20, scale),
fontSize: _fontSize(24, scale),
fontWeight: FontWeight.w700,
),
),
@@ -781,15 +499,15 @@ class _SettlementPanel extends StatelessWidget {
key: const ValueKey('customer-payable-price'),
style: TextStyle(
color: XfjDesignTokens.primary,
fontSize: _fontSize(64, scale),
fontSize: _fontSize(76, scale),
height: 1.05,
fontWeight: FontWeight.w900,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
SizedBox(height: 12 * scale),
SizedBox(height: 14 * scale),
_PriceLine(label: '商品总价', price: state.totalPrice, scale: scale),
SizedBox(height: 7 * scale),
SizedBox(height: 8 * scale),
_PriceLine(
label: '额外支付',
price: state.amountMealPrice,
@@ -828,7 +546,7 @@ class _PriceLine extends StatelessWidget {
label,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(22, scale),
fontSize: _fontSize(26, scale),
fontWeight: FontWeight.w600,
),
),
@@ -836,7 +554,7 @@ class _PriceLine extends StatelessWidget {
_money(price),
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(26, scale),
fontSize: _fontSize(31, scale),
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
@@ -898,31 +616,31 @@ class _PaymentStatePanel extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(34, scale),
fontSize: _fontSize(40, scale),
height: 1,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 6 * scale),
SizedBox(height: 8 * scale),
Text(
_countdown(state.countdownSeconds),
key: const ValueKey('customer-payment-countdown'),
style: TextStyle(
color: XfjDesignTokens.danger,
fontSize: _fontSize(44, scale),
fontSize: _fontSize(50, scale),
height: 1,
fontWeight: FontWeight.w900,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
SizedBox(height: 5 * scale),
SizedBox(height: 7 * scale),
Text(
'支付完成后请勿重复扫码',
maxLines: compactPaymentHint ? 1 : 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(20, scale),
fontSize: _fontSize(24, scale),
height: 1.2,
),
),
@@ -948,8 +666,8 @@ class _PaymentStatePanel extends StatelessWidget {
),
child: <Widget>[
Container(
width: 86 * scale,
height: 86 * scale,
width: 96 * scale,
height: 96 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: successColor,
@@ -958,14 +676,14 @@ class _PaymentStatePanel extends StatelessWidget {
child: Icon(
Icons.check_rounded,
color: Colors.white,
size: 50 * scale,
size: 56 * scale,
),
),
Text(
'支付成功',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(52, scale),
fontSize: _fontSize(60, scale),
height: 1,
fontWeight: FontWeight.w900,
),
@@ -974,7 +692,7 @@ class _PaymentStatePanel extends StatelessWidget {
'请带好您的商品',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(27, scale),
fontSize: _fontSize(32, scale),
height: 1.15,
fontWeight: FontWeight.w700,
),
@@ -986,7 +704,7 @@ class _PaymentStatePanel extends StatelessWidget {
text: '${state.countdownSeconds}',
style: TextStyle(
color: successColor,
fontSize: _fontSize(48, scale),
fontSize: _fontSize(54, scale),
fontWeight: FontWeight.w900,
),
),
@@ -994,7 +712,7 @@ class _PaymentStatePanel extends StatelessWidget {
text: ' 秒后自动清屏',
style: TextStyle(
color: successColor,
fontSize: _fontSize(25, scale),
fontSize: _fontSize(29, scale),
fontWeight: FontWeight.w800,
),
),
@@ -1029,26 +747,26 @@ class _PaymentStatePanel extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 68 * scale,
height: 68 * scale,
width: 78 * scale,
height: 78 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: visual.color,
shape: BoxShape.circle,
),
child: Icon(visual.icon, color: Colors.white, size: 36 * scale),
child: Icon(visual.icon, color: Colors.white, size: 42 * scale),
),
SizedBox(height: 10 * scale),
SizedBox(height: 12 * scale),
Text(
visual.title,
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(36, scale),
fontSize: _fontSize(42, scale),
height: 1,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 5 * scale),
SizedBox(height: 7 * scale),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20 * scale),
child: Text(
@@ -1058,7 +776,7 @@ class _PaymentStatePanel extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(22, scale),
fontSize: _fontSize(26, scale),
height: 1.2,
),
),
@@ -1112,8 +830,8 @@ class _Step extends StatelessWidget {
child: Column(
children: [
Container(
width: 30 * scale,
height: 30 * scale,
width: 34 * scale,
height: 34 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? XfjDesignTokens.primary : XfjDesignTokens.control,
@@ -1123,17 +841,17 @@ class _Step extends StatelessWidget {
'$index',
style: TextStyle(
color: active ? Colors.white : XfjDesignTokens.muted,
fontSize: _fontSize(18, scale),
fontSize: _fontSize(21, scale),
fontWeight: FontWeight.w800,
),
),
),
SizedBox(height: 5 * scale),
SizedBox(height: 6 * scale),
Text(
label,
style: TextStyle(
color: active ? XfjDesignTokens.ink : XfjDesignTokens.muted,
fontSize: _fontSize(18, scale),
fontSize: _fontSize(22, scale),
fontWeight: FontWeight.w600,
),
),
@@ -1159,20 +877,20 @@ class _StatusBadge extends StatelessWidget {
Widget build(BuildContext context) {
final visual = _scenarioVisual(scenario);
return Container(
padding: EdgeInsets.symmetric(horizontal: 8 * scale, vertical: 4 * scale),
padding: EdgeInsets.symmetric(horizontal: 10 * scale, vertical: 5 * scale),
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18 * scale),
borderRadius: BorderRadius.circular(20 * scale),
),
child: Row(
children: [
Icon(visual.icon, color: visual.color, size: 20 * scale),
SizedBox(width: 4 * scale),
Icon(visual.icon, color: visual.color, size: 24 * scale),
SizedBox(width: 6 * scale),
Text(
scenario.title,
style: TextStyle(
color: visual.color,
fontSize: _fontSize(20, scale),
fontSize: _fontSize(24, scale),
fontWeight: FontWeight.w700,
),
),
+10 -203
View File
@@ -1,67 +1,20 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局和时钟生命周期。
class CustomerWelcomePage extends StatefulWidget {
/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局;
/// 顶部状态栏由 [SubScreenShell] 统一提供,本页只负责欢迎插图区。
class CustomerWelcomePage extends StatelessWidget {
const CustomerWelcomePage({
super.key,
this.previewMode = false,
this.onExit,
this.now,
this.clockTick = const Duration(minutes: 1),
});
final bool previewMode;
final VoidCallback? onExit;
final DateTime Function()? now;
final Duration clockTick;
@override
State<CustomerWelcomePage> createState() => _CustomerWelcomePageState();
}
class _CustomerWelcomePageState extends State<CustomerWelcomePage> {
late DateTime _currentTime;
Timer? _clockTimer;
@override
void initState() {
super.initState();
_currentTime = _readNow();
_startClock();
}
@override
void didUpdateWidget(CustomerWelcomePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.clockTick != widget.clockTick ||
oldWidget.now != widget.now) {
_currentTime = _readNow();
_startClock();
}
}
DateTime _readNow() => widget.now?.call() ?? DateTime.now();
void _startClock() {
_clockTimer?.cancel();
final tick = widget.clockTick > Duration.zero
? widget.clockTick
: const Duration(minutes: 1);
_clockTimer = Timer.periodic(tick, (_) {
if (mounted) setState(() => _currentTime = _readNow());
});
}
@override
void dispose() {
_clockTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -69,15 +22,13 @@ class _CustomerWelcomePageState extends State<CustomerWelcomePage> {
backgroundColor: XfjDesignTokens.canvas,
body: Stack(
children: [
Positioned.fill(
child: _CustomerWelcomeContent(currentTime: _currentTime),
),
if (widget.previewMode)
const Positioned.fill(child: _CustomerWelcomeContent()),
if (previewMode)
Positioned(
top: 16,
right: 16,
child: SafeArea(
child: _PreviewExitButton(onTap: widget.onExit),
child: _PreviewExitButton(onTap: onExit),
),
),
],
@@ -87,9 +38,7 @@ class _CustomerWelcomePageState extends State<CustomerWelcomePage> {
}
class _CustomerWelcomeContent extends StatelessWidget {
const _CustomerWelcomeContent({required this.currentTime});
final DateTime currentTime;
const _CustomerWelcomeContent();
@override
Widget build(BuildContext context) {
@@ -103,21 +52,9 @@ class _CustomerWelcomeContent extends StatelessWidget {
key: const ValueKey('customer-welcome-page'),
color: XfjDesignTokens.canvas,
child: SafeArea(
child: Column(
children: [
_WelcomeHeader(currentTime: currentTime, scale: scale),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(
24 * scale,
0,
24 * scale,
24 * scale,
),
child: _WelcomeHero(scale: scale),
),
),
],
child: Padding(
padding: EdgeInsets.all(24 * scale),
child: _WelcomeHero(scale: scale),
),
),
);
@@ -126,136 +63,6 @@ class _CustomerWelcomeContent extends StatelessWidget {
}
}
class _WelcomeHeader extends StatelessWidget {
const _WelcomeHeader({required this.currentTime, required this.scale});
final DateTime currentTime;
final double scale;
@override
Widget build(BuildContext context) {
final hour = currentTime.hour.toString().padLeft(2, '0');
final minute = currentTime.minute.toString().padLeft(2, '0');
return SizedBox(
height: 80 * scale,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32 * scale),
child: Row(
children: [
Text(
'ZAX',
style: TextStyle(
color: const Color(0xFF0865E8),
fontSize: 36 * scale,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
letterSpacing: -2 * scale,
),
),
SizedBox(width: 20 * scale),
Container(
width: 1,
height: 36 * scale,
color: XfjDesignTokens.line,
),
SizedBox(width: 20 * scale),
Text(
'欢迎光临',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 24 * scale,
fontWeight: FontWeight.w800,
letterSpacing: 1.2 * scale,
),
),
SizedBox(width: 28 * scale),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16 * scale,
vertical: 9 * scale,
),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
border: Border.all(
color: XfjDesignTokens.primary.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(12 * scale),
),
child: Text(
'智慧收银',
style: TextStyle(
color: XfjDesignTokens.primary,
fontSize: 16 * scale,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
_ConnectionStatus(scale: scale),
SizedBox(width: 20 * scale),
Container(
width: 1,
height: 28 * scale,
color: XfjDesignTokens.line,
),
SizedBox(width: 20 * scale),
Text(
'$hour:$minute',
key: const ValueKey('customer-welcome-clock'),
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 20 * scale,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
);
}
}
class _ConnectionStatus extends StatelessWidget {
const _ConnectionStatus({required this.scale});
final double scale;
@override
Widget build(BuildContext context) {
return Semantics(
label: '设备已连接',
child: Row(
children: [
Container(
width: 10 * scale,
height: 10 * scale,
decoration: BoxDecoration(
color: XfjDesignTokens.success,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: XfjDesignTokens.success.withValues(alpha: 0.12),
spreadRadius: 5 * scale,
),
],
),
),
SizedBox(width: 10 * scale),
Text(
'设备已连接',
style: TextStyle(
color: XfjDesignTokens.success,
fontSize: 18 * scale,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _WelcomeHero extends StatelessWidget {
const _WelcomeHero({required this.scale});
@@ -9,6 +9,7 @@ import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart'
import 'package:pj_supermarket_cloud/common/widgets/face/face_recognition_preview_layer.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/utils/face_recognition_coordinator.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart';
/// 顾客副屏人脸页面。视频由副屏 Engine 自己申请 Texture,状态消息不承载任何视频帧或身份数据。
class FaceRecognitionCustomerPage extends StatefulWidget {
@@ -103,30 +104,22 @@ class _FaceRecognitionCustomerPageState
return SafeArea(
child: Padding(
padding: EdgeInsets.all(24 * scale),
child: Column(
child: Row(
children: [
_FaceHeader(state: state, scale: scale),
SizedBox(height: 16 * scale),
Expanded(
child: Row(
children: [
Expanded(
flex: 13,
child: _PreviewPanel(
texture: _texture,
nativeEvent: _nativeEvent,
error: _previewError,
scale: scale,
),
),
SizedBox(width: 16 * scale),
Expanded(
flex: 7,
child: _GuidePanel(state: state, scale: scale),
),
],
flex: 13,
child: _PreviewPanel(
texture: _texture,
nativeEvent: _nativeEvent,
error: _previewError,
scale: scale,
),
),
SizedBox(width: 16 * scale),
Expanded(
flex: 7,
child: _GuidePanel(state: state, scale: scale),
),
],
),
),
@@ -139,64 +132,6 @@ class _FaceRecognitionCustomerPageState
}
}
class _FaceHeader extends StatelessWidget {
const _FaceHeader({required this.state, required this.scale});
final FaceRecognitionDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
final reservation = state.scene == FaceRecognitionDisplayScene.reservation;
return Container(
height: 82 * scale,
padding: EdgeInsets.symmetric(horizontal: 22 * scale),
decoration: _surfaceDecoration(16 * scale),
child: Row(
children: [
Container(
width: 48 * scale,
height: 48 * scale,
decoration: BoxDecoration(
color: XfjDesignTokens.primary,
borderRadius: BorderRadius.circular(13 * scale),
),
child: Icon(
Icons.face_retouching_natural_rounded,
color: Colors.white,
size: 28 * scale,
),
),
SizedBox(width: 14 * scale),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reservation ? '本人预约查询' : '人脸支付',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 24 * scale,
fontWeight: FontWeight.w900,
),
),
Text(
reservation ? '仅用于查询当前顾客本人的预约信息' : '请顾客面向摄像头完成身份识别',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: 14 * scale,
),
),
],
),
const Spacer(),
_StatusPill(status: state.status, scale: scale),
],
),
);
}
}
class _PreviewPanel extends StatelessWidget {
const _PreviewPanel({
required this.texture,
@@ -276,7 +211,7 @@ class _GuidePanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = _statusColor(state.status);
final color = subScreenFaceStatusColor(state.status);
final reservation = state.scene == FaceRecognitionDisplayScene.reservation;
return Container(
padding: EdgeInsets.all(28 * scale),
@@ -292,12 +227,15 @@ class _GuidePanel extends StatelessWidget {
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child:
Icon(_statusIcon(state.status), color: color, size: 44 * scale),
child: Icon(
subScreenFaceStatusIcon(state.status),
color: color,
size: 44 * scale,
),
),
SizedBox(height: 22 * scale),
Text(
_statusTitle(state.status),
subScreenFaceStatusTitle(state.status),
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: 34 * scale,
@@ -428,41 +366,6 @@ class _AmountCard extends StatelessWidget {
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.status, required this.scale});
final FaceRecognitionDisplayStatus status;
final double scale;
@override
Widget build(BuildContext context) {
final color = _statusColor(status);
return Container(
constraints: BoxConstraints(minHeight: 48 * scale),
padding: EdgeInsets.symmetric(horizontal: 16 * scale),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.09),
border: Border.all(color: color.withValues(alpha: 0.24)),
borderRadius: BorderRadius.circular(24 * scale),
),
child: Row(
children: [
Icon(_statusIcon(status), color: color, size: 22 * scale),
SizedBox(width: 8 * scale),
Text(
_statusTitle(status),
style: TextStyle(
color: color,
fontSize: 16 * scale,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
BoxDecoration _surfaceDecoration(double radius) => BoxDecoration(
color: XfjDesignTokens.surface,
border: Border.all(color: XfjDesignTokens.line),
@@ -470,29 +373,4 @@ BoxDecoration _surfaceDecoration(double radius) => BoxDecoration(
boxShadow: XfjDesignTokens.surfaceShadow,
);
Color _statusColor(FaceRecognitionDisplayStatus status) => switch (status) {
FaceRecognitionDisplayStatus.success => XfjDesignTokens.success,
FaceRecognitionDisplayStatus.failed ||
FaceRecognitionDisplayStatus.cancelled =>
XfjDesignTokens.danger,
_ => XfjDesignTokens.primary,
};
IconData _statusIcon(FaceRecognitionDisplayStatus status) => switch (status) {
FaceRecognitionDisplayStatus.success => Icons.check_circle_rounded,
FaceRecognitionDisplayStatus.failed => Icons.error_outline_rounded,
FaceRecognitionDisplayStatus.cancelled => Icons.cancel_outlined,
FaceRecognitionDisplayStatus.initializing => Icons.videocam_rounded,
FaceRecognitionDisplayStatus.detecting =>
Icons.face_retouching_natural_rounded,
};
String _statusTitle(FaceRecognitionDisplayStatus status) => switch (status) {
FaceRecognitionDisplayStatus.initializing => '摄像头启动中',
FaceRecognitionDisplayStatus.detecting => '正在识别',
FaceRecognitionDisplayStatus.success => '识别成功',
FaceRecognitionDisplayStatus.failed => '需要重试',
FaceRecognitionDisplayStatus.cancelled => '识别已取消',
};
String _money(int fen) => '¥ ${(fen / 100).toStringAsFixed(2)}';
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/utils/reservation_query_coordinator.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart';
/// 真实副屏和 Debug 预览共用的预约菜单查询屏。
class ReservationQueryDisplayPage extends StatelessWidget {
@@ -102,8 +103,6 @@ class _ReservationQueryDisplayView extends StatelessWidget {
padding: EdgeInsets.all(24 * scale),
child: Column(
children: [
_ReservationHeader(state: state, scale: scale),
SizedBox(height: 14 * scale),
Expanded(
child: state.continuousFaceRecognition
? _ContinuousFaceReservationBody(
@@ -124,128 +123,6 @@ class _ReservationQueryDisplayView extends StatelessWidget {
}
}
class _ReservationHeader extends StatelessWidget {
const _ReservationHeader({required this.state, required this.scale});
final ReservationQueryDisplayState state;
final double scale;
@override
Widget build(BuildContext context) {
return Container(
// 字号保底后两行文字在小尺寸副屏上会超过按比例缩放的 82 高,
// 与首页副屏一致给头部一个最低高度,避免标题和副标题被裁切。
height: math.max(82 * scale, 56),
padding: EdgeInsets.symmetric(horizontal: 22 * scale),
decoration: _surfaceDecoration(16 * scale),
child: Row(
children: [
Container(
width: 48 * scale,
height: 48 * scale,
alignment: Alignment.center,
decoration: BoxDecoration(
color: XfjDesignTokens.primary,
borderRadius: BorderRadius.circular(13 * scale),
),
child: Icon(
Icons.calendar_month_rounded,
color: Colors.white,
size: _fontSize(28, scale),
),
),
SizedBox(width: 14 * scale),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'预约查询',
style: TextStyle(
color: XfjDesignTokens.ink,
fontSize: _fontSize(28, scale),
height: 1.2,
fontWeight: FontWeight.w900,
),
),
Text(
state.merchantName ?? '请使用饭卡或人脸识别本人预约',
style: TextStyle(
color: XfjDesignTokens.muted,
fontSize: _fontSize(18, scale),
height: 1.3,
fontWeight: FontWeight.w500,
),
),
],
),
const Spacer(),
if (state.maskedName != null) ...[
_HeaderPill(
icon: Icons.person_outline_rounded,
label: [state.maskedName, state.deptName]
.whereType<String>()
.where((value) => value.trim().isNotEmpty)
.join(' · '),
color: XfjDesignTokens.primary,
scale: scale,
),
SizedBox(width: 10 * scale),
],
_HeaderPill(
icon: _scenarioIcon(state.scenario),
label: state.scenario.title,
color: _scenarioColor(state.scenario),
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(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: _fontSize(21, scale)),
SizedBox(width: 7 * scale),
Text(
label,
style: TextStyle(
color: color,
fontSize: _fontSize(18, scale),
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// 持续人脸副屏组合区,左侧长期保留实时预览,右侧按状态切换等待或预约菜单。
class _ContinuousFaceReservationBody extends StatelessWidget {
/// 创建与预约快照同步的持续人脸组合布局。
@@ -547,7 +424,7 @@ class _ReservationStatePanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = _scenarioColor(state.scenario);
final color = subScreenReservationScenarioColor(state.scenario);
final title = switch (state.scenario) {
ReservationQueryDisplayScenario.idle => '请刷饭卡或进行人脸识别',
ReservationQueryDisplayScenario.recognizing => '正在识别下一位用户',
@@ -593,7 +470,7 @@ class _ReservationStatePanel extends StatelessWidget {
),
)
: Icon(
_scenarioIcon(state.scenario),
subScreenReservationScenarioIcon(state.scenario),
color: color,
size: _fontSize(60, scale),
),
@@ -680,33 +557,6 @@ class _ReservationFooter extends StatelessWidget {
}
}
IconData _scenarioIcon(ReservationQueryDisplayScenario scenario) {
return switch (scenario) {
ReservationQueryDisplayScenario.idle => Icons.badge_outlined,
ReservationQueryDisplayScenario.recognizing =>
Icons.face_retouching_natural_rounded,
ReservationQueryDisplayScenario.loading => Icons.search_rounded,
ReservationQueryDisplayScenario.success => Icons.check_circle_outline,
ReservationQueryDisplayScenario.empty => Icons.event_busy_outlined,
ReservationQueryDisplayScenario.error => Icons.error_outline_rounded,
ReservationQueryDisplayScenario.expired => Icons.lock_clock_outlined,
};
}
Color _scenarioColor(ReservationQueryDisplayScenario scenario) {
return switch (scenario) {
ReservationQueryDisplayScenario.idle ||
ReservationQueryDisplayScenario.recognizing ||
ReservationQueryDisplayScenario.loading ||
ReservationQueryDisplayScenario.success =>
XfjDesignTokens.primary,
ReservationQueryDisplayScenario.empty ||
ReservationQueryDisplayScenario.expired =>
XfjDesignTokens.warning,
ReservationQueryDisplayScenario.error => XfjDesignTokens.danger,
};
}
BoxDecoration _surfaceDecoration(double radius) {
return BoxDecoration(
color: XfjDesignTokens.surface,
+116 -12
View File
@@ -5,7 +5,10 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart';
import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart';
import 'package:pj_supermarket_cloud/sub_screen/page/customer_display.dart';
import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart';
import 'package:pj_supermarket_cloud/sub_screen/page/face_recognition_customer_page.dart';
@@ -57,21 +60,122 @@ class _SubAppPageState extends State<SubAppPage> {
child: AnimatedBuilder(
animation: _coordinator,
builder: (context, _) {
return switch (_coordinator.mode) {
SubDisplayMode.welcome => const CustomerWelcomePage(),
SubDisplayMode.settlement => CustomerDisplayPage(
coordinator: _coordinator.customerCoordinator,
),
SubDisplayMode.reservationQuery => ReservationQueryDisplayPage(
coordinator: _coordinator.reservationCoordinator,
),
SubDisplayMode.faceRecognition => FaceRecognitionCustomerPage(
coordinator: _coordinator.faceRecognitionCoordinator,
),
};
// 与主屏终端外壳一致:顶栏常驻并随模式切换标题,页面只负责工作区。
return SubScreenShell(
title: _modeTitle(),
subtitle: _modeSubtitle(),
trailingBuilder: _modeTrailing,
child: switch (_coordinator.mode) {
SubDisplayMode.welcome => const CustomerWelcomePage(),
SubDisplayMode.settlement => CustomerDisplayPage(
coordinator: _coordinator.customerCoordinator,
),
SubDisplayMode.reservationQuery => ReservationQueryDisplayPage(
coordinator: _coordinator.reservationCoordinator,
),
SubDisplayMode.faceRecognition => FaceRecognitionCustomerPage(
coordinator: _coordinator.faceRecognitionCoordinator,
),
},
);
},
),
),
);
}
String _modeTitle() {
switch (_coordinator.mode) {
case SubDisplayMode.welcome:
return '欢迎光临';
case SubDisplayMode.settlement:
return _coordinator.customerCoordinator.state.businessTitle;
case SubDisplayMode.reservationQuery:
return '预约查询';
case SubDisplayMode.faceRecognition:
final faceState = _coordinator.faceRecognitionCoordinator.state;
return faceState?.scene == FaceRecognitionDisplayScene.reservation
? '本人预约查询'
: '人脸支付';
}
}
String? _modeSubtitle() {
if (_coordinator.mode != SubDisplayMode.reservationQuery) return null;
final state = _coordinator.reservationCoordinator.state;
return state.merchantName ?? '请使用饭卡或人脸识别本人预约';
}
List<Widget> _modeTrailing(double scale) {
switch (_coordinator.mode) {
case SubDisplayMode.settlement:
return _customerTrailing(scale);
case SubDisplayMode.reservationQuery:
return _reservationTrailing(scale);
case SubDisplayMode.faceRecognition:
return _faceTrailing(scale);
case SubDisplayMode.welcome:
return const [];
}
}
/// 已识别到的顾客在顶栏展示脱敏姓名和手机号,副屏永不显示完整个人信息。
List<Widget> _customerTrailing(double scale) {
final state = _coordinator.customerCoordinator.state;
final name = state.maskedCustomerName;
final phone = state.maskedCustomerPhone;
if (name == null && phone == null) return const [];
return [
if (name != null)
SubScreenHeaderPill(
icon: Icons.person_outline_rounded,
label: '$name,您好',
color: XfjDesignTokens.primary,
scale: scale,
),
if (phone != null)
SubScreenHeaderPill(
icon: Icons.call_outlined,
label: phone,
color: XfjDesignTokens.primary,
scale: scale,
),
];
}
List<Widget> _reservationTrailing(double scale) {
final state = _coordinator.reservationCoordinator.state;
final nameParts = [state.maskedName, state.deptName]
.whereType<String>()
.where((value) => value.trim().isNotEmpty)
.toList();
return [
if (nameParts.isNotEmpty)
SubScreenHeaderPill(
icon: Icons.person_outline_rounded,
label: nameParts.join(' · '),
color: XfjDesignTokens.primary,
scale: scale,
),
SubScreenHeaderPill(
icon: subScreenReservationScenarioIcon(state.scenario),
label: state.scenario.title,
color: subScreenReservationScenarioColor(state.scenario),
scale: scale,
),
];
}
List<Widget> _faceTrailing(double scale) {
final faceState = _coordinator.faceRecognitionCoordinator.state;
if (faceState == null) return const [];
return [
SubScreenHeaderPill(
icon: subScreenFaceStatusIcon(faceState.status),
label: subScreenFaceStatusTitle(faceState.status),
color: subScreenFaceStatusColor(faceState.status),
scale: scale,
),
];
}
}