副屏
This commit is contained in:
1 parent
154122a847
commit
2e929c199b
21 files changed
+5517
-85
No files matched your search
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 顾客屏可展示的稳定业务状态,同时用于真实副屏和主屏调试预览。
|
||||
enum CustomerDisplayScenario {
|
||||
idle,
|
||||
review,
|
||||
paying,
|
||||
success,
|
||||
cancelled,
|
||||
timeout,
|
||||
}
|
||||
|
||||
CustomerDisplayScenario customerDisplayScenarioFromName(String? name) {
|
||||
return CustomerDisplayScenario.values.firstWhere(
|
||||
(scenario) => scenario.name == name,
|
||||
orElse: () => CustomerDisplayScenario.review,
|
||||
);
|
||||
}
|
||||
|
||||
extension CustomerDisplayScenarioX on CustomerDisplayScenario {
|
||||
String get title => switch (this) {
|
||||
CustomerDisplayScenario.idle => '待机欢迎',
|
||||
CustomerDisplayScenario.review => '商品核对',
|
||||
CustomerDisplayScenario.paying => '扫码支付',
|
||||
CustomerDisplayScenario.success => '支付成功',
|
||||
CustomerDisplayScenario.cancelled => '支付取消',
|
||||
CustomerDisplayScenario.timeout => '支付超时',
|
||||
};
|
||||
|
||||
String get description => switch (this) {
|
||||
CustomerDisplayScenario.idle => '展示欢迎信息,等待收银员添加商品',
|
||||
CustomerDisplayScenario.review => '展示商品明细、额外支付和实际扣款',
|
||||
CustomerDisplayScenario.paying => '展示支付二维码、倒计时和防重复扫码提示',
|
||||
CustomerDisplayScenario.success => '提示本次支付已完成',
|
||||
CustomerDisplayScenario.cancelled => '提示支付已取消并等待重新发起',
|
||||
CustomerDisplayScenario.timeout => '提示二维码已失效并等待重新发起',
|
||||
};
|
||||
}
|
||||
|
||||
/// 副屏套餐中的单个组成菜品,只承载名称和每套份数,不参与结算金额。
|
||||
@immutable
|
||||
class CustomerDisplayComboItem {
|
||||
/// 创建一条已经过副屏消息校验的套餐组成菜品。
|
||||
const CustomerDisplayComboItem({
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
/// 套餐组成菜品名称,来源于主屏本地每日菜谱快照。
|
||||
final String name;
|
||||
|
||||
/// 每套包含的菜品份数,始终为正整数。
|
||||
final int quantity;
|
||||
|
||||
/// 按全部展示字段判断两条套餐组成是否一致。
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayComboItem &&
|
||||
other.name == name &&
|
||||
other.quantity == quantity;
|
||||
}
|
||||
|
||||
/// 为副屏状态去重提供与相等判断一致的哈希值。
|
||||
@override
|
||||
int get hashCode => Object.hash(name, quantity);
|
||||
}
|
||||
|
||||
/// 副屏购物车中的一个主商品,包含可选的食堂套餐组成。
|
||||
@immutable
|
||||
class CustomerDisplayProduct {
|
||||
/// 创建一条副屏商品展示记录;旧协议商品默认按单品处理。
|
||||
const CustomerDisplayProduct({
|
||||
required this.name,
|
||||
required this.quantityLabel,
|
||||
required this.unitPrice,
|
||||
required this.totalPrice,
|
||||
this.imageSource,
|
||||
this.isCombo = false,
|
||||
this.comboItems = const [],
|
||||
});
|
||||
|
||||
/// 主商品名称。
|
||||
final String name;
|
||||
|
||||
/// 按件数量或称重重量的本地化展示文本。
|
||||
final String quantityLabel;
|
||||
|
||||
/// 主商品单价,单位为分。
|
||||
final int unitPrice;
|
||||
|
||||
/// 当前购物车条目的结算小计,单位为分。
|
||||
final int totalPrice;
|
||||
|
||||
/// 商品图片来源,兼容完整网络地址和服务端返回的 OSS 相对地址;为空时展示名称首字占位。
|
||||
final String? imageSource;
|
||||
|
||||
/// 是否为食堂套餐;旧版副屏消息缺失该字段时为 false。
|
||||
final bool isCombo;
|
||||
|
||||
/// 每套包含的组成菜品;单品或本地明细缺失时为空列表。
|
||||
final List<CustomerDisplayComboItem> comboItems;
|
||||
|
||||
/// 按主商品及套餐组成的全部展示字段判断两条记录是否一致。
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayProduct &&
|
||||
other.name == name &&
|
||||
other.quantityLabel == quantityLabel &&
|
||||
other.unitPrice == unitPrice &&
|
||||
other.totalPrice == totalPrice &&
|
||||
other.imageSource == imageSource &&
|
||||
other.isCombo == isCombo &&
|
||||
listEquals(other.comboItems, comboItems);
|
||||
}
|
||||
|
||||
/// 为副屏状态去重提供与相等判断一致的哈希值。
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
name,
|
||||
quantityLabel,
|
||||
unitPrice,
|
||||
totalPrice,
|
||||
imageSource,
|
||||
isCombo,
|
||||
Object.hashAll(comboItems),
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class CustomerDisplayState {
|
||||
const CustomerDisplayState({
|
||||
required this.scenario,
|
||||
required this.businessTitle,
|
||||
required this.products,
|
||||
required this.totalPrice,
|
||||
required this.amountMealPrice,
|
||||
this.customerName,
|
||||
this.qrCodeData,
|
||||
this.countdownSeconds = 0,
|
||||
this.connected = true,
|
||||
});
|
||||
|
||||
factory CustomerDisplayState.idle() => const CustomerDisplayState(
|
||||
scenario: CustomerDisplayScenario.idle,
|
||||
businessTitle: '智慧收银',
|
||||
products: [],
|
||||
totalPrice: 0,
|
||||
amountMealPrice: 0,
|
||||
);
|
||||
|
||||
/// 演示数据只用于主屏预览,避免调试页面读取真实顾客、订单或支付信息。
|
||||
factory CustomerDisplayState.demo(CustomerDisplayScenario scenario) {
|
||||
if (scenario == CustomerDisplayScenario.idle) {
|
||||
return CustomerDisplayState.idle();
|
||||
}
|
||||
return CustomerDisplayState(
|
||||
scenario: scenario,
|
||||
businessTitle: '智慧超市',
|
||||
customerName: '张小明',
|
||||
products: const [
|
||||
CustomerDisplayProduct(
|
||||
name: '农夫山泉 550ml',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 200,
|
||||
totalPrice: 200,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '纯牛奶 250ml',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 480,
|
||||
totalPrice: 480,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '乐事薯片 原味 70g',
|
||||
quantityLabel: '1 件',
|
||||
unitPrice: 390,
|
||||
totalPrice: 390,
|
||||
),
|
||||
CustomerDisplayProduct(
|
||||
name: '香蕉(称重)',
|
||||
quantityLabel: '0.36 kg',
|
||||
unitPrice: 10000,
|
||||
totalPrice: 3600,
|
||||
),
|
||||
],
|
||||
totalPrice: 4670,
|
||||
amountMealPrice: 300,
|
||||
qrCodeData: scenario == CustomerDisplayScenario.paying
|
||||
? 'biz-xfj-customer-preview'
|
||||
: null,
|
||||
countdownSeconds: switch (scenario) {
|
||||
CustomerDisplayScenario.paying => 298,
|
||||
CustomerDisplayScenario.success => 10,
|
||||
_ => 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final CustomerDisplayScenario scenario;
|
||||
final String businessTitle;
|
||||
final String? customerName;
|
||||
final List<CustomerDisplayProduct> products;
|
||||
final int totalPrice;
|
||||
final int amountMealPrice;
|
||||
final String? qrCodeData;
|
||||
final int countdownSeconds;
|
||||
final bool connected;
|
||||
|
||||
int get payablePrice => totalPrice + amountMealPrice;
|
||||
|
||||
String? get maskedCustomerName {
|
||||
final value = customerName?.trim();
|
||||
if (value == null || value.isEmpty) return null;
|
||||
if (value.length == 1) return '*';
|
||||
if (value.length == 2) return '${value[0]}*';
|
||||
return '${value[0]}*${value[value.length - 1]}';
|
||||
}
|
||||
|
||||
CustomerDisplayState withScenario(
|
||||
CustomerDisplayScenario next, {
|
||||
String? qrCodeData,
|
||||
int countdownSeconds = 0,
|
||||
}) {
|
||||
return CustomerDisplayState(
|
||||
scenario: next,
|
||||
businessTitle: businessTitle,
|
||||
customerName: customerName,
|
||||
products: products,
|
||||
totalPrice: totalPrice,
|
||||
amountMealPrice: amountMealPrice,
|
||||
qrCodeData: qrCodeData,
|
||||
countdownSeconds: countdownSeconds,
|
||||
connected: connected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CustomerDisplayState &&
|
||||
other.scenario == scenario &&
|
||||
other.businessTitle == businessTitle &&
|
||||
other.customerName == customerName &&
|
||||
listEquals(other.products, products) &&
|
||||
other.totalPrice == totalPrice &&
|
||||
other.amountMealPrice == amountMealPrice &&
|
||||
other.qrCodeData == qrCodeData &&
|
||||
other.countdownSeconds == countdownSeconds &&
|
||||
other.connected == connected;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
scenario,
|
||||
businessTitle,
|
||||
customerName,
|
||||
Object.hashAll(products),
|
||||
totalPrice,
|
||||
amountMealPrice,
|
||||
qrCodeData,
|
||||
countdownSeconds,
|
||||
connected,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum FaceRecognitionDisplayScene { cashier, reservation }
|
||||
|
||||
enum FaceRecognitionDisplayStatus {
|
||||
initializing,
|
||||
detecting,
|
||||
success,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FaceRecognitionDisplayState {
|
||||
const FaceRecognitionDisplayState({
|
||||
required this.sessionId,
|
||||
required this.revision,
|
||||
required this.scene,
|
||||
required this.status,
|
||||
required this.message,
|
||||
this.payableAmount,
|
||||
this.totalAmount,
|
||||
this.discountAmount,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final int revision;
|
||||
final FaceRecognitionDisplayScene scene;
|
||||
final FaceRecognitionDisplayStatus status;
|
||||
final String message;
|
||||
final int? payableAmount;
|
||||
final int? totalAmount;
|
||||
final int? discountAmount;
|
||||
|
||||
FaceRecognitionDisplayState copyWith({
|
||||
required int revision,
|
||||
required FaceRecognitionDisplayStatus status,
|
||||
required String message,
|
||||
}) {
|
||||
return FaceRecognitionDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scene: scene,
|
||||
status: status,
|
||||
message: message,
|
||||
payableAmount: payableAmount,
|
||||
totalAmount: totalAmount,
|
||||
discountAmount: discountAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart';
|
||||
|
||||
/// 预约查询副屏的稳定展示状态,真实副屏和设置预览共用同一份模型。
|
||||
enum ReservationQueryDisplayScenario {
|
||||
/// 普通饭卡或单轮人脸模式尚未开始识别。
|
||||
idle,
|
||||
|
||||
/// 持续人脸模式正在等待并识别下一位用户。
|
||||
recognizing,
|
||||
|
||||
/// 身份已识别,正在请求当前用户的预约菜单。
|
||||
loading,
|
||||
|
||||
/// 已获取并展示当前用户的预约菜单。
|
||||
success,
|
||||
|
||||
/// 身份有效,但当前用户没有可展示的预约。
|
||||
empty,
|
||||
|
||||
/// 识别设备或预约查询出现需要人工恢复的异常。
|
||||
error,
|
||||
|
||||
/// 普通模式的身份结果已按隐私时限清除。
|
||||
expired,
|
||||
}
|
||||
|
||||
/// 将跨 Engine 场景名称转换为副屏枚举,未知预览参数兼容为成功态。
|
||||
ReservationQueryDisplayScenario reservationQueryDisplayScenarioFromName(
|
||||
String? name,
|
||||
) {
|
||||
return ReservationQueryDisplayScenario.values.firstWhere(
|
||||
(scenario) => scenario.name == name,
|
||||
orElse: () => ReservationQueryDisplayScenario.success,
|
||||
);
|
||||
}
|
||||
|
||||
extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario {
|
||||
/// 当前场景用于状态标签的短标题。
|
||||
String get title => switch (this) {
|
||||
ReservationQueryDisplayScenario.idle => '等待识别',
|
||||
ReservationQueryDisplayScenario.recognizing => '持续识别中',
|
||||
ReservationQueryDisplayScenario.loading => '正在查询',
|
||||
ReservationQueryDisplayScenario.success => '查询成功',
|
||||
ReservationQueryDisplayScenario.empty => '暂无预约',
|
||||
ReservationQueryDisplayScenario.error => '查询失败',
|
||||
ReservationQueryDisplayScenario.expired => '隐私清屏',
|
||||
};
|
||||
|
||||
/// 当前场景用于预览和辅助说明的完整描述。
|
||||
String get description => switch (this) {
|
||||
ReservationQueryDisplayScenario.idle => '等待用户刷饭卡或进行人脸识别',
|
||||
ReservationQueryDisplayScenario.recognizing => '摄像头已开启,正在识别下一位用户',
|
||||
ReservationQueryDisplayScenario.loading => '正在识别本人身份并查询预约菜单',
|
||||
ReservationQueryDisplayScenario.success => '展示当前页预约日期、餐段和菜品',
|
||||
ReservationQueryDisplayScenario.empty => '身份识别成功,但当前没有有效预约',
|
||||
ReservationQueryDisplayScenario.error => '识别或网络异常,等待主屏重新发起',
|
||||
ReservationQueryDisplayScenario.expired => '身份与预约内容已按隐私规则清除',
|
||||
};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ReservationQueryDisplayState {
|
||||
/// 创建已通过 Coordinator 校验的不可变副屏展示快照。
|
||||
const ReservationQueryDisplayState({
|
||||
required this.sessionId,
|
||||
required this.revision,
|
||||
required this.scenario,
|
||||
required this.totalDayCount,
|
||||
required this.totalMealCount,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.days,
|
||||
this.maskedName,
|
||||
this.deptName,
|
||||
this.merchantName,
|
||||
this.expiresAt,
|
||||
this.remainingSeconds = 0,
|
||||
this.continuousFaceRecognition = false,
|
||||
});
|
||||
|
||||
factory ReservationQueryDisplayState.idle({
|
||||
String sessionId = '',
|
||||
int revision = 0,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: ReservationQueryDisplayScenario.idle,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 调试数据均为虚构内容,不读取真实用户身份或终端预约。
|
||||
factory ReservationQueryDisplayState.demo(
|
||||
ReservationQueryDisplayScenario scenario, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final current = now ?? DateTime.now();
|
||||
if (scenario == ReservationQueryDisplayScenario.idle ||
|
||||
scenario == ReservationQueryDisplayScenario.recognizing ||
|
||||
scenario == ReservationQueryDisplayScenario.expired) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition:
|
||||
scenario == ReservationQueryDisplayScenario.recognizing,
|
||||
);
|
||||
}
|
||||
if (scenario == ReservationQueryDisplayScenario.loading ||
|
||||
scenario == ReservationQueryDisplayScenario.error) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
totalDayCount: 0,
|
||||
totalMealCount: 0,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
days: const [],
|
||||
continuousFaceRecognition: false,
|
||||
);
|
||||
}
|
||||
|
||||
final days = scenario == ReservationQueryDisplayScenario.success
|
||||
? List<ReservationMenuDayData>.generate(10, (index) {
|
||||
final date = DateTime(current.year, current.month, current.day)
|
||||
.add(Duration(days: index));
|
||||
final dateText =
|
||||
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
return ReservationMenuDayData(
|
||||
date: dateText,
|
||||
meals: const [
|
||||
ReservationMenuMealData(
|
||||
hourType: 'BREAKFAST',
|
||||
title: '早餐',
|
||||
items: ['鲜肉包', '水煮蛋', '纯牛奶'],
|
||||
),
|
||||
ReservationMenuMealData(
|
||||
hourType: 'LUNCH',
|
||||
title: '午餐',
|
||||
items: ['香菇滑鸡', '清炒时蔬', '玉米排骨汤'],
|
||||
),
|
||||
],
|
||||
);
|
||||
})
|
||||
: const <ReservationMenuDayData>[];
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: 'preview',
|
||||
revision: 1,
|
||||
scenario: scenario,
|
||||
maskedName: '张*明',
|
||||
deptName: '三年级二班',
|
||||
merchantName: '第一食堂',
|
||||
totalDayCount:
|
||||
scenario == ReservationQueryDisplayScenario.success ? 12 : 0,
|
||||
totalMealCount:
|
||||
scenario == ReservationQueryDisplayScenario.success ? 24 : 0,
|
||||
currentPage: 1,
|
||||
totalPages: scenario == ReservationQueryDisplayScenario.success ? 2 : 1,
|
||||
days: List.unmodifiable(days),
|
||||
expiresAt: current.add(const Duration(seconds: 60)),
|
||||
remainingSeconds: 60,
|
||||
continuousFaceRecognition: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 主屏查询页面会话编号,用于拒绝旧页面迟到消息。
|
||||
final String sessionId;
|
||||
|
||||
/// 当前会话内单调递增的消息版本。
|
||||
final int revision;
|
||||
|
||||
/// 副屏当前展示的查询阶段。
|
||||
final ReservationQueryDisplayScenario scenario;
|
||||
|
||||
/// 服务端返回的脱敏姓名;等待和异常场景为空。
|
||||
final String? maskedName;
|
||||
|
||||
/// 用餐人所属班级或部门;服务端未提供时为空。
|
||||
final String? deptName;
|
||||
|
||||
/// 当前终端绑定的食堂名称;未取得身份结果时为空。
|
||||
final String? merchantName;
|
||||
|
||||
/// 当前身份全部预约日期数量,不等同于当前页卡片数。
|
||||
final int totalDayCount;
|
||||
|
||||
/// 当前身份全部已预约餐次数量。
|
||||
final int totalMealCount;
|
||||
|
||||
/// 当前结果页码,从一开始。
|
||||
final int currentPage;
|
||||
|
||||
/// 当前结果总页数,至少为一。
|
||||
final int totalPages;
|
||||
|
||||
/// 当前页最多十个预约日期,不包含核销订单标识。
|
||||
final List<ReservationMenuDayData> days;
|
||||
|
||||
/// 当前身份结果的绝对过期时间;非结果场景为空。
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// 距离结果清理的剩余秒数,使用等宽数字展示。
|
||||
final int remainingSeconds;
|
||||
|
||||
/// 是否在副屏使用摄像头与预约内容并列的持续人脸布局。
|
||||
final bool continuousFaceRecognition;
|
||||
|
||||
/// 复制计时器需要变化的场景或剩余秒数,其余快照内容保持不变。
|
||||
ReservationQueryDisplayState copyWith({
|
||||
ReservationQueryDisplayScenario? scenario,
|
||||
int? remainingSeconds,
|
||||
bool? continuousFaceRecognition,
|
||||
}) {
|
||||
return ReservationQueryDisplayState(
|
||||
sessionId: sessionId,
|
||||
revision: revision,
|
||||
scenario: scenario ?? this.scenario,
|
||||
maskedName: maskedName,
|
||||
deptName: deptName,
|
||||
merchantName: merchantName,
|
||||
totalDayCount: totalDayCount,
|
||||
totalMealCount: totalMealCount,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
days: days,
|
||||
expiresAt: expiresAt,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
continuousFaceRecognition:
|
||||
continuousFaceRecognition ?? this.continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ReservationQueryDisplayState &&
|
||||
other.sessionId == sessionId &&
|
||||
other.revision == revision &&
|
||||
other.scenario == scenario &&
|
||||
other.maskedName == maskedName &&
|
||||
other.deptName == deptName &&
|
||||
other.merchantName == merchantName &&
|
||||
other.totalDayCount == totalDayCount &&
|
||||
other.totalMealCount == totalMealCount &&
|
||||
other.currentPage == currentPage &&
|
||||
other.totalPages == totalPages &&
|
||||
listEquals(other.days, days) &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.remainingSeconds == remainingSeconds &&
|
||||
other.continuousFaceRecognition == continuousFaceRecognition;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
sessionId,
|
||||
revision,
|
||||
scenario,
|
||||
maskedName,
|
||||
deptName,
|
||||
merchantName,
|
||||
totalDayCount,
|
||||
totalMealCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
Object.hashAll(days),
|
||||
expiresAt,
|
||||
remainingSeconds,
|
||||
continuousFaceRecognition,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user