feat:保持当前订单的购买人员、饭卡等信息
This commit is contained in:
1 parent
a81a79b69f
commit
e1d41c5370
13 files changed
+631
-896
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user