111
This commit is contained in:
1 parent
fe6d988fa3
commit
a461b0fda3
29 files changed
+6012
-760
No files matched your search
@@ -10,6 +10,7 @@ export './api/wallet_api_models.dart';
|
||||
export './base/base_models.dart';
|
||||
|
||||
export './page/app_version_enum_models.dart';
|
||||
export './page/cashier_checkout_snapshot.dart';
|
||||
export './page/order_page_enum_models.dart';
|
||||
export './page/order_page_models.dart';
|
||||
export './page/panel_page_models.dart';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:pj_supermarket_cloud/common/models/database/database_enum_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/services/database/entity/shop_card_entity.dart';
|
||||
|
||||
/// 收银弹框打开时冻结的单个购物车条目,避免后续页面状态变化污染展示与提交上下文。
|
||||
class CashierCheckoutItemSnapshot {
|
||||
/// 从现有购物车实体复制全部支付展示所需字段。
|
||||
CashierCheckoutItemSnapshot.fromEntity(
|
||||
ShopCardEntity entity, {
|
||||
this.stock,
|
||||
}) : productServiceUniqueCode = entity.productServiceUniqueCode,
|
||||
productName = entity.productName,
|
||||
productPic = entity.productPic,
|
||||
productPrice = entity.productPrice,
|
||||
nature = entity.nature,
|
||||
productNumber = entity.productNumber,
|
||||
productWeight = entity.productWidget,
|
||||
totalPrice = entity.totalPrice,
|
||||
isPriceIncreased = entity.isPriceIncreased;
|
||||
|
||||
/// 商品跨端唯一编码,仅用于区分购物车条目,不在弹框内修改。
|
||||
final String productServiceUniqueCode;
|
||||
|
||||
/// 打开弹框时的商品名称快照。
|
||||
final String productName;
|
||||
|
||||
/// 打开弹框时的商品图片地址;空字符串表示没有可展示图片。
|
||||
final String productPic;
|
||||
|
||||
/// 商品单价,单位为分。
|
||||
final int productPrice;
|
||||
|
||||
/// 商品计量性质,用于区分按件和称重展示。
|
||||
final ProductNature nature;
|
||||
|
||||
/// 按件商品数量;称重商品通常为零。
|
||||
final int productNumber;
|
||||
|
||||
/// 称重商品重量,单位沿用现有购物车与电子秤契约。
|
||||
final double productWeight;
|
||||
|
||||
/// 当前条目合计金额,单位为分。
|
||||
final int totalPrice;
|
||||
|
||||
/// 当前条目是否包含既有涨价规则结果。
|
||||
final bool isPriceIncreased;
|
||||
|
||||
/// 商品本地库存快照;为空表示同步接口没有返回库存,仅供收银员参考而不限制加购。
|
||||
final int? stock;
|
||||
|
||||
/// 收银汇总中计入的商品件数,称重条目按一个购物车条目计算。
|
||||
int get displayCount => productNumber > 0 ? productNumber : 1;
|
||||
}
|
||||
|
||||
/// 收银支付弹框打开瞬间的不可变交易快照。
|
||||
///
|
||||
/// 购物车实体会被转换为只读条目,金额继续使用项目既有的整数分契约。
|
||||
/// 适配说明:源自消费机项目的快照含食堂套餐组成(combo)支持,本超市项目
|
||||
/// 无套餐数据模型,套餐字段在移植时移除。
|
||||
class CashierCheckoutSnapshot {
|
||||
/// 创建一份仅供弹框展示和支付上下文使用的不可变快照。
|
||||
CashierCheckoutSnapshot.capture({
|
||||
required List<ShopCardEntity> cartItems,
|
||||
required this.originalAmount,
|
||||
required this.discountAmount,
|
||||
required this.surchargeAmount,
|
||||
required this.payableAmount,
|
||||
this.cartTitle = '当前购物车',
|
||||
Map<String, int?> stockByProductCode = const {},
|
||||
}) : items = List<CashierCheckoutItemSnapshot>.unmodifiable(
|
||||
cartItems.map(
|
||||
(entity) => CashierCheckoutItemSnapshot.fromEntity(
|
||||
entity,
|
||||
stock: stockByProductCode[entity.productServiceUniqueCode],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 购物车区域标题,食堂和超市入口可按场景覆盖。
|
||||
final String cartTitle;
|
||||
|
||||
/// 打开弹框时复制的只读购物车条目。
|
||||
final List<CashierCheckoutItemSnapshot> items;
|
||||
|
||||
/// 商品原价合计,单位为分。
|
||||
final int originalAmount;
|
||||
|
||||
/// 优惠合计,单位为分;零表示当前交易没有优惠。
|
||||
final int discountAmount;
|
||||
|
||||
/// 附加费用合计,单位为分;可用于食堂餐费等既有费用。
|
||||
final int surchargeAmount;
|
||||
|
||||
/// 弹框打开时的应付金额,单位为分。
|
||||
final int payableAmount;
|
||||
|
||||
/// 当前快照包含的商品数量,称重商品按一个条目计算。
|
||||
int get productCount =>
|
||||
items.fold<int>(0, (total, item) => total + item.displayCount);
|
||||
|
||||
/// 当前快照是否没有任何购物车条目。
|
||||
bool get isEmpty => items.isEmpty;
|
||||
}
|
||||
@@ -35,8 +35,8 @@ class RouteNames {
|
||||
/// 设置(终端二级路由)
|
||||
static const setting = '$terminal/setting';
|
||||
|
||||
/// 定时任务日志(根路由,全屏覆盖终端外壳)
|
||||
static const settingSyncLogs = '/setting/sync-logs';
|
||||
/// 定时任务日志(终端二级路由,从设置页压栈打开)
|
||||
static const settingSyncLogs = '$terminal/setting/sync-logs';
|
||||
|
||||
/// 饭卡(终端二级路由)
|
||||
static const cardActive = '$terminal/card_active';
|
||||
|
||||
@@ -31,16 +31,5 @@ class RoutePages {
|
||||
name: RouteNames.terminal,
|
||||
page: () => const TerminalPage(),
|
||||
),
|
||||
// 定时任务日志从设置页全屏打开,保留根路由与 Get.arguments 传参方式。
|
||||
GetPage(
|
||||
name: RouteNames.settingSyncLogs,
|
||||
page: () => const ScheduledTaskLogsPage(),
|
||||
binding: BindingsBuilder(() {
|
||||
Get.lazyPut(
|
||||
() => ScheduledTaskLogsController(Get.arguments as ScheduledTaskType),
|
||||
fenix: true,
|
||||
);
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -10,6 +10,15 @@ class AppThemeControl extends GetxController {
|
||||
/// 字体大小
|
||||
RxDouble sizeScalar = SettingGlobalFontSize.xl3.fontSize.obs;
|
||||
|
||||
/// 按钮圆角
|
||||
final buttonRadius = SettingButtonRadius.standard.obs;
|
||||
|
||||
/// 卡片圆角
|
||||
final cardRadius = SettingCardRadius.standard.obs;
|
||||
|
||||
/// 按钮尺寸档位
|
||||
final buttonSize = SettingButtonSize.standard.obs;
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
super.onInit();
|
||||
@@ -20,9 +29,36 @@ class AppThemeControl extends GetxController {
|
||||
if (appSetting.globalFontSize != null) {
|
||||
sizeScalar.value = appSetting.globalFontSize!.fontSize;
|
||||
}
|
||||
buttonRadius.value = appSetting.buttonRadius;
|
||||
cardRadius.value = appSetting.cardRadius;
|
||||
buttonSize.value = appSetting.buttonSize;
|
||||
_applyMetrics();
|
||||
}
|
||||
|
||||
void setSizeScalar(SettingGlobalFontSize fontSize) {
|
||||
sizeScalar.value = fontSize.fontSize;
|
||||
}
|
||||
|
||||
void setButtonRadius(SettingButtonRadius value) {
|
||||
buttonRadius.value = value;
|
||||
_applyMetrics();
|
||||
}
|
||||
|
||||
void setCardRadius(SettingCardRadius value) {
|
||||
cardRadius.value = value;
|
||||
_applyMetrics();
|
||||
}
|
||||
|
||||
void setButtonSize(SettingButtonSize value) {
|
||||
buttonSize.value = value;
|
||||
_applyMetrics();
|
||||
}
|
||||
|
||||
void _applyMetrics() {
|
||||
XfjDesignTokens.applyThemeMetrics(
|
||||
cardRadius: cardRadius.value.radius,
|
||||
controlRadius: buttonRadius.value.radius,
|
||||
buttonSizeScale: buttonSize.value.scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@ class MqManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前连接是否已建立;供基本信息页等界面展示实时连接状态。
|
||||
static bool get isConnected =>
|
||||
state == MqttConnectionState.connected && mqttClient != null;
|
||||
|
||||
static Future<void> subscribeCurrentTopic() async {
|
||||
if (ObjectUtil.isEmpty(mqttClient)) return;
|
||||
final config = await validate();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 商品类型标签:套餐为主色实底胶囊加图层图标,单品为浅灰描边胶囊加餐具图标。
|
||||
///
|
||||
/// 图标、底色和字色三重差异避免仅靠颜色传达类型,供查询菜单核销弹框与
|
||||
/// 收银结算弹框共用,保证两个场景的类型语义完全一致。
|
||||
class ProductTypeTag extends StatelessWidget {
|
||||
/// 创建商品类型标签。
|
||||
const ProductTypeTag({super.key, this.isCombo = false});
|
||||
|
||||
/// 是否为组合套餐;false 表示单品。
|
||||
final bool isCombo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return <Widget>[
|
||||
FIcon(
|
||||
isCombo ? FAssets.icons.layers : FAssets.icons.utensils,
|
||||
color: isCombo ? Colors.white : XfjDesignTokens.muted,
|
||||
size: 12.sp,
|
||||
),
|
||||
Text(
|
||||
isCombo ? '套餐' : '单品',
|
||||
style: FTheme.of(context).typography.xs.copyWith(
|
||||
color: isCombo ? Colors.white : XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
]
|
||||
.toRowSpace(space: 3.w, mainAxisSize: MainAxisSize.min)
|
||||
.padding(horizontal: 9.w, vertical: 3.h)
|
||||
.decorated(
|
||||
color: isCombo ? XfjDesignTokens.primary : XfjDesignTokens.control,
|
||||
border: Border.all(
|
||||
color: isCombo ? XfjDesignTokens.primary : XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.w),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 商品类型卡片皮肤:套餐“蓝底蓝框”、单品“灰底灰框”。
|
||||
///
|
||||
/// 背景色与边框双重差异让操作员不读标签即可扫视分辨商品类型;内容边距
|
||||
/// 由调用方自行控制,皮肤只负责底色、边框和圆角。
|
||||
class ProductTypeCard extends StatelessWidget {
|
||||
/// 创建按商品类型着色的卡片容器。
|
||||
const ProductTypeCard({
|
||||
super.key,
|
||||
this.isCombo = false,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// 是否为组合套餐;false 表示单品。
|
||||
final bool isCombo;
|
||||
|
||||
/// 卡片内容,通常为主商品行及其可展开的套餐组成列表。
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return child.decorated(
|
||||
color: isCombo ? XfjDesignTokens.primarySoft : XfjDesignTokens.canvas,
|
||||
border: Border.all(
|
||||
color: isCombo
|
||||
? XfjDesignTokens.primary.withValues(alpha: 0.45)
|
||||
: XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,8 @@ class TerminalBrand extends StatelessWidget {
|
||||
'PJ',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.typography.xl6.copyWith(
|
||||
color: const Color(0xFF1268E8),
|
||||
// 与顶栏标题保持同一主题墨色,不使用单独的品牌蓝。
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
@@ -659,7 +660,8 @@ class TerminalSleepOverlay extends StatelessWidget {
|
||||
Text(
|
||||
'PJ',
|
||||
style: theme.typography.xl2.copyWith(
|
||||
color: XfjDesignTokens.primary,
|
||||
// 与品牌行其余文字保持同一主题墨色,不再单独使用品牌蓝。
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
import 'package:pj_supermarket_cloud/common/widgets/face/face_recognition_preview_layer.dart';
|
||||
|
||||
/// 人脸摄像头画面组件,只负责 Texture、人脸框、摄像头状态和空画面提示。
|
||||
class FaceCameraPreview extends StatelessWidget {
|
||||
/// 创建一个不持有识别会话和插件资源的摄像头展示区域。
|
||||
const FaceCameraPreview({
|
||||
super.key,
|
||||
required this.texture,
|
||||
required this.event,
|
||||
required this.status,
|
||||
this.statusText,
|
||||
this.statusColor,
|
||||
this.scale = 1,
|
||||
});
|
||||
|
||||
/// 原生创建的预览纹理;为空时展示摄像头准备状态。
|
||||
final FaceRecognitionPreviewTexture? texture;
|
||||
|
||||
/// 最近一次原生识别事件,用于绘制人脸框和方向信息。
|
||||
final FaceRecognitionSessionEvent? event;
|
||||
|
||||
/// 当前识别状态,用于展示文字和非颜色状态标识。
|
||||
final FaceRecognitionSessionStatus status;
|
||||
|
||||
/// 业务页面覆盖的摄像头状态文案;为空时沿用原生会话状态文案。
|
||||
final String? statusText;
|
||||
|
||||
/// 业务页面覆盖的摄像头状态颜色;为空时沿用原生会话状态颜色。
|
||||
final Color? statusColor;
|
||||
|
||||
/// 宿主 Engine 提供的尺寸缩放系数,副屏不得依赖主屏 ScreenUtil 全局状态。
|
||||
final double scale;
|
||||
|
||||
/// 构建固定比例的摄像头画面和状态浮层。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(0xFF102635),
|
||||
child: <Widget>[
|
||||
if (texture != null)
|
||||
FaceRecognitionPreviewLayer(
|
||||
texture: texture!,
|
||||
event: event,
|
||||
labelScale: scale,
|
||||
)
|
||||
else
|
||||
_FaceCameraEmptyState(status: status, scale: scale),
|
||||
_FaceCameraStatusPill(
|
||||
status: status,
|
||||
text: statusText,
|
||||
color: statusColor,
|
||||
scale: scale,
|
||||
).positioned(
|
||||
left: 18 * scale,
|
||||
top: 18 * scale,
|
||||
),
|
||||
_FaceCameraResolutionLabel(
|
||||
texture: texture,
|
||||
scale: scale,
|
||||
).positioned(
|
||||
right: 18 * scale,
|
||||
bottom: 16 * scale,
|
||||
),
|
||||
].toStack(fit: StackFit.expand),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄像头 Texture 尚未创建时的明确等待或失败提示。
|
||||
class _FaceCameraEmptyState extends StatelessWidget {
|
||||
/// 创建与当前识别状态匹配的空画面。
|
||||
const _FaceCameraEmptyState({required this.status, required this.scale});
|
||||
|
||||
/// 决定展示加载反馈还是设备异常提示的会话状态。
|
||||
final FaceRecognitionSessionStatus status;
|
||||
|
||||
/// 宿主页面计算的尺寸缩放系数。
|
||||
final double scale;
|
||||
|
||||
/// 构建不会造成画面尺寸跳变的居中状态。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final failed = status == FaceRecognitionSessionStatus.failed;
|
||||
return <Widget>[
|
||||
if (failed)
|
||||
Icon(
|
||||
Icons.videocam_off_rounded,
|
||||
color: Colors.white,
|
||||
size: 42 * scale,
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 36 * scale,
|
||||
height: 36 * scale,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12 * scale),
|
||||
Text(
|
||||
failed ? '摄像头画面暂不可用' : '正在准备摄像头画面',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
].toColumn(mainAxisSize: MainAxisSize.min).center();
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄像头左上角的文字状态标识,确保状态不只依赖红绿颜色表达。
|
||||
class _FaceCameraStatusPill extends StatelessWidget {
|
||||
/// 创建当前识别阶段的状态标识。
|
||||
const _FaceCameraStatusPill({
|
||||
required this.status,
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
/// 当前原生识别会话状态。
|
||||
final FaceRecognitionSessionStatus status;
|
||||
|
||||
/// 业务页面提供的可选状态文案。
|
||||
final String? text;
|
||||
|
||||
/// 业务页面提供的可选语义颜色。
|
||||
final Color? color;
|
||||
|
||||
/// 宿主页面计算的尺寸缩放系数。
|
||||
final double scale;
|
||||
|
||||
/// 构建包含状态点和文字的高对比标签。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resolvedText = text ??
|
||||
switch (status) {
|
||||
FaceRecognitionSessionStatus.initializing => '初始化中',
|
||||
FaceRecognitionSessionStatus.success => '已识别',
|
||||
FaceRecognitionSessionStatus.failed => '需要重试',
|
||||
FaceRecognitionSessionStatus.cancelled => '已取消',
|
||||
_ => '识别中',
|
||||
};
|
||||
final resolvedColor = color ??
|
||||
switch (status) {
|
||||
FaceRecognitionSessionStatus.failed ||
|
||||
FaceRecognitionSessionStatus.cancelled =>
|
||||
XfjDesignTokens.danger,
|
||||
FaceRecognitionSessionStatus.initializing => XfjDesignTokens.warning,
|
||||
_ => XfjDesignTokens.success,
|
||||
};
|
||||
return <Widget>[
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: resolvedColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
).width(10 * scale).height(10 * scale),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
resolvedText,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
]
|
||||
.toRow(mainAxisSize: MainAxisSize.min)
|
||||
.padding(horizontal: 14 * scale, vertical: 9 * scale)
|
||||
.decorated(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄像头右下角的实际纹理分辨率标签。
|
||||
class _FaceCameraResolutionLabel extends StatelessWidget {
|
||||
/// 创建分辨率说明;纹理未就绪时使用插件既有默认分辨率。
|
||||
const _FaceCameraResolutionLabel({
|
||||
required this.texture,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
/// 当前预览纹理元数据。
|
||||
final FaceRecognitionPreviewTexture? texture;
|
||||
|
||||
/// 宿主页面计算的尺寸缩放系数。
|
||||
final double scale;
|
||||
|
||||
/// 构建不遮挡主要人脸区域的紧凑标签。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
'彩色摄像头 ${texture?.width ?? 1280} x ${texture?.height ?? 720}',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13 * scale),
|
||||
).padding(horizontal: 12 * scale, vertical: 8 * scale).decorated(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart';
|
||||
import 'package:pj_supermarket_cloud/common/widgets/face/face_camera_preview.dart';
|
||||
|
||||
/// 主副屏共用的常驻人脸摄像头预览。
|
||||
///
|
||||
/// 组件只持有当前 Flutter Engine 的 Texture 和原生状态订阅,不创建、结束或重试
|
||||
/// 业务识别 Session,因此查询结果展示的十秒内仍能保持实时画面。
|
||||
class FaceLiveCameraPreview extends StatefulWidget {
|
||||
/// 创建跟随宿主页面生命周期的实时预览区域。
|
||||
const FaceLiveCameraPreview({
|
||||
super.key,
|
||||
required this.statusText,
|
||||
required this.statusColor,
|
||||
this.scale = 1,
|
||||
});
|
||||
|
||||
/// 查询状态对应的可见文案,避免原生空闲态被误解为仍在识别下一位。
|
||||
final String statusText;
|
||||
|
||||
/// 查询状态对应的语义颜色,必须与文字同时表达状态。
|
||||
final Color statusColor;
|
||||
|
||||
/// 宿主 Engine 自己计算的尺寸比例,默认按 1920 x 1080 设计尺寸一比一展示。
|
||||
final double scale;
|
||||
|
||||
/// 创建当前 Flutter Engine 的 Texture 生命周期状态。
|
||||
@override
|
||||
State<FaceLiveCameraPreview> createState() => _FaceLiveCameraPreviewState();
|
||||
}
|
||||
|
||||
/// [FaceLiveCameraPreview] 的 Texture、原生事件和异常状态所有者。
|
||||
class _FaceLiveCameraPreviewState extends State<FaceLiveCameraPreview> {
|
||||
/// 当前 Flutter Engine 持有的原生预览纹理。
|
||||
FaceRecognitionPreviewTexture? _texture;
|
||||
|
||||
/// 最近一次原生人脸会话事件,用于方向、人脸框和设备状态渲染。
|
||||
FaceRecognitionSessionEvent? _event;
|
||||
|
||||
/// 常驻原生会话事件订阅,页面销毁时必须取消。
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _eventSubscription;
|
||||
|
||||
/// Texture 异步创建序号,用于释放页面销毁或重建后迟到的旧资源。
|
||||
int _textureRequestId = 0;
|
||||
|
||||
/// 当前预览初始化失败信息;为空表示按 Texture 和原生状态正常展示。
|
||||
String? _previewError;
|
||||
|
||||
/// 订阅原生状态并创建当前 Engine 独立的输出 Texture。
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = FaceDiscernPlugs.sessionStream.listen(
|
||||
_handleEvent,
|
||||
onError: _handleEventError,
|
||||
);
|
||||
unawaited(_loadInitialState());
|
||||
unawaited(_initializeTexture());
|
||||
}
|
||||
|
||||
/// 读取挂载前已存在的热摄像头状态,避免页面刚进入时长期停留在初始化提示。
|
||||
Future<void> _loadInitialState() async {
|
||||
try {
|
||||
final event = await FaceDiscernPlugs.getRecognitionState();
|
||||
// EventChannel 可能先送达真实检测帧;迟到的 MethodChannel 快照不含 faceBoxes,
|
||||
// 只能用于尚未收到任何事件的首次占位,不能把已经显示的人脸框清空。
|
||||
if (mounted && _event == null) setState(() => _event = event);
|
||||
} catch (_) {
|
||||
// 后续 EventChannel 仍可恢复状态,首次快照失败不覆盖真实 Texture 画面。
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新最近原生状态;致命失败后的冷重试需要替换已经失效的 Renderer 输出。
|
||||
void _handleEvent(FaceRecognitionSessionEvent event) {
|
||||
if (!mounted || !_shouldAcceptEvent(event)) return;
|
||||
final previousStatus = _event?.status;
|
||||
final shouldReplace =
|
||||
(previousStatus == FaceRecognitionSessionStatus.cold ||
|
||||
previousStatus == FaceRecognitionSessionStatus.failed) &&
|
||||
(event.status == FaceRecognitionSessionStatus.initializing ||
|
||||
event.status == FaceRecognitionSessionStatus.detecting);
|
||||
setState(() {
|
||||
_event = event;
|
||||
_previewError = null;
|
||||
});
|
||||
if (shouldReplace) unawaited(_initializeTexture(replace: true));
|
||||
}
|
||||
|
||||
/// 按原生单调序号拒绝乱序快照,避免跨会话的旧空框覆盖较新的多人检测结果。
|
||||
bool _shouldAcceptEvent(FaceRecognitionSessionEvent event) {
|
||||
final current = _event;
|
||||
if (current == null) return true;
|
||||
if (current.eventSequence > 0 && event.eventSequence > 0) {
|
||||
if (event.eventSequence != current.eventSequence) {
|
||||
return event.eventSequence > current.eventSequence;
|
||||
}
|
||||
// MethodChannel 快照不含框;允许相同序号的真实 EventChannel 事件补齐它。
|
||||
return current.faceBoxes.isEmpty && event.faceBoxes.isNotEmpty;
|
||||
}
|
||||
if (event.sessionId != current.sessionId) {
|
||||
// 兼容旧版原生事件:活动识别期间不得让迟到的空闲资源快照清掉当前人脸框。
|
||||
return !(current.isActive && event.sessionId.isEmpty);
|
||||
}
|
||||
return event.revision >= current.revision;
|
||||
}
|
||||
|
||||
/// 将原生事件通道异常收敛为预览内提示,不中断主屏查询状态机。
|
||||
void _handleEventError(Object error) {
|
||||
if (mounted) setState(() => _previewError = '摄像头状态连接异常,请检查设备');
|
||||
}
|
||||
|
||||
/// 创建或替换预览 Texture,并释放异步迟到或已被替换的输出。
|
||||
Future<void> _initializeTexture({bool replace = false}) async {
|
||||
final requestId = ++_textureRequestId;
|
||||
if (replace) {
|
||||
final previous = _texture;
|
||||
_texture = null;
|
||||
if (previous != null) {
|
||||
try {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(previous.textureId);
|
||||
} catch (_) {
|
||||
// 原生 Renderer 已冷释放时旧端点可能失效,仍要继续创建新 Texture 恢复画面。
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
try {
|
||||
final texture = await FaceDiscernPlugs.createPreviewTexture();
|
||||
if (!mounted || requestId != _textureRequestId) {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(texture.textureId);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_texture = texture;
|
||||
_previewError = null;
|
||||
});
|
||||
} catch (_) {
|
||||
// 已被新一轮 Texture 请求替代的旧异常不得覆盖新画面。
|
||||
if (mounted && requestId == _textureRequestId) {
|
||||
setState(() => _previewError = '摄像头画面暂不可用,请检查设备');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消事件订阅并释放当前 Engine 的 Texture,不触碰业务识别 Session。
|
||||
@override
|
||||
void dispose() {
|
||||
_textureRequestId++;
|
||||
unawaited(_eventSubscription?.cancel());
|
||||
final texture = _texture;
|
||||
_texture = null;
|
||||
if (texture != null) {
|
||||
unawaited(FaceDiscernPlugs.releasePreviewTexture(texture.textureId));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 构建不会因 Session 暂停或结果展示而移除的实时画面。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = _previewError == null
|
||||
? _event?.status ?? FaceRecognitionSessionStatus.initializing
|
||||
: FaceRecognitionSessionStatus.failed;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FaceCameraPreview(
|
||||
texture: _texture,
|
||||
event: _event,
|
||||
status: status,
|
||||
statusText: widget.statusText,
|
||||
statusColor: widget.statusColor,
|
||||
scale: widget.scale,
|
||||
),
|
||||
if (_previewError != null)
|
||||
Center(
|
||||
child: Text(
|
||||
_previewError!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16 * widget.scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_discern_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart';
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/plugs/face_discern_plugs.dart';
|
||||
import 'package:pj_supermarket_cloud/common/widgets/face/face_camera_preview.dart';
|
||||
|
||||
/// 人脸识别面板的默认无人结果超时,超时后保留当前会话以允许原位重试。
|
||||
const Duration faceRecognitionPanelDefaultTimeout = Duration(seconds: 45);
|
||||
|
||||
/// 人脸识别成功画面的默认冻结时长,沿用原业务成功展示语义。
|
||||
const Duration faceRecognitionPanelSuccessHold = Duration(seconds: 1);
|
||||
|
||||
/// 宿主控制人脸面板取消动作的轻量句柄,不直接暴露 State 或原生插件。
|
||||
class FaceRecognitionPanelController {
|
||||
/// 当前挂载面板提供的取消实现;面板销毁后会清空。
|
||||
Future<void> Function()? _cancelCallback;
|
||||
|
||||
/// 取消当前面板持有的识别会话;没有挂载面板时直接完成。
|
||||
Future<void> cancel() async {
|
||||
await _cancelCallback?.call();
|
||||
}
|
||||
|
||||
/// 由面板挂载时绑定取消实现,禁止业务侧直接调用。
|
||||
void _attach(Future<void> Function() callback) {
|
||||
_cancelCallback = callback;
|
||||
}
|
||||
|
||||
/// 仅清除当前面板绑定,避免旧面板销毁时覆盖新面板句柄。
|
||||
void _detach(Future<void> Function() callback) {
|
||||
if (_cancelCallback == callback) _cancelCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 可嵌入独立弹框或收银工作区的人脸识别面板。
|
||||
///
|
||||
/// 面板持有事件订阅、Texture、超时和结果幂等;销毁时会停止仍属于自己的
|
||||
/// 会话并释放纹理,避免重复打开后累积监听器或继续消费旧识别结果。
|
||||
class FaceRecognitionPanel extends StatefulWidget {
|
||||
/// 创建绑定到单个人脸会话句柄的识别面板。
|
||||
const FaceRecognitionPanel({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.handle,
|
||||
required this.onRecognized,
|
||||
this.controller,
|
||||
this.onFailure,
|
||||
this.onCancelled,
|
||||
this.timeout = faceRecognitionPanelDefaultTimeout,
|
||||
this.successHoldDuration = faceRecognitionPanelSuccessHold,
|
||||
this.showCancelAction = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// 应用级唯一人脸会话服务。
|
||||
final FaceRecognitionSessionService service;
|
||||
|
||||
/// 当前面板独占处理的会话句柄。
|
||||
final FaceRecognitionSessionHandle handle;
|
||||
|
||||
/// 成功冻结结束后的结果回调,每个面板生命周期最多调用一次。
|
||||
final ValueChanged<FaceStream> onRecognized;
|
||||
|
||||
/// 供宿主主动取消当前面板的可选控制器。
|
||||
final FaceRecognitionPanelController? controller;
|
||||
|
||||
/// 插件、超时或识别失败回调;非终止失败仍允许用户原位重试。
|
||||
final ValueChanged<FaceRecognitionFailure>? onFailure;
|
||||
|
||||
/// 用户主动取消后的通知回调。
|
||||
final VoidCallback? onCancelled;
|
||||
|
||||
/// 单轮识别超时时长;到期后显示可恢复提示但不自动关闭弹框。
|
||||
final Duration timeout;
|
||||
|
||||
/// 成功结果在主屏保持冻结的时长。
|
||||
final Duration successHoldDuration;
|
||||
|
||||
/// 是否在面板底部展示取消识别按钮。
|
||||
final bool showCancelAction;
|
||||
|
||||
/// 是否使用收银工作区的紧凑间距。
|
||||
final bool compact;
|
||||
|
||||
/// 创建识别订阅、Texture 和超时任务状态。
|
||||
@override
|
||||
State<FaceRecognitionPanel> createState() => _FaceRecognitionPanelState();
|
||||
}
|
||||
|
||||
/// [FaceRecognitionPanel] 的资源和可见状态所有者。
|
||||
class _FaceRecognitionPanelState extends State<FaceRecognitionPanel> {
|
||||
/// 当前会话事件订阅,销毁面板时必须取消。
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _eventSubscription;
|
||||
|
||||
/// 当前面板持有的原生预览纹理。
|
||||
FaceRecognitionPreviewTexture? _texture;
|
||||
|
||||
/// 最近一次属于当前会话的识别事件。
|
||||
FaceRecognitionSessionEvent? _event;
|
||||
|
||||
/// 当前可恢复或终止失败;为空表示按事件状态展示。
|
||||
FaceRecognitionFailure? _failure;
|
||||
|
||||
/// 单轮超时计时器,重试时重新开始。
|
||||
Timer? _timeoutTimer;
|
||||
|
||||
/// Texture 异步创建序号,用于释放迟到的旧纹理。
|
||||
int _textureRequestId = 0;
|
||||
|
||||
/// 成功结果锁,防止 Future 和事件重复触发业务回调。
|
||||
bool _resultHandled = false;
|
||||
|
||||
/// 主动取消锁,防止按钮、切换方式和销毁同时取消同一会话。
|
||||
bool _cancelling = false;
|
||||
|
||||
/// 初始化事件监听、取消句柄、Texture 与超时任务。
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller?._attach(_cancel);
|
||||
_eventSubscription = widget.handle.events.listen(
|
||||
_handleEvent,
|
||||
onError: _handleStreamError,
|
||||
);
|
||||
_restartTimeout();
|
||||
unawaited(_initializeTexture());
|
||||
unawaited(_waitForResult());
|
||||
}
|
||||
|
||||
/// 仅接收当前句柄事件,并把原生失败转换为面板内可恢复状态。
|
||||
void _handleEvent(FaceRecognitionSessionEvent event) {
|
||||
if (!mounted || event.sessionId != widget.handle.sessionId) return;
|
||||
final failure = event.status == FaceRecognitionSessionStatus.failed
|
||||
? FaceRecognitionFailure(
|
||||
code: event.errorCode ?? 'RECOGNITION_FAILED',
|
||||
message: event.message.isEmpty ? '人脸识别失败,请重试' : event.message,
|
||||
)
|
||||
: null;
|
||||
setState(() {
|
||||
_event = event;
|
||||
_failure = failure;
|
||||
});
|
||||
if (failure != null) widget.onFailure?.call(failure);
|
||||
}
|
||||
|
||||
/// 将事件通道异常转为终止失败,宿主可据此结束当前业务流程。
|
||||
void _handleStreamError(Object error) {
|
||||
_showFailure(const FaceRecognitionFailure(
|
||||
code: 'SESSION_STREAM_ERROR',
|
||||
message: '人脸识别服务连接异常,请重新发起识别',
|
||||
));
|
||||
}
|
||||
|
||||
/// 创建或替换预览 Texture,并释放异步迟到或已被替换的资源。
|
||||
Future<void> _initializeTexture({bool replace = false}) async {
|
||||
final requestId = ++_textureRequestId;
|
||||
if (replace) {
|
||||
final previous = _texture;
|
||||
_texture = null;
|
||||
if (previous != null) {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(previous.textureId);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
try {
|
||||
final texture = await FaceDiscernPlugs.createPreviewTexture();
|
||||
if (!mounted || requestId != _textureRequestId) {
|
||||
await FaceDiscernPlugs.releasePreviewTexture(texture.textureId);
|
||||
return;
|
||||
}
|
||||
setState(() => _texture = texture);
|
||||
} catch (_) {
|
||||
_showFailure(const FaceRecognitionFailure(
|
||||
code: 'PREVIEW_TEXTURE_FAILED',
|
||||
message: '无法显示摄像头画面,请检查设备后重试',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待服务层的幂等结果,冻结三秒后结束原生会话并通知宿主。
|
||||
Future<void> _waitForResult() async {
|
||||
try {
|
||||
final result = await widget.handle.result;
|
||||
if (!mounted || _resultHandled || _cancelling) return;
|
||||
_resultHandled = true;
|
||||
_timeoutTimer?.cancel();
|
||||
if (_event?.status != FaceRecognitionSessionStatus.success) {
|
||||
setState(() => _failure = null);
|
||||
}
|
||||
await Future<void>.delayed(widget.successHoldDuration);
|
||||
if (!mounted || _cancelling) return;
|
||||
if (widget.service.owns(widget.handle.sessionId)) {
|
||||
await widget.service.finish();
|
||||
}
|
||||
if (mounted) widget.onRecognized(result);
|
||||
} on FaceRecognitionFailure catch (failure) {
|
||||
if (!mounted || _cancelling) return;
|
||||
if (failure.cancelled) {
|
||||
widget.onCancelled?.call();
|
||||
return;
|
||||
}
|
||||
_showFailure(failure);
|
||||
} catch (_) {
|
||||
_showFailure(const FaceRecognitionFailure(
|
||||
code: 'RECOGNITION_FAILED',
|
||||
message: '人脸识别异常,请重新识别',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置单轮超时;超时不结束会话,使原生 retry 能继续复用热摄像头。
|
||||
void _restartTimeout() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(widget.timeout, () {
|
||||
_showFailure(const FaceRecognitionFailure(
|
||||
code: 'RECOGNITION_TIMEOUT',
|
||||
message: '暂未识别到人员,请调整距离或角度后重试',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// 展示结构化失败并同步通知宿主,不因 Toast 或全局 Loading 遮挡工作区。
|
||||
void _showFailure(FaceRecognitionFailure failure) {
|
||||
if (!mounted || _cancelling || _resultHandled) return;
|
||||
_timeoutTimer?.cancel();
|
||||
setState(() => _failure = failure);
|
||||
widget.onFailure?.call(failure);
|
||||
}
|
||||
|
||||
/// 原位重试当前会话并替换 Texture,避免旧预览资源继续占用。
|
||||
Future<void> _retry() async {
|
||||
if (_cancelling || _resultHandled) return;
|
||||
await HapticFeedback.mediumImpact();
|
||||
setState(() => _failure = null);
|
||||
try {
|
||||
await widget.service.retry();
|
||||
await _initializeTexture(replace: true);
|
||||
_restartTimeout();
|
||||
} on FaceRecognitionFailure catch (failure) {
|
||||
_showFailure(failure);
|
||||
} catch (_) {
|
||||
_showFailure(const FaceRecognitionFailure(
|
||||
code: 'RECOGNITION_RETRY_FAILED',
|
||||
message: '重新识别失败,请检查摄像头后再试',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动取消当前会话,并在服务释放后通知宿主关闭或切换工作区。
|
||||
Future<void> _cancel() async {
|
||||
if (_cancelling || _resultHandled) return;
|
||||
_cancelling = true;
|
||||
_timeoutTimer?.cancel();
|
||||
await HapticFeedback.mediumImpact();
|
||||
if (widget.service.owns(widget.handle.sessionId)) {
|
||||
await widget.service.cancel();
|
||||
}
|
||||
if (mounted) widget.onCancelled?.call();
|
||||
}
|
||||
|
||||
/// 取消监听、释放 Texture,并停止仍属于当前面板的会话。
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller?._detach(_cancel);
|
||||
_timeoutTimer?.cancel();
|
||||
unawaited(_eventSubscription?.cancel());
|
||||
final texture = _texture;
|
||||
_texture = null;
|
||||
if (texture != null) {
|
||||
unawaited(FaceDiscernPlugs.releasePreviewTexture(texture.textureId));
|
||||
}
|
||||
if (widget.service.owns(widget.handle.sessionId)) {
|
||||
unawaited(_resultHandled
|
||||
? widget.service.finish()
|
||||
: widget.service.cancel(message: '识别界面已关闭'));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 构建摄像头、状态、隐私说明和可恢复操作区。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = _failure == null
|
||||
? _event?.status ?? FaceRecognitionSessionStatus.initializing
|
||||
: FaceRecognitionSessionStatus.failed;
|
||||
final gap = widget.compact ? 12.h : 16.h;
|
||||
return <Widget>[
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: FaceCameraPreview(
|
||||
texture: _texture,
|
||||
event: _event,
|
||||
status: status,
|
||||
scale: 1.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
_FaceRecognitionStatusCard(
|
||||
status: status,
|
||||
message: _failure?.message ?? _event?.message,
|
||||
compact: widget.compact,
|
||||
),
|
||||
SizedBox(height: gap),
|
||||
_FaceRecognitionPrivacyHint(compact: widget.compact),
|
||||
if (_failure != null || widget.showCancelAction) ...[
|
||||
SizedBox(height: gap),
|
||||
<Widget>[
|
||||
if (widget.showCancelAction)
|
||||
_FacePanelButton(
|
||||
label: '取消识别',
|
||||
onTap: _cancel,
|
||||
outlined: true,
|
||||
).expanded(),
|
||||
if (widget.showCancelAction && _failure != null)
|
||||
SizedBox(width: 12.w),
|
||||
if (_failure != null)
|
||||
_FacePanelButton(label: '重新识别', onTap: _retry).expanded(),
|
||||
].toRow(),
|
||||
],
|
||||
].toColumn(mainAxisSize: MainAxisSize.min);
|
||||
}
|
||||
}
|
||||
|
||||
/// 人脸面板的状态说明卡,使用图标与文字共同表达成功和失败。
|
||||
class _FaceRecognitionStatusCard extends StatelessWidget {
|
||||
/// 创建当前识别阶段的说明卡。
|
||||
const _FaceRecognitionStatusCard({
|
||||
required this.status,
|
||||
required this.message,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
/// 当前面板识别状态。
|
||||
final FaceRecognitionSessionStatus status;
|
||||
|
||||
/// 原生或本地超时生成的可排查说明。
|
||||
final String? message;
|
||||
|
||||
/// 是否使用收银工作区紧凑间距。
|
||||
final bool compact;
|
||||
|
||||
/// 构建带有恢复方向的状态反馈。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final failed = status == FaceRecognitionSessionStatus.failed;
|
||||
final success = status == FaceRecognitionSessionStatus.success;
|
||||
final color = failed
|
||||
? XfjDesignTokens.danger
|
||||
: success
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.primary;
|
||||
final title = failed
|
||||
? '人脸识别未完成'
|
||||
: success
|
||||
? '识别成功'
|
||||
: status == FaceRecognitionSessionStatus.initializing
|
||||
? '正在启动摄像头'
|
||||
: '请顾客正对摄像头';
|
||||
final icon = failed
|
||||
? Icons.error_outline_rounded
|
||||
: success
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.face_retouching_natural_rounded;
|
||||
return <Widget>[
|
||||
Icon(icon, color: color, size: compact ? 30.w : 36.w),
|
||||
SizedBox(width: 14.w),
|
||||
<Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 17.sp : 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text(
|
||||
message?.trim().isNotEmpty == true
|
||||
? message!.trim()
|
||||
: '保持面部完整出现在识别框内',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp),
|
||||
),
|
||||
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(),
|
||||
]
|
||||
.toRow(crossAxisAlignment: CrossAxisAlignment.center)
|
||||
.padding(all: compact ? 14.w : 18.w)
|
||||
.decorated(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
border: Border.all(color: color.withValues(alpha: 0.26)),
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 人脸画面使用范围和识别距离提示。
|
||||
class _FaceRecognitionPrivacyHint extends StatelessWidget {
|
||||
/// 创建紧凑或常规密度的隐私提示。
|
||||
const _FaceRecognitionPrivacyHint({required this.compact});
|
||||
|
||||
/// 是否使用收银工作区紧凑间距。
|
||||
final bool compact;
|
||||
|
||||
/// 构建不会与主状态争夺层级的次要说明。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return <Widget>[
|
||||
Icon(
|
||||
Icons.face_retouching_natural_rounded,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 22.w,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Text(
|
||||
'建议距离 50-80cm,保持面部完整可见',
|
||||
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 14.sp),
|
||||
).expanded(),
|
||||
Text(
|
||||
'画面仅用于本次识别',
|
||||
style: TextStyle(color: XfjDesignTokens.muted, fontSize: 13.sp),
|
||||
),
|
||||
]
|
||||
.toRow(crossAxisAlignment: CrossAxisAlignment.center)
|
||||
.padding(horizontal: 14.w, vertical: compact ? 10.h : 12.h)
|
||||
.decorated(
|
||||
color: XfjDesignTokens.control,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 人脸面板底部的固定高度操作按钮。
|
||||
class _FacePanelButton extends StatelessWidget {
|
||||
/// 创建主操作或描边操作按钮。
|
||||
const _FacePanelButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.outlined = false,
|
||||
});
|
||||
|
||||
/// 按钮文字。
|
||||
final String label;
|
||||
|
||||
/// 按钮异步动作;执行期间由上层幂等锁避免重复操作。
|
||||
final Future<void> Function() onTap;
|
||||
|
||||
/// 是否使用取消操作的描边样式。
|
||||
final bool outlined;
|
||||
|
||||
/// 构建至少 56dp 高的触控区域。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: outlined ? XfjDesignTokens.surface : XfjDesignTokens.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
side: BorderSide(
|
||||
color: outlined ? XfjDesignTokens.danger : XfjDesignTokens.primary,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: outlined ? XfjDesignTokens.danger : Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
).center().height(56.h),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 使用全局 [AppDialog] 展示可复用人脸识别面板。
|
||||
///
|
||||
/// 成功画面由面板保持三秒后返回,预订核验等独立场景继续沿用原返回值。
|
||||
Future<FaceStream?> showFaceRecognitionPreviewDialog({
|
||||
required FaceRecognitionSessionService service,
|
||||
required FaceRecognitionSessionHandle handle,
|
||||
}) {
|
||||
final context = Get.context!;
|
||||
return showAppDialog<FaceStream>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
canPop: false,
|
||||
barrierLabel: '人脸识别进行中',
|
||||
builder: (_) => FaceRecognitionPreviewDialog(
|
||||
service: service,
|
||||
handle: handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 独立人脸识别弹框,只负责用 [AppDialog] 包装 [FaceRecognitionPanel]。
|
||||
class FaceRecognitionPreviewDialog extends StatefulWidget {
|
||||
/// 创建绑定到当前识别会话的独立预览弹框。
|
||||
const FaceRecognitionPreviewDialog({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.handle,
|
||||
});
|
||||
|
||||
/// 应用级人脸识别会话服务。
|
||||
final FaceRecognitionSessionService service;
|
||||
|
||||
/// 当前独立弹框处理的会话句柄。
|
||||
final FaceRecognitionSessionHandle handle;
|
||||
|
||||
/// 创建弹框关闭和面板控制状态。
|
||||
@override
|
||||
State<FaceRecognitionPreviewDialog> createState() =>
|
||||
_FaceRecognitionPreviewDialogState();
|
||||
}
|
||||
|
||||
/// [FaceRecognitionPreviewDialog] 的关闭幂等状态。
|
||||
class _FaceRecognitionPreviewDialogState
|
||||
extends State<FaceRecognitionPreviewDialog> {
|
||||
/// 用于标题栏关闭按钮取消当前面板会话。
|
||||
final FaceRecognitionPanelController _panelController =
|
||||
FaceRecognitionPanelController();
|
||||
|
||||
/// 防止成功、取消和终止失败重复关闭同一路由。
|
||||
bool _closing = false;
|
||||
|
||||
/// 取消当前识别并关闭独立弹框。
|
||||
Future<void> _cancel() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await _panelController.cancel();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// 返回唯一识别结果;面板已经完成成功冻结和会话释放。
|
||||
void _recognized(FaceStream result) {
|
||||
if (_closing || !mounted) return;
|
||||
_closing = true;
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
/// 用户在面板内取消时关闭当前路由。
|
||||
void _cancelled() {
|
||||
if (_closing || !mounted) return;
|
||||
_closing = true;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// 终止型错误无法原位重试时关闭弹框,由调用方展示业务级恢复提示。
|
||||
void _failed(FaceRecognitionFailure failure) {
|
||||
if (!failure.terminatesSession || _closing || !mounted) return;
|
||||
_closing = true;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// 构建接近原预览尺寸、受当前 SafeArea 高度约束的全局弹框。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewport = MediaQuery.sizeOf(context);
|
||||
final width = math.min(760.w, viewport.width - 32);
|
||||
final height = math.min(900.h, viewport.height - 32);
|
||||
return AppDialog(
|
||||
title: const Text('人脸识别'),
|
||||
subtitle: const Text('主屏实时预览'),
|
||||
leading: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.videocam_rounded,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 26.w,
|
||||
).center().width(46.w).height(46.h),
|
||||
),
|
||||
headerTrailing: Text(
|
||||
'副屏同步引导中',
|
||||
style: TextStyle(
|
||||
color: XfjDesignTokens.success,
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
).padding(horizontal: 12.w, vertical: 8.h).decorated(
|
||||
color: const Color(0xFFECFBF3),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
width: width,
|
||||
maxWidth: width,
|
||||
height: height,
|
||||
draggable: true,
|
||||
onClose: _cancel,
|
||||
semanticLabel: '人脸识别实时预览弹框',
|
||||
content: SingleChildScrollView(
|
||||
child: FaceRecognitionPanel(
|
||||
service: widget.service,
|
||||
handle: widget.handle,
|
||||
controller: _panelController,
|
||||
onRecognized: _recognized,
|
||||
onFailure: _failed,
|
||||
onCancelled: _cancelled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
|
||||
/// 主副屏共用的人脸 Texture 与跟踪框图层。
|
||||
///
|
||||
/// 原生只上报 NV21 原始坐标;本组件对画面和人脸框应用完全相同的镜像、旋转,
|
||||
/// 避免设备方向补偿后出现画面正常但框位置相反的问题。
|
||||
class FaceRecognitionPreviewLayer extends StatelessWidget {
|
||||
/// 创建与原生 Texture 共用方向参数的人脸预览图层。
|
||||
const FaceRecognitionPreviewLayer({
|
||||
super.key,
|
||||
required this.texture,
|
||||
required this.event,
|
||||
this.labelScale = 1,
|
||||
});
|
||||
|
||||
/// 当前 Flutter Engine 持有的原生预览纹理。
|
||||
final FaceRecognitionPreviewTexture texture;
|
||||
|
||||
/// 最近一帧会话状态;为空时只展示 Texture,不绘制人脸框。
|
||||
final FaceRecognitionSessionEvent? event;
|
||||
|
||||
/// 主副屏标签缩放系数,不改变人脸框的坐标与面积判断。
|
||||
final double labelScale;
|
||||
|
||||
/// 叠加方向一致的纹理和人脸框画布。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rotation = event?.rotation ?? texture.rotation;
|
||||
final mirror = event?.mirror ?? texture.mirror;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_OrientedTexture(
|
||||
textureId: texture.textureId,
|
||||
rotation: rotation,
|
||||
mirror: mirror,
|
||||
),
|
||||
CustomPaint(
|
||||
painter: FaceRecognitionBoxPainter(
|
||||
boxes: event?.faceBoxes ?? const [],
|
||||
rotation: rotation,
|
||||
mirror: mirror,
|
||||
livenessEnabled: event?.livenessEnabled ?? false,
|
||||
keepMaxFace: event?.keepMaxFace ?? false,
|
||||
similarityThreshold: event?.similarityThreshold ?? 0.8,
|
||||
labelScale: labelScale,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 对原生 Texture 应用与人脸框一致的旋转和镜像。
|
||||
class _OrientedTexture extends StatelessWidget {
|
||||
/// 创建只负责方向变换的纹理组件。
|
||||
const _OrientedTexture({
|
||||
required this.textureId,
|
||||
required this.rotation,
|
||||
required this.mirror,
|
||||
});
|
||||
|
||||
/// 当前 Flutter Engine 内的 Texture 编号。
|
||||
final int textureId;
|
||||
|
||||
/// 纹理顺时针旋转角度,单位为度。
|
||||
final int rotation;
|
||||
|
||||
/// 是否水平镜像纹理。
|
||||
final bool mirror;
|
||||
|
||||
/// 构建镜像后再按四分之一圈旋转的预览纹理。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Texture(textureId: textureId);
|
||||
if (mirror) {
|
||||
child = Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.diagonal3Values(-1, 1, 1),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return RotatedBox(quarterTurns: _quarterTurns(rotation), child: child);
|
||||
}
|
||||
}
|
||||
|
||||
/// 绘制真实相似度、画面面积占比和当前识别策略。
|
||||
///
|
||||
/// 面积占比来自检测框归一化面积,不是相似度;仅识别最大脸时,最大面积框
|
||||
/// 使用绿色并显示“识别目标”,其他框使用红色并显示“非识别目标”,避免只靠颜色传达状态。
|
||||
class FaceRecognitionBoxPainter extends CustomPainter {
|
||||
/// 创建人脸框绘制器。
|
||||
const FaceRecognitionBoxPainter({
|
||||
required this.boxes,
|
||||
required this.rotation,
|
||||
required this.mirror,
|
||||
required this.livenessEnabled,
|
||||
required this.keepMaxFace,
|
||||
required this.similarityThreshold,
|
||||
this.labelScale = 1,
|
||||
});
|
||||
|
||||
/// 当前帧检测到的全部归一化人脸框。
|
||||
final List<FaceRecognitionFaceBox> boxes;
|
||||
|
||||
/// 预览画面顺时针旋转角度,单位为度。
|
||||
final int rotation;
|
||||
|
||||
/// 预览画面是否水平镜像。
|
||||
final bool mirror;
|
||||
|
||||
/// 当前识别配置是否启用活体检测。
|
||||
final bool livenessEnabled;
|
||||
|
||||
/// 当前识别配置是否只允许面积最大的人脸参与识别。
|
||||
final bool keepMaxFace;
|
||||
|
||||
/// 人脸比对成功阈值,范围为 0 到 1。
|
||||
final double similarityThreshold;
|
||||
|
||||
/// 标签和描边缩放系数。
|
||||
final double labelScale;
|
||||
|
||||
/// 当前帧面积最大的人脸下标;没有人脸时为空,面积相同时保持原生列表中的第一张。
|
||||
int? get primaryFaceIndex {
|
||||
if (boxes.isEmpty) return null;
|
||||
var largestIndex = 0;
|
||||
var largestArea = boxes.first.normalizedArea;
|
||||
for (var index = 1; index < boxes.length; index++) {
|
||||
final area = boxes[index].normalizedArea;
|
||||
if (area > largestArea) {
|
||||
largestIndex = index;
|
||||
largestArea = area;
|
||||
}
|
||||
}
|
||||
return largestIndex;
|
||||
}
|
||||
|
||||
/// 返回指定人脸框的语义颜色,供绘制与定向测试共用同一判断。
|
||||
///
|
||||
/// [index] 必须是 [boxes] 中的有效下标。
|
||||
Color colorFor(int index) {
|
||||
final box = boxes[index];
|
||||
if (keepMaxFace) {
|
||||
return index == primaryFaceIndex
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.danger;
|
||||
}
|
||||
final passed = box.similarity != null &&
|
||||
box.similarity! >= similarityThreshold &&
|
||||
(!livenessEnabled || box.livenessStatus == 'alive');
|
||||
final failed =
|
||||
box.recognizeStatus == 'failed' || box.livenessStatus == 'notAlive';
|
||||
return failed
|
||||
? XfjDesignTokens.danger
|
||||
: passed
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.primary;
|
||||
}
|
||||
|
||||
/// 返回指定人脸框的可见标签,明确区分面积占比与人脸相似度。
|
||||
///
|
||||
/// [index] 必须是 [boxes] 中的有效下标。
|
||||
String labelFor(int index) {
|
||||
final box = boxes[index];
|
||||
final mode = livenessEnabled ? '人脸识别 + 活体' : '人脸识别';
|
||||
final similarity = box.similarity == null
|
||||
? _recognizeText(box.recognizeStatus)
|
||||
: '相似度 ${(box.similarity! * 100).toStringAsFixed(1)}%';
|
||||
final liveness =
|
||||
livenessEnabled ? ' · ${_livenessText(box.livenessStatus)}' : '';
|
||||
if (!keepMaxFace) {
|
||||
return '$mode $similarity$liveness';
|
||||
}
|
||||
final screenShare = (box.normalizedArea * 100).toStringAsFixed(1);
|
||||
if (index == primaryFaceIndex) {
|
||||
return '识别目标 · 占画面 $screenShare%\n$mode $similarity$liveness';
|
||||
}
|
||||
final recentSimilarity = box.similarity == null ? '' : ' · 最近$similarity';
|
||||
return '非识别目标 · 占画面 $screenShare%\n不参与识别$recentSimilarity';
|
||||
}
|
||||
|
||||
/// 绘制所有检测框及高对比文字标签。
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final strokeWidth = 4 * labelScale;
|
||||
for (var index = 0; index < boxes.length; index++) {
|
||||
final box = boxes[index];
|
||||
final rect = _transformRect(box, size, rotation, mirror);
|
||||
final color = colorFor(index);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = color;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, Radius.circular(18 * labelScale)),
|
||||
paint,
|
||||
);
|
||||
_paintLabel(canvas, size, rect, labelFor(index), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// 在人脸框上方或下方绘制最多两行标签,并限制标签不超出画布。
|
||||
void _paintLabel(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Rect rect,
|
||||
String label,
|
||||
Color color,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14 * labelScale,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 2,
|
||||
)..layout(maxWidth: math.max(80, size.width - 24 * labelScale));
|
||||
final horizontalPadding = 10 * labelScale;
|
||||
final verticalPadding = 6 * labelScale;
|
||||
final labelSize = Size(
|
||||
textPainter.width + horizontalPadding * 2,
|
||||
textPainter.height + verticalPadding * 2,
|
||||
);
|
||||
final left = rect.left
|
||||
.clamp(
|
||||
0.0,
|
||||
math.max(0.0, size.width - labelSize.width),
|
||||
)
|
||||
.toDouble();
|
||||
final preferredTop = rect.top - labelSize.height - 6 * labelScale;
|
||||
final top = preferredTop >= 0
|
||||
? preferredTop
|
||||
: math.min(
|
||||
size.height - labelSize.height, rect.bottom + 6 * labelScale);
|
||||
final labelRect =
|
||||
Rect.fromLTWH(left, top, labelSize.width, labelSize.height);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(labelRect, Radius.circular(8 * labelScale)),
|
||||
Paint()
|
||||
..color = Color.alphaBlend(Colors.black.withValues(alpha: 0.36), color),
|
||||
);
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(left + horizontalPadding, top + verticalPadding),
|
||||
);
|
||||
}
|
||||
|
||||
/// 当人脸数据、方向或识别策略变化时请求重绘。
|
||||
@override
|
||||
bool shouldRepaint(covariant FaceRecognitionBoxPainter oldDelegate) {
|
||||
return oldDelegate.boxes != boxes ||
|
||||
oldDelegate.rotation != rotation ||
|
||||
oldDelegate.mirror != mirror ||
|
||||
oldDelegate.livenessEnabled != livenessEnabled ||
|
||||
oldDelegate.keepMaxFace != keepMaxFace ||
|
||||
oldDelegate.similarityThreshold != similarityThreshold ||
|
||||
oldDelegate.labelScale != labelScale;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将原始归一化人脸框按 Texture 同样的旋转和镜像规则映射到画布。
|
||||
Rect _transformRect(
|
||||
FaceRecognitionFaceBox box,
|
||||
Size size,
|
||||
int rotation,
|
||||
bool mirror,
|
||||
) {
|
||||
final points = <Offset>[
|
||||
Offset(box.left, box.top),
|
||||
Offset(box.right, box.top),
|
||||
Offset(box.right, box.bottom),
|
||||
Offset(box.left, box.bottom),
|
||||
].map((point) => _transformPoint(point, rotation, mirror)).toList();
|
||||
final left = points.map((point) => point.dx).reduce(math.min) * size.width;
|
||||
final right = points.map((point) => point.dx).reduce(math.max) * size.width;
|
||||
final top = points.map((point) => point.dy).reduce(math.min) * size.height;
|
||||
final bottom = points.map((point) => point.dy).reduce(math.max) * size.height;
|
||||
return Rect.fromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
/// 转换单个归一化坐标点,旋转和镜像均保持面积不变。
|
||||
Offset _transformPoint(Offset point, int rotation, bool mirror) {
|
||||
final x = mirror ? 1 - point.dx : point.dx;
|
||||
final y = point.dy;
|
||||
return switch (_quarterTurns(rotation)) {
|
||||
1 => Offset(1 - y, x),
|
||||
2 => Offset(1 - x, 1 - y),
|
||||
3 => Offset(y, 1 - x),
|
||||
_ => Offset(x, y),
|
||||
};
|
||||
}
|
||||
|
||||
/// 将任意角度归一为 Flutter [RotatedBox] 使用的四分之一圈数量。
|
||||
int _quarterTurns(int rotation) => ((rotation % 360) / 90).round() % 4;
|
||||
|
||||
/// 将原生特征提取状态转换为摄像头框内的短文案。
|
||||
String _recognizeText(String status) => switch (status) {
|
||||
'comparing' => '比对中',
|
||||
'success' => '已通过',
|
||||
'failed' => '未通过',
|
||||
_ => '检测中',
|
||||
};
|
||||
|
||||
/// 将原生活体状态转换为摄像头框内的短文案。
|
||||
String _livenessText(String status) => switch (status) {
|
||||
'analyzing' => '活体检测中',
|
||||
'alive' => '活体通过',
|
||||
'notAlive' => '非活体',
|
||||
_ => '等待活体',
|
||||
};
|
||||
@@ -21,9 +21,16 @@ export './app/app_wallet_selector.dart';
|
||||
export './app/app_dialog.dart';
|
||||
export './app/terminal_toolbar.dart';
|
||||
export './app/terminal_shell.dart';
|
||||
export './app/product_type_card.dart';
|
||||
|
||||
export './card/card_common.dart';
|
||||
|
||||
export './face/face_recognition_preview_layer.dart';
|
||||
export './face/face_camera_preview.dart';
|
||||
export './face/face_live_camera_preview.dart';
|
||||
export './face/face_recognition_panel.dart';
|
||||
export './face/face_recognition_preview_dialog.dart';
|
||||
|
||||
export './order/mixin/order_mixin.dart';
|
||||
export './order/select_pay_type_dialog.dart';
|
||||
export './order/steelyard_container.dart';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -157,9 +157,8 @@ class CanteenOrderTotalPage extends GetView<CanteenOrderTotalController> {
|
||||
id: "canteen_order_total",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供。
|
||||
header: FHeader(title: SizedBox.shrink()),
|
||||
content: _buildView(context).marginOnly(top: 20.h),
|
||||
// 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。
|
||||
content: _buildView(context),
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -129,9 +129,40 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
Future<void> doPay() async {
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
final snapshot = _createCheckoutSnapshot();
|
||||
// 创建本地订单
|
||||
await createSqlOrder();
|
||||
selectPayTypeDialog();
|
||||
await selectPayTypeDialog(
|
||||
snapshot: snapshot,
|
||||
onQuantityChanged: _changeCheckoutQuantity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额的展示快照。
|
||||
///
|
||||
/// 弹框只负责触发操作,购物车、餐费和副屏仍由当前页面 Controller 统一维护。
|
||||
Future<CashierCheckoutSnapshot?> _changeCheckoutQuantity(
|
||||
String productServiceUniqueCode,
|
||||
int quantity,
|
||||
) async {
|
||||
final index = shopCardList.indexWhere(
|
||||
(item) => item.productServiceUniqueCode == productServiceUniqueCode,
|
||||
);
|
||||
if (index < 0) return _createCheckoutSnapshot();
|
||||
await doComputedInputChange(quantity, index);
|
||||
return _createCheckoutSnapshot();
|
||||
}
|
||||
|
||||
/// 在打开支付弹框前冻结菜品和整数分金额,避免背景状态影响本次结算展示。
|
||||
CashierCheckoutSnapshot _createCheckoutSnapshot() {
|
||||
return CashierCheckoutSnapshot.capture(
|
||||
cartTitle: '当前已点菜品',
|
||||
cartItems: shopCardList.toList(growable: false),
|
||||
originalAmount: totalPrice.value,
|
||||
discountAmount: 0,
|
||||
surchargeAmount: amountMealPrice.value,
|
||||
payableAmount: totalPrice.value + amountMealPrice.value,
|
||||
);
|
||||
}
|
||||
|
||||
// |下单|点击取消下单
|
||||
@@ -141,7 +172,7 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
}
|
||||
|
||||
// |+|或者|-|点击更改商品数量
|
||||
void doComputedInputChange(int number, int index) async {
|
||||
Future<void> doComputedInputChange(int number, int index) async {
|
||||
final current = shopCardList[index];
|
||||
current.productNumber = number;
|
||||
current.totalPrice = _computedProductTotalPrice(
|
||||
@@ -211,6 +242,7 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
orderPayType,
|
||||
orderPayDisplayType,
|
||||
qrCode,
|
||||
faceRecognition,
|
||||
) async {
|
||||
final log =
|
||||
"|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;";
|
||||
@@ -221,9 +253,9 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
this.globalQrCode = qrCode;
|
||||
payUser.value = user;
|
||||
final validPayUser = _validatePayUser();
|
||||
if (!validPayUser) return;
|
||||
if (!validPayUser) return false;
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
if (!validShopCar) return false;
|
||||
EasyLoading.show(
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
@@ -231,10 +263,14 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
// 最后计算一次额外的价格
|
||||
await _computedAmountMealPrice();
|
||||
await updateSqlOrder();
|
||||
await createApiOrder();
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
putOffSendSubScreen();
|
||||
_clearShopCard();
|
||||
EasyLoading.dismiss();
|
||||
// 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
return true;
|
||||
};
|
||||
// |支付方式|点击|取消|
|
||||
payTypeSelect.onCancel = () {};
|
||||
|
||||
@@ -227,8 +227,7 @@ class CanteenReservePage extends GetView<CanteenReserveController> {
|
||||
id: "canteen_reserve",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供。
|
||||
header: FHeader(title: SizedBox.shrink()),
|
||||
// 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。
|
||||
content: _buildView(context),
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ class OrderHistoryPage extends GetView<OrderHistoryController> {
|
||||
id: "order_history",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
header: FHeader(title: SizedBox.shrink()),
|
||||
// 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。
|
||||
content: _buildView(context),
|
||||
contentPad: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
|
||||
@@ -216,7 +216,6 @@ class OrderHistoryDetailPage extends GetView<OrderHistoryDetailController> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
final height = context.height;
|
||||
// 内层路由的参数挂在 RouteSettings 上,首次构建时交给 Controller 拉取详情。
|
||||
final routeArguments = ModalRoute.of(context)?.settings.arguments;
|
||||
if (routeArguments is Map<String, dynamic>) {
|
||||
@@ -240,7 +239,7 @@ class OrderHistoryDetailPage extends GetView<OrderHistoryDetailController> {
|
||||
size: theme.typography.xl2.fontSize,
|
||||
),
|
||||
),
|
||||
).height(height * 0.1),
|
||||
),
|
||||
content: _buildView(context),
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
|
||||
@@ -16,18 +16,43 @@ class SettingController extends GetxController {
|
||||
|
||||
Rx<UserSetting?> userSetting = Rx(null);
|
||||
|
||||
/// 是否使用人工输入代替扫码、读卡和人脸等真实硬件输入。
|
||||
final manualHardwareInput = false.obs;
|
||||
|
||||
/// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。
|
||||
final kioskStatus = AppRuntimeTool.kioskStatus.obs;
|
||||
|
||||
/// Root 手动刷新是否正在执行,用于禁用重复点击并展示局部进度。
|
||||
final rootRefreshing = false.obs;
|
||||
|
||||
/// Root 一键配置 Device Owner 是否正在执行,命令期间禁止重复提交。
|
||||
final kioskProvisioning = false.obs;
|
||||
|
||||
/// Kiosk 开关是否正在同步原生 Lock Task,防止连续点击形成状态反转。
|
||||
final kioskSwitching = false.obs;
|
||||
|
||||
/// 最近一次 Root 配置 Kiosk 的失败处理建议;成功或重新加载设置后清空。
|
||||
final kioskProvisionMessage = ''.obs;
|
||||
|
||||
// 全局文字大小
|
||||
var globalFontSizeC = FRadioSelectGroupController<String>(
|
||||
value: SettingGlobalFontSize.xl3.code,
|
||||
);
|
||||
|
||||
// 付款方式
|
||||
var globalPayTableC = FMultiSelectGroupController<String>(
|
||||
values: {},
|
||||
);
|
||||
/// 支付设置的界面排序,初始顺序来自枚举并由本地持久化覆盖。
|
||||
final payTypeOrder = <OrderPayDisplayType>[
|
||||
...OrderPayDisplayType.values,
|
||||
].obs;
|
||||
|
||||
// 默认付款方式
|
||||
var defaultGlobalPayTableC = FRadioSelectGroupController<String>();
|
||||
/// 允许在收银弹框内重新识别人员的支付方式集合;初始化采用业务默认值,配置加载后以持久化结果为准。
|
||||
final cashierReidentifiablePayTypes =
|
||||
AppSettingTool.defaultCashierReidentifiablePayTypes.toSet().obs;
|
||||
|
||||
/// 支付偏好串行保存中;保存期间的新变更会继续下一轮写入。
|
||||
bool _payPersisting = false;
|
||||
|
||||
/// 支付偏好存在待写入变更;连续拖动或切换开关时合并为一次落盘。
|
||||
bool _payPendingPersist = false;
|
||||
|
||||
/// 商品餐段时间配置;餐段校验弹框和自动切换按该时间范围判定。
|
||||
final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs;
|
||||
@@ -118,31 +143,12 @@ class SettingController extends GetxController {
|
||||
final appTheme = Get.find<AppThemeControl>();
|
||||
appTheme.setSizeScalar(fontSizeType);
|
||||
});
|
||||
globalPayTableC.addValueListener((v) async {
|
||||
List<OrderPayDisplayType> usePayList = [];
|
||||
for (final i in v) {
|
||||
final payType = OrderPayDisplayType.values.fromCode(
|
||||
i,
|
||||
);
|
||||
if (payType == null) continue;
|
||||
usePayList.add(payType);
|
||||
}
|
||||
usePayList.sort((pren, next) => pren.code.compareTo(next.code));
|
||||
sysSetting.value?.usePayDisplayType = usePayList;
|
||||
await AppSettingTool.setSysSetting(sysSetting.value!);
|
||||
});
|
||||
defaultGlobalPayTableC.addValueListener((v) async {
|
||||
final payType = OrderPayDisplayType.values.fromCode(
|
||||
v.first,
|
||||
);
|
||||
if (payType == null) return;
|
||||
sysSetting.value?.defaultPayDisplayType = payType;
|
||||
await AppSettingTool.setSysSetting(sysSetting.value!);
|
||||
});
|
||||
mqUpdateListen = appC.mqUpdateEventBus.on<MqtActionEnum>().listen(
|
||||
(event) async {
|
||||
if (event == MqtActionEnum.CONFIG) {
|
||||
sysSetting.value = await AppSettingTool.getSysSetting();
|
||||
sysSetting.value = _orderedSysSetting(
|
||||
await AppSettingTool.getSysSetting(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -158,8 +164,6 @@ class SettingController extends GetxController {
|
||||
void onClose() {
|
||||
super.onClose();
|
||||
globalFontSizeC.dispose();
|
||||
globalPayTableC.dispose();
|
||||
defaultGlobalPayTableC.dispose();
|
||||
mqUpdateListen.cancel();
|
||||
// 隐藏键盘
|
||||
// FocusManager.instance.primaryFocus?.unfocus();
|
||||
@@ -361,21 +365,177 @@ class SettingController extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> getSetting() async {
|
||||
sysSetting.value = await AppSettingTool.getSysSetting();
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final storedPayTypeOrder = await AppSettingTool.getPayTypeOrder();
|
||||
final reidentifiablePayTypes =
|
||||
await AppSettingTool.getCashierReidentifiablePayTypes();
|
||||
final byCode = {
|
||||
for (final item in OrderPayDisplayType.values) item.code: item,
|
||||
};
|
||||
final restoredPayTypeOrder = <OrderPayDisplayType>[];
|
||||
for (final code in storedPayTypeOrder) {
|
||||
final item = byCode[code];
|
||||
if (item != null && !restoredPayTypeOrder.contains(item)) {
|
||||
restoredPayTypeOrder.add(item);
|
||||
}
|
||||
}
|
||||
for (final item in OrderPayDisplayType.values) {
|
||||
if (!restoredPayTypeOrder.contains(item)) {
|
||||
restoredPayTypeOrder.add(item);
|
||||
}
|
||||
}
|
||||
payTypeOrder.assignAll(restoredPayTypeOrder);
|
||||
cashierReidentifiablePayTypes.assignAll(reidentifiablePayTypes);
|
||||
sysSetting.value = _orderedSysSetting(setting);
|
||||
productMealPeriodSchedule.value =
|
||||
await AppSettingTool.getProductMealPeriodSchedule();
|
||||
manualHardwareInput.value =
|
||||
AppRuntimeTool.isEmulator || sysSetting.value!.manualHardwareInput;
|
||||
AppRuntimeTool.applyManualHardwareInput(
|
||||
sysSetting.value!.manualHardwareInput,
|
||||
);
|
||||
if (sysSetting.value!.globalFontSize != null) {
|
||||
globalFontSizeC.value = {sysSetting.value!.globalFontSize!.code};
|
||||
}
|
||||
if (!ObjectUtil.isEmptyList(sysSetting.value!.usePayDisplayType)) {
|
||||
globalPayTableC.value =
|
||||
sysSetting.value!.usePayDisplayType!.map((v) => v.code).toSet();
|
||||
}
|
||||
|
||||
/// 将设置中的启用支付方式按本地排序重排;null 保持 null 由收银页按默认处理。
|
||||
SysSetting _orderedSysSetting(SysSetting setting) {
|
||||
if (setting.usePayDisplayType != null) {
|
||||
setting.usePayDisplayType = payTypeOrder
|
||||
.where(setting.usePayDisplayType!.contains)
|
||||
.toList();
|
||||
}
|
||||
if (sysSetting.value!.defaultPayDisplayType != null) {
|
||||
defaultGlobalPayTableC.value = {
|
||||
sysSetting.value!.defaultPayDisplayType!.code
|
||||
};
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// 启用或停用支付方式,始终至少保留一种并修正失效的默认方式。
|
||||
void togglePayType(OrderPayDisplayType type) {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null) return;
|
||||
final enabled = List<OrderPayDisplayType>.from(
|
||||
setting.usePayDisplayType ?? OrderPayDisplayType.values,
|
||||
);
|
||||
if (enabled.contains(type)) {
|
||||
if (enabled.length == 1) {
|
||||
Toast.error(msg: '至少保留一种支付方式');
|
||||
return;
|
||||
}
|
||||
enabled.remove(type);
|
||||
} else {
|
||||
enabled.add(type);
|
||||
}
|
||||
final orderedEnabled = payTypeOrder.where(enabled.contains).toList();
|
||||
|
||||
// 默认支付方式必须属于启用集合,否则收银页会出现默认值不可选择的矛盾状态。
|
||||
if (!orderedEnabled.contains(setting.defaultPayDisplayType)) {
|
||||
setting.defaultPayDisplayType = orderedEnabled.first;
|
||||
}
|
||||
setting.usePayDisplayType = orderedEnabled;
|
||||
sysSetting.refresh();
|
||||
_schedulePayPreferencesPersist();
|
||||
}
|
||||
|
||||
/// 切换指定支付方式能否在收银弹框中重新识别人员,并立即安排本机持久化。
|
||||
void togglePayTypeReidentification(OrderPayDisplayType type) {
|
||||
if (cashierReidentifiablePayTypes.contains(type)) {
|
||||
cashierReidentifiablePayTypes.remove(type);
|
||||
} else {
|
||||
cashierReidentifiablePayTypes.add(type);
|
||||
}
|
||||
_schedulePayPreferencesPersist();
|
||||
}
|
||||
|
||||
/// 调整支付方式展示顺序,同时保持启用集合与完整排序一致。
|
||||
void reorderPayType(int oldIndex, int newIndex) {
|
||||
if (oldIndex == newIndex ||
|
||||
oldIndex < 0 ||
|
||||
oldIndex >= payTypeOrder.length) {
|
||||
return;
|
||||
}
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
if (newIndex < 0 || newIndex >= payTypeOrder.length) return;
|
||||
final updated = payTypeOrder.toList();
|
||||
final item = updated.removeAt(oldIndex);
|
||||
updated.insert(newIndex, item);
|
||||
payTypeOrder.assignAll(updated);
|
||||
|
||||
final setting = sysSetting.value;
|
||||
if (setting != null) {
|
||||
final visible = setting.usePayDisplayType ?? OrderPayDisplayType.values;
|
||||
setting.usePayDisplayType = updated.where(visible.contains).toList();
|
||||
sysSetting.refresh();
|
||||
}
|
||||
_schedulePayPreferencesPersist();
|
||||
}
|
||||
|
||||
/// 恢复全部支付方式、默认枚举顺序和默认重新识别范围,并安排持久化。
|
||||
void resetPayTypes() {
|
||||
payTypeOrder.assignAll(OrderPayDisplayType.values);
|
||||
cashierReidentifiablePayTypes.assignAll(
|
||||
AppSettingTool.defaultCashierReidentifiablePayTypes,
|
||||
);
|
||||
final setting = sysSetting.value;
|
||||
if (setting != null) {
|
||||
setting.usePayDisplayType = OrderPayDisplayType.values.toList();
|
||||
if (!setting.usePayDisplayType!.contains(setting.defaultPayDisplayType)) {
|
||||
setting.defaultPayDisplayType = setting.usePayDisplayType!.first;
|
||||
}
|
||||
sysSetting.refresh();
|
||||
}
|
||||
_schedulePayPreferencesPersist();
|
||||
}
|
||||
|
||||
/// 设置默认支付方式;未启用的支付方式不能成为默认值。
|
||||
void setDefaultPayType(OrderPayDisplayType type) {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null) return;
|
||||
final enabled = setting.usePayDisplayType ?? OrderPayDisplayType.values;
|
||||
if (!enabled.contains(type)) {
|
||||
Toast.error(msg: '请先启用该支付方式');
|
||||
return;
|
||||
}
|
||||
setting.defaultPayDisplayType = type;
|
||||
sysSetting.refresh();
|
||||
_schedulePayPreferencesPersist();
|
||||
}
|
||||
|
||||
/// 支付设置表格“是否默认”开关入口;默认方式唯一,关闭当前默认时提示改选其他方式。
|
||||
void toggleDefaultPayType(OrderPayDisplayType type, bool value) {
|
||||
if (value) {
|
||||
setDefaultPayType(type);
|
||||
return;
|
||||
}
|
||||
if (sysSetting.value?.defaultPayDisplayType == type) {
|
||||
Toast.error(msg: '默认支付方式不能取消,请选择其他支付方式');
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付方式连续拖动或切换时串行保存,始终以最后一次界面状态为准。
|
||||
void _schedulePayPreferencesPersist() {
|
||||
_payPendingPersist = true;
|
||||
if (!_payPersisting) unawaited(_flushPayPreferences());
|
||||
}
|
||||
|
||||
/// 串行写入支付方式偏好,保存期间的新变更会继续下一轮写入。
|
||||
Future<void> _flushPayPreferences() async {
|
||||
_payPersisting = true;
|
||||
while (_payPendingPersist) {
|
||||
_payPendingPersist = false;
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null) continue;
|
||||
try {
|
||||
await AppSettingTool.setPayTypePreferences(
|
||||
sysInfo: setting,
|
||||
order: payTypeOrder,
|
||||
reidentifiableTypes: cashierReidentifiablePayTypes,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|保存支付管理配置失败error:$e|stack:$stack|');
|
||||
Toast.error(msg: '支付设置保存失败,请重试');
|
||||
}
|
||||
}
|
||||
_payPersisting = false;
|
||||
}
|
||||
|
||||
Future<bool> checkIsBind() async {
|
||||
@@ -410,4 +570,163 @@ class SettingController extends GetxController {
|
||||
productMealPeriodSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Android 原生能力重新读取当前 Kiosk 状态。
|
||||
Future<void> refreshKioskStatus() async {
|
||||
kioskStatus.value = await AppRuntimeTool.refreshKioskStatus();
|
||||
}
|
||||
|
||||
/// 立即执行真实 Root 命令并局部持久化结果;失败状态也作为有效检查结果保存。
|
||||
Future<void> refreshRootAccess({bool showFeedback = true}) async {
|
||||
if (rootRefreshing.value) return;
|
||||
rootRefreshing.value = true;
|
||||
try {
|
||||
final result = await AppRuntimeTool.checkRootAccess();
|
||||
await AppSettingTool.setRootAccessResult(result);
|
||||
final setting = sysSetting.value;
|
||||
if (setting != null) {
|
||||
setting.rootAccessStatus = result.status;
|
||||
setting.rootAccessCheckedAt = result.checkedAt;
|
||||
setting.rootAccessDiagnostic = result.diagnostic;
|
||||
sysSetting.refresh();
|
||||
}
|
||||
if (showFeedback) {
|
||||
if (result.available) {
|
||||
Toast.success(msg: 'Root 权限可用');
|
||||
} else {
|
||||
Toast.error(msg: result.diagnostic);
|
||||
}
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|刷新Root状态失败error:|$e|stack:$stack|');
|
||||
if (showFeedback) Toast.error(msg: 'Root 状态检查失败,请稍后重试');
|
||||
} finally {
|
||||
rootRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 经用户二次确认后通过 Root 配置 Device Owner;不删除账户、不移除其他 Owner。
|
||||
Future<void> enableKioskWithRoot() async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || kioskProvisioning.value) return;
|
||||
if (setting.rootAccessStatus != RootAccessStatus.available) {
|
||||
kioskProvisionMessage.value = 'Root 不可用,请先刷新 Root 状态并完成授权';
|
||||
return;
|
||||
}
|
||||
final confirmed = await appMessageDialog(
|
||||
Get.context!,
|
||||
'将使用 Root 把当前应用配置为 Android Device Owner,并立即进入 Kiosk。该操作不会删除账户或移除其他 Device Owner,系统拒绝时只会返回原因。是否继续?',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
kioskProvisioning.value = true;
|
||||
kioskProvisionMessage.value = '';
|
||||
EasyLoading.show(
|
||||
status: '正在启用专用设备模式...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
try {
|
||||
final result = await AppRuntimeTool.enableKioskWithRoot();
|
||||
kioskStatus.value = result.status;
|
||||
if (result.success) {
|
||||
kioskProvisionMessage.value = '';
|
||||
Toast.success(msg: 'Kiosk 已启用并自动进入');
|
||||
} else {
|
||||
kioskProvisionMessage.value = result.message;
|
||||
Toast.error(msg: result.message);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|Root启用Kiosk失败error:|$e|stack:$stack|');
|
||||
kioskProvisionMessage.value = '启用失败,请稍后重试并检查系统 Device Owner 状态';
|
||||
Toast.error(msg: kioskProvisionMessage.value);
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
kioskProvisioning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换主控手动硬件输入;虚拟机强制开启,开启前需操作员确认。
|
||||
Future<void> setManualHardwareInput(bool enabled) async {
|
||||
if (AppRuntimeTool.isEmulator && !enabled) {
|
||||
Toast.error(msg: '虚拟机必须使用手动硬件输入模式');
|
||||
manualHardwareInput.value = true;
|
||||
return;
|
||||
}
|
||||
if (enabled && !manualHardwareInput.value) {
|
||||
final confirmed = await appMessageDialog(
|
||||
Get.context!,
|
||||
'设置为主控后,扫码、饭卡读写和人脸识别将改为人工输入,是否继续?',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null) return;
|
||||
setting.manualHardwareInput = enabled;
|
||||
await AppSettingTool.setSysSetting(setting);
|
||||
AppRuntimeTool.applyManualHardwareInput(enabled);
|
||||
manualHardwareInput.value = enabled;
|
||||
sysSetting.refresh();
|
||||
Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式');
|
||||
}
|
||||
|
||||
/// 立即开启或关闭 Kiosk 自动进入,并同步原生 Lock Task 状态。
|
||||
Future<void> setKioskAutoEnterEnabled(bool enabled) async {
|
||||
if (!kioskStatus.value.available || kioskSwitching.value) return;
|
||||
kioskSwitching.value = true;
|
||||
try {
|
||||
final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled);
|
||||
kioskStatus.value = status;
|
||||
final applied =
|
||||
status.autoEnterEnabled == enabled && status.active == enabled;
|
||||
if (!applied) {
|
||||
Toast.error(
|
||||
msg: enabled ? 'Kiosk 未能立即开启,请检查设备策略' : 'Kiosk 未能立即关闭,请重试',
|
||||
);
|
||||
return;
|
||||
}
|
||||
Toast.success(msg: enabled ? 'Kiosk 已立即开启' : 'Kiosk 已立即关闭');
|
||||
} finally {
|
||||
kioskSwitching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存全局字体倍率并立即应用到运行中主题。
|
||||
Future<void> setGlobalFontSize(SettingGlobalFontSize fontSize) async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || setting.globalFontSize == fontSize) return;
|
||||
setting.globalFontSize = fontSize;
|
||||
await AppSettingTool.setSysSetting(setting);
|
||||
Get.find<AppThemeControl>().setSizeScalar(fontSize);
|
||||
sysSetting.refresh();
|
||||
}
|
||||
|
||||
/// 保存按钮圆角并立即应用到运行中主题。
|
||||
Future<void> setButtonRadius(SettingButtonRadius value) async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || setting.buttonRadius == value) return;
|
||||
setting.buttonRadius = value;
|
||||
await AppSettingTool.setSysSetting(setting);
|
||||
Get.find<AppThemeControl>().setButtonRadius(value);
|
||||
sysSetting.refresh();
|
||||
}
|
||||
|
||||
/// 保存卡片圆角并立即应用到运行中主题。
|
||||
Future<void> setCardRadius(SettingCardRadius value) async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || setting.cardRadius == value) return;
|
||||
setting.cardRadius = value;
|
||||
await AppSettingTool.setSysSetting(setting);
|
||||
Get.find<AppThemeControl>().setCardRadius(value);
|
||||
sysSetting.refresh();
|
||||
}
|
||||
|
||||
/// 保存按钮尺寸档位并立即应用到运行中主题。
|
||||
Future<void> setButtonSize(SettingButtonSize value) async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || setting.buttonSize == value) return;
|
||||
setting.buttonSize = value;
|
||||
await AppSettingTool.setSysSetting(setting);
|
||||
Get.find<AppThemeControl>().setButtonSize(value);
|
||||
sysSetting.refresh();
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,14 @@ import 'package:pj_supermarket_cloud/pages/setting/controller.dart';
|
||||
/// 设置模块内部导航入口。
|
||||
///
|
||||
/// 适配自 biz_xfj_cloud 的同名类:目标项目设置页为单页左菜单结构,
|
||||
/// 任务日志跳转改走全局 GetX 路由,菜单切换复用 SettingController。
|
||||
/// 任务日志作为终端二级路由压入内层工作区栈,菜单切换复用 SettingController。
|
||||
abstract final class SettingNavigation {
|
||||
/// 打开指定任务的执行日志页。
|
||||
/// 打开指定任务的执行日志页(压入终端内层工作区栈,返回时回到定时任务设置)。
|
||||
static Future<void> openTaskLogs(ScheduledTaskType type) async {
|
||||
await Get.toNamed(RouteNames.settingSyncLogs, arguments: type);
|
||||
}
|
||||
|
||||
/// 从日志页返回定时任务设置。
|
||||
static void backToSync() {
|
||||
Get.back();
|
||||
TerminalNavigationTool.pushWorkspace(
|
||||
RouteNames.settingSyncLogs,
|
||||
arguments: type,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换设置左侧菜单到指定功能。
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import '../routes/navigation.dart';
|
||||
import 'controller.dart';
|
||||
|
||||
/// 紧凑型定时任务结构化日志页面。
|
||||
|
||||
@@ -143,9 +143,9 @@ class SettingPage extends GetView<SettingController> {
|
||||
id: "setting",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单保留在内容区。
|
||||
header: FHeader(title: SizedBox.shrink()),
|
||||
content: _buildView().marginOnly(top: 20.h),
|
||||
// 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单
|
||||
// 保留在内容区并直接贴紧顶栏。
|
||||
content: _buildView(),
|
||||
contentPad: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
|
||||
@@ -1,190 +1,495 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mqtt_client/mqtt_client.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import '../index.dart';
|
||||
import 'setting_components.dart';
|
||||
|
||||
/// 绑定接口成功即结束主操作,消息服务连接失败由后台定时任务继续恢复,不能反向判定设备绑定失败。
|
||||
Future<void> _refreshMqAfterBinding() async {
|
||||
try {
|
||||
await AppMqTool.mqRefresh();
|
||||
} catch (e, stack) {
|
||||
logger.e('|绑定后连接消息服务失败error:|$e|stack:$stack|');
|
||||
}
|
||||
if (MqManager.isConnected) return;
|
||||
final reason = MqManager.connectionFailureReason ?? '消息服务暂不可用';
|
||||
logger.w('|绑定成功但消息服务未连接reason:|$reason|');
|
||||
Toast.error(msg: '绑定成功,消息服务暂未连接');
|
||||
}
|
||||
|
||||
/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。
|
||||
Future<void> _bindServiceDialog(BuildContext context, SysSetting sys) async {
|
||||
final theme = FTheme.of(context);
|
||||
TextEditingController uniqueCodeC = TextEditingController();
|
||||
if (ObjectUtil.isNotEmpty(sys.serviceId)) {
|
||||
uniqueCodeC.text = sys.serviceId ?? "";
|
||||
}
|
||||
if (AppSysTool.isDev()) {
|
||||
// 正式环境-超市
|
||||
// uniqueCodeC.text = "1894303392833716226";
|
||||
// 正式环境-测试
|
||||
// uniqueCodeC.text = "1896157295523049473";
|
||||
// 正式环境-新县府02-主要测试
|
||||
uniqueCodeC.text = "1887466308336488450";
|
||||
|
||||
/// 测试环境-测试设备-(食堂主要测试)
|
||||
// uniqueCodeC.text = "1877592554489143298";
|
||||
// 测试少年宫测试
|
||||
// uniqueCodeC.text = "1927932460185784321";
|
||||
// 测试环境-食堂-浦阳
|
||||
// uniqueCodeC.text = "1914567628657778690";
|
||||
// 正式环境-超市-浦阳
|
||||
// uniqueCodeC.text = "1894303392833716226";
|
||||
// 正式环境-超市-新县府-主要测试(2025-8-26)
|
||||
// uniqueCodeC.text = "1902270180536061954";
|
||||
}
|
||||
await showAdaptiveDialog(
|
||||
// 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。
|
||||
final text = sys.serviceId ?? "";
|
||||
final uniqueCodeController = TextEditingController(text: text);
|
||||
await showAppDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => FDialog(
|
||||
direction: Axis.horizontal,
|
||||
builder: (dialogContext) => AppDialog(
|
||||
title: Text('绑定唯一编号', style: theme.typography.xl2),
|
||||
body: FTextField(
|
||||
width: context.width * 0.55,
|
||||
maxWidth: context.width * 0.55,
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
semanticLabel: '绑定设备唯一编号弹框',
|
||||
content: FTextField(
|
||||
textInputAction: TextInputAction.none,
|
||||
controller: uniqueCodeC,
|
||||
hint: "请输入设备唯一编号",
|
||||
controller: uniqueCodeController,
|
||||
hint: '请输入设备唯一编号',
|
||||
maxLines: 1,
|
||||
).width(context.width * 0.5).marginSymmetric(vertical: 20.h),
|
||||
).width(double.infinity).marginSymmetric(vertical: 20.h),
|
||||
actions: [
|
||||
FButton(
|
||||
style: FButtonStyle.outline,
|
||||
label: Text('取消', style: theme.typography.base),
|
||||
onPress: () => Get.back(),
|
||||
onPress: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
FButton(
|
||||
label: Text(
|
||||
'绑定',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
style: theme.typography.base.copyWith(color: Colors.white),
|
||||
),
|
||||
onPress: () async {
|
||||
final code = uniqueCodeC.text;
|
||||
if (ObjectUtil.isEmpty(code)) {
|
||||
Toast.error(msg: "请输入设备唯一编号");
|
||||
final serverId = int.tryParse(uniqueCodeController.text.trim());
|
||||
if (serverId == null) {
|
||||
Toast.error(msg: '请输入正确的设备唯一编号');
|
||||
return;
|
||||
}
|
||||
int? serverId = int.tryParse(code);
|
||||
if (ObjectUtil.isEmpty(serverId)) {
|
||||
Toast.error(msg: "服务id格式错误");
|
||||
return;
|
||||
}
|
||||
final isNetwork = AppNetworkTool.isAutoNetworkMsg();
|
||||
if (!isNetwork) return;
|
||||
if (!AppNetworkTool.isAutoNetworkMsg()) return;
|
||||
EasyLoading.show(
|
||||
status: '绑定中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
try {
|
||||
final flag = await AppSettingTool.bindSys(serverId!);
|
||||
EasyLoading.dismiss();
|
||||
if (flag) {
|
||||
final settingC = Get.find<SettingController>();
|
||||
await settingC.getSetting();
|
||||
// 连接mq
|
||||
await AppMqTool.mqRefresh();
|
||||
Toast.success(msg: "绑定成功");
|
||||
final success = await AppSettingTool.bindSys(serverId);
|
||||
if (!success) {
|
||||
Toast.error(msg: '绑定失败,请核对唯一编号');
|
||||
return;
|
||||
}
|
||||
final controller = Get.find<SettingController>();
|
||||
await controller.getSetting();
|
||||
Toast.success(msg: '绑定成功');
|
||||
unawaited(TerminalHeartbeatTool.report());
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
unawaited(_refreshMqAfterBinding());
|
||||
} catch (e, stack) {
|
||||
logger.e("|绑定服务端设备信息error:|$e|stack:|$stack|");
|
||||
logger.e('|绑定服务端设备信息error:|$e|stack:$stack|');
|
||||
Toast.error(msg: '绑定失败,请稍后重试');
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
Get.back();
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
uniqueCodeController.dispose();
|
||||
}
|
||||
|
||||
Widget settingBaseInfo(BuildContext context) {
|
||||
final controller = Get.find<SettingController>();
|
||||
return Obx(() {
|
||||
final sys = controller.sysSetting.value;
|
||||
if (sys == null) return const SettingEmpty(message: '暂无终端信息');
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
SettingGroup(
|
||||
title: '基本信息',
|
||||
child: Column(
|
||||
children: [
|
||||
_copyRow(
|
||||
icon: FAssets.icons.store,
|
||||
label: '设备类型',
|
||||
value: sys.sysType.cnStr,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.hardDrive,
|
||||
label: '设备名称',
|
||||
value: sys.serviceName,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.idCard,
|
||||
label: 'Android ID',
|
||||
value: sys.androidId,
|
||||
masked: true,
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.idCard,
|
||||
label: '唯一编号',
|
||||
value: _maskIdentifier(sys.serviceId),
|
||||
actionLabel:
|
||||
ObjectUtil.isEmpty(sys.serviceId) ? '立即绑定' : '重新绑定',
|
||||
onTap: () => _bindServiceDialog(context, sys),
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.store,
|
||||
label: '商户编号',
|
||||
value: sys.serviceMerchantId,
|
||||
masked: true,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.building,
|
||||
label: '商户部门编号',
|
||||
value: sys.merchantDeptId,
|
||||
masked: true,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
_buildMqttGroup(controller, sys),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
SettingGroup(
|
||||
title: '运行环境',
|
||||
child: Column(
|
||||
children: [
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.monitor,
|
||||
label: '当前设备',
|
||||
subtitle: '检测到 ${AppRuntimeTool.displayCount} 个显示屏',
|
||||
value: AppRuntimeTool.isEmulator
|
||||
? 'Android 虚拟机'
|
||||
: 'Android 真实设备',
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.smartphone,
|
||||
label: 'Android 版本',
|
||||
value: AppRuntimeTool.androidVersionLabel,
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.package,
|
||||
label: 'APK 版本',
|
||||
value: AppRuntimeTool.apkVersionLabel,
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: sys.rootAccessStatus == RootAccessStatus.available
|
||||
? FAssets.icons.shieldCheck
|
||||
: FAssets.icons.shieldAlert,
|
||||
label: 'Root 权限',
|
||||
subtitle: _rootAccessDescription(sys),
|
||||
value: _rootAccessLabel(sys.rootAccessStatus),
|
||||
trailing: Tooltip(
|
||||
message: '重新检查 Root 权限',
|
||||
child: IconButton(
|
||||
tooltip: '重新检查 Root 权限',
|
||||
onPressed: controller.rootRefreshing.value
|
||||
? null
|
||||
: controller.refreshRootAccess,
|
||||
icon: controller.rootRefreshing.value
|
||||
? SizedBox.square(
|
||||
dimension: 20.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: FIcon(
|
||||
FAssets.icons.refreshCw,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: controller.kioskStatus.value.available
|
||||
? FAssets.icons.shieldCheck
|
||||
: FAssets.icons.circleAlert,
|
||||
label: '专用设备 / Kiosk',
|
||||
subtitle: controller.kioskProvisionMessage.isNotEmpty
|
||||
? controller.kioskProvisionMessage.value
|
||||
: controller.kioskStatus.value.description,
|
||||
value: controller.kioskStatus.value.statusLabel,
|
||||
trailing: controller.kioskStatus.value.available
|
||||
? FSwitch(
|
||||
semanticLabel: '立即开启或关闭 Kiosk',
|
||||
value: controller.kioskStatus.value.autoEnterEnabled,
|
||||
enabled: !controller.kioskSwitching.value,
|
||||
onChange: controller.setKioskAutoEnterEnabled,
|
||||
)
|
||||
: Tooltip(
|
||||
message:
|
||||
sys.rootAccessStatus == RootAccessStatus.available
|
||||
? '使用 Root 配置 Device Owner 并进入 Kiosk'
|
||||
: 'Root 不可用,无法自动配置 Device Owner',
|
||||
child: SettingActionButton(
|
||||
label: controller.kioskProvisioning.value
|
||||
? '正在启用'
|
||||
: '启用 Kiosk',
|
||||
icon: FAssets.icons.shieldCheck,
|
||||
primary: true,
|
||||
enabled: sys.rootAccessStatus ==
|
||||
RootAccessStatus.available &&
|
||||
!controller.kioskProvisioning.value,
|
||||
onTap: controller.enableKioskWithRoot,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.keyboard,
|
||||
label: '设置为主控',
|
||||
subtitle: AppRuntimeTool.isEmulator
|
||||
? '虚拟机强制使用人工输入,不连接扫码、饭卡和人脸硬件'
|
||||
: '开启后扫码、饭卡读写和人脸识别由操作员输入结果',
|
||||
showDivider: false,
|
||||
trailing: FSwitch(
|
||||
semanticLabel: '设置为主控手动硬件输入模式',
|
||||
value: controller.manualHardwareInput.value,
|
||||
enabled: !AppRuntimeTool.isEmulator,
|
||||
onChange: controller.setManualHardwareInput,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
_terminalDeviceStatusVisibilitySetting(),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
SettingGroup(
|
||||
title: '主题样式',
|
||||
child: Column(
|
||||
children: [
|
||||
SettingSegmentedSelector<SettingGlobalFontSize>(
|
||||
label: '字体大小',
|
||||
value: sys.globalFontSize ?? SettingGlobalFontSize.xl3,
|
||||
options: const {
|
||||
SettingGlobalFontSize.xl2: '紧凑',
|
||||
SettingGlobalFontSize.xl3: '标准',
|
||||
SettingGlobalFontSize.xl4: '大号',
|
||||
},
|
||||
onChanged: controller.setGlobalFontSize,
|
||||
),
|
||||
SettingSegmentedSelector<SettingButtonRadius>(
|
||||
label: '按钮圆角',
|
||||
value: sys.buttonRadius,
|
||||
options: const {
|
||||
SettingButtonRadius.compact: '紧凑',
|
||||
SettingButtonRadius.standard: '标准',
|
||||
SettingButtonRadius.large: '大号',
|
||||
},
|
||||
onChanged: controller.setButtonRadius,
|
||||
),
|
||||
SettingSegmentedSelector<SettingCardRadius>(
|
||||
label: '卡片圆角',
|
||||
value: sys.cardRadius,
|
||||
options: const {
|
||||
SettingCardRadius.compact: '紧凑',
|
||||
SettingCardRadius.standard: '标准',
|
||||
SettingCardRadius.large: '大号',
|
||||
},
|
||||
onChanged: controller.setCardRadius,
|
||||
),
|
||||
SettingSegmentedSelector<SettingButtonSize>(
|
||||
label: '按钮大小',
|
||||
value: sys.buttonSize,
|
||||
options: const {
|
||||
SettingButtonSize.compact: '紧凑',
|
||||
SettingButtonSize.standard: '标准',
|
||||
SettingButtonSize.large: '大号',
|
||||
},
|
||||
showDivider: false,
|
||||
onChanged: controller.setButtonSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 消息服务(MQTT)分组:完整展示本机生效的连接配置与实时连接状态。
|
||||
Widget _buildMqttGroup(SettingController controller, SysSetting sys) {
|
||||
return SettingGroup(
|
||||
title: '消息服务(MQTT)',
|
||||
child: Column(
|
||||
children: [
|
||||
_copyRow(
|
||||
icon: FAssets.icons.server,
|
||||
label: '服务器地址',
|
||||
value: sys.mqSocketAddress,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.plug,
|
||||
label: '端口',
|
||||
value: sys.mqSocketPort,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.userRound,
|
||||
label: '用户名',
|
||||
value: sys.mqUsername,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.lockKeyhole,
|
||||
label: '密码',
|
||||
value: sys.mqPassword,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.fingerprint,
|
||||
label: 'Client ID',
|
||||
value: sys.mqClientId,
|
||||
),
|
||||
_copyRow(
|
||||
icon: FAssets.icons.radioTower,
|
||||
label: '订阅主题',
|
||||
value: sys.mainTopic,
|
||||
),
|
||||
_mqttConnectionRow(controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget settingBaseInfo(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return GetBuilder<SettingController>(
|
||||
builder: (controller) {
|
||||
return Obx(() {
|
||||
final sys = controller.sysSetting.value;
|
||||
if (sys == null) return const SizedBox.shrink();
|
||||
return <Widget>[
|
||||
appDividerTitle(context, "基本信息"),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.files),
|
||||
title: appPrefixTitle(context, "设备类型"),
|
||||
details: appSuffixTitle(context, sys.sysType.cnStr),
|
||||
onPress: () => KeywordTool.copy(sys.sysType.cnStr),
|
||||
),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.files),
|
||||
title: appPrefixTitle(context, "设备名称"),
|
||||
details: appSuffixTitle(context, sys.serviceName),
|
||||
onPress: () => KeywordTool.copy(sys.serviceName),
|
||||
),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.files),
|
||||
title: appPrefixTitle(context, "android id"),
|
||||
details: appSuffixTitle(context, sys.androidId),
|
||||
onPress: () => KeywordTool.copy(sys.androidId),
|
||||
),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines),
|
||||
title: appPrefixTitle(context, "唯一编号"),
|
||||
details: appSuffixTitle(context, sys.serviceId),
|
||||
onPress: () => _bindServiceDialog(context, sys),
|
||||
),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.files),
|
||||
title: appPrefixTitle(context, "商户编号"),
|
||||
details: appSuffixTitle(context, sys.serviceMerchantId),
|
||||
onPress: () => KeywordTool.copy(sys.serviceMerchantId),
|
||||
),
|
||||
FTile(
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.files),
|
||||
title: appPrefixTitle(context, "商户部门编号"),
|
||||
details: appSuffixTitle(context, sys.merchantDeptId),
|
||||
onPress: () => KeywordTool.copy(sys.merchantDeptId),
|
||||
),
|
||||
appDividerTitle(context, "主题样式"),
|
||||
FSelectMenuTile(
|
||||
groupController: controller.globalFontSizeC,
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines),
|
||||
title: appPrefixTitle(context, "字体大小"),
|
||||
autoHide: true,
|
||||
validator: (value) => value == null ? '请选择' : null,
|
||||
menu: SettingGlobalFontSize.values.map((v) {
|
||||
return FSelectTile(
|
||||
title: Text(
|
||||
v.title,
|
||||
style: theme.typography.xl,
|
||||
),
|
||||
value: v.code,
|
||||
);
|
||||
}).toList(),
|
||||
details: ListenableBuilder(
|
||||
listenable: controller.globalFontSizeC,
|
||||
builder: (context, _) {
|
||||
final v = controller.globalFontSizeC.value.firstOrNull;
|
||||
if (v == null) return const SizedBox();
|
||||
final fontSizeType = SettingGlobalFontSize.values.fromCode(
|
||||
v,
|
||||
);
|
||||
if (fontSizeType == null) return const SizedBox();
|
||||
return Text(
|
||||
fontSizeType.title,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
].toColumnSpace(space: 20.h);
|
||||
});
|
||||
},
|
||||
/// 实时连接状态行;连接由原生回调更新,页面重建时读取当前快照。
|
||||
Widget _mqttConnectionRow(SettingController controller) {
|
||||
final connected = MqManager.isConnected;
|
||||
final failureReason = MqManager.connectionFailureReason;
|
||||
return SettingInfoRow(
|
||||
icon: connected ? FAssets.icons.wifi : FAssets.icons.circleAlert,
|
||||
label: '连接状态',
|
||||
subtitle: connected
|
||||
? '消息服务已连接,指令和心跳通道正常。'
|
||||
: (failureReason ?? '未连接;点击右侧按钮可按当前配置重新连接。'),
|
||||
value: _mqttStateLabel(MqManager.state),
|
||||
showDivider: false,
|
||||
trailing: SettingActionButton(
|
||||
label: '重新连接',
|
||||
icon: FAssets.icons.refreshCw,
|
||||
dense: true,
|
||||
onTap: () => _reconnectMqtt(controller),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 手动重连消息服务:复用绑定后的刷新链路,结束后刷新页面状态并反馈结果。
|
||||
Future<void> _reconnectMqtt(SettingController controller) async {
|
||||
if (!AppNetworkTool.isAutoNetworkMsg()) return;
|
||||
EasyLoading.show(status: '连接中...', maskType: EasyLoadingMaskType.black);
|
||||
try {
|
||||
await AppMqTool.mqRefresh();
|
||||
} catch (e, stack) {
|
||||
logger.e('|基本信息|手动重连消息服务失败error:|$e|stack:$stack|');
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
controller.sysSetting.refresh();
|
||||
if (MqManager.isConnected) {
|
||||
Toast.success(msg: '消息服务已连接');
|
||||
} else {
|
||||
Toast.error(
|
||||
msg: MqManager.connectionFailureReason ?? '消息服务连接失败,请检查配置或网络',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 mqtt_client 连接状态枚举转换为面向操作人员的中文标签。
|
||||
String _mqttStateLabel(MqttConnectionState state) => switch (state) {
|
||||
MqttConnectionState.connected => '已连接',
|
||||
MqttConnectionState.connecting => '连接中',
|
||||
MqttConnectionState.disconnecting => '断开中',
|
||||
MqttConnectionState.faulted => '异常',
|
||||
MqttConnectionState.disconnected => '未连接',
|
||||
};
|
||||
|
||||
/// 以数据驱动的五行开关提供顶部设备状态逐项配置,并与详情弹框共享应用级响应状态。
|
||||
Widget _terminalDeviceStatusVisibilitySetting() {
|
||||
final control = Get.find<TerminalDeviceStatusControl>();
|
||||
final devices = TerminalDeviceType.values;
|
||||
return SettingGroup(
|
||||
title: '顶部状态栏',
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < devices.length; index++)
|
||||
Obx(() {
|
||||
final device = devices[index];
|
||||
final visible = control.isHeaderDeviceVisible(device);
|
||||
return SettingInfoRow(
|
||||
icon: _terminalDeviceStatusIcon(device),
|
||||
label: device.label,
|
||||
subtitle: '控制${device.label}状态是否显示在所有业务页面顶部。',
|
||||
value: visible ? '已显示' : '已隐藏',
|
||||
showDivider: index < devices.length - 1,
|
||||
trailing: FSwitch(
|
||||
key: ValueKey(
|
||||
'setting-terminal-device-status-${device.code}-switch',
|
||||
),
|
||||
semanticLabel: '在页面顶部显示${device.label}状态',
|
||||
value: visible,
|
||||
onChange: (value) => unawaited(
|
||||
control.setHeaderDeviceVisible(device, value),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回设备状态配置行使用的统一 Forui 图标,映射顺序与终端顶部五类设备保持一致。
|
||||
SvgAsset _terminalDeviceStatusIcon(TerminalDeviceType type) => switch (type) {
|
||||
TerminalDeviceType.printer => FAssets.icons.printer,
|
||||
TerminalDeviceType.steelyard => FAssets.icons.scale,
|
||||
TerminalDeviceType.cardReader => FAssets.icons.idCard,
|
||||
TerminalDeviceType.subScreen => FAssets.icons.monitor,
|
||||
TerminalDeviceType.camera => FAssets.icons.camera,
|
||||
};
|
||||
|
||||
/// 将 Root 持久化状态转换为基本信息页短标签。
|
||||
String _rootAccessLabel(RootAccessStatus status) => switch (status) {
|
||||
RootAccessStatus.unknown => '未检查',
|
||||
RootAccessStatus.available => '可用',
|
||||
RootAccessStatus.unavailable => '不可用',
|
||||
};
|
||||
|
||||
/// 组合 Root 最近检查时间和诊断摘要,旧配置保持明确的未检查提示。
|
||||
String _rootAccessDescription(SysSetting sys) {
|
||||
final checkedAt = sys.rootAccessCheckedAt;
|
||||
final checkedLabel = checkedAt == null
|
||||
? '尚未执行真实 Root 命令检查'
|
||||
: '最近检查 ${DateUtil.formatDate(checkedAt, format: DateFormats.full)}';
|
||||
final diagnostic = sys.rootAccessDiagnostic?.trim();
|
||||
return diagnostic == null || diagnostic.isEmpty
|
||||
? checkedLabel
|
||||
: '$checkedLabel · $diagnostic';
|
||||
}
|
||||
|
||||
Widget _copyRow({
|
||||
required SvgAsset icon,
|
||||
required String label,
|
||||
String? value,
|
||||
bool masked = false,
|
||||
bool showDivider = true,
|
||||
}) {
|
||||
return SettingInfoRow(
|
||||
icon: icon,
|
||||
label: label,
|
||||
value: masked ? _maskIdentifier(value) : _safeValue(value),
|
||||
actionLabel: '复制',
|
||||
showDivider: showDivider,
|
||||
onTap: ObjectUtil.isEmpty(value) ? null : () => KeywordTool.copy(value),
|
||||
);
|
||||
}
|
||||
|
||||
String _safeValue(String? value) {
|
||||
return ObjectUtil.isEmpty(value) ? '--' : value!;
|
||||
}
|
||||
|
||||
String _maskIdentifier(String? value) {
|
||||
if (ObjectUtil.isEmpty(value)) return '--';
|
||||
final text = value!;
|
||||
if (text.length <= 8) return '${text.substring(0, 2)}••••';
|
||||
final prefixLength = text.length >= 16 ? 4 : 3;
|
||||
final suffixLength = text.length >= 16 ? 4 : 3;
|
||||
return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
@@ -7,84 +9,551 @@ import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import '../index.dart';
|
||||
|
||||
/// “是否显示”列宽;与列头和数据行保持一致,窄屏时退化到固定像素。
|
||||
double get _visibilityColumnWidth => math.max(180.w, 144.0);
|
||||
|
||||
/// “是否默认”列宽。
|
||||
double get _defaultColumnWidth => math.max(180.w, 144.0);
|
||||
|
||||
/// “是否允许重新识别”列宽。
|
||||
double get _reidentifiableColumnWidth => math.max(220.w, 176.0);
|
||||
|
||||
/// “是否支持离线支付”列宽。
|
||||
double get _offlineColumnWidth => math.max(150.w, 120.0);
|
||||
|
||||
/// 支付设置分区:以表格形式管理支付方式的顺序、显隐、默认和重新识别能力。
|
||||
Widget settingPay(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return GetBuilder<SettingController>(builder: (controller) {
|
||||
return Obx(() {
|
||||
final sys = controller.sysSetting.value;
|
||||
if (sys == null) return const SizedBox.shrink();
|
||||
return <Widget>[
|
||||
FSelectMenuTile(
|
||||
groupController: controller.globalPayTableC,
|
||||
validator: (value) => value == null ? '请选择' : null,
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines),
|
||||
title: appPrefixTitle(context, "使用支付方式"),
|
||||
menu: OrderPayDisplayType.values.map((v) {
|
||||
return FSelectTile(
|
||||
title: Text(
|
||||
v.title,
|
||||
style: theme.typography.xl,
|
||||
),
|
||||
value: v.code,
|
||||
);
|
||||
}).toList(),
|
||||
details: ListenableBuilder(
|
||||
listenable: controller.globalPayTableC,
|
||||
builder: (context, _) {
|
||||
final selectList = controller.globalPayTableC.value;
|
||||
String title = '';
|
||||
for (final i in selectList) {
|
||||
final payType = OrderPayDisplayType.values.fromCode(
|
||||
i,
|
||||
);
|
||||
if (payType == null) continue;
|
||||
title += '${payType.title} ';
|
||||
}
|
||||
return Text(
|
||||
title,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
final controller = Get.find<SettingController>();
|
||||
return Obx(() {
|
||||
final sys = controller.sysSetting.value;
|
||||
if (sys == null) return const SizedBox.shrink();
|
||||
final enabled = sys.usePayDisplayType ?? OrderPayDisplayType.values;
|
||||
final defaultType = sys.defaultPayDisplayType ?? enabled.firstOrNull;
|
||||
return <Widget>[
|
||||
_PayEditorHeader(
|
||||
onReset: () {
|
||||
controller.resetPayTypes();
|
||||
Toast.success(msg: '支付设置已还原');
|
||||
},
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
const _PayColumnHeader(),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: controller.payTypeOrder.length,
|
||||
onReorder: controller.reorderPayType,
|
||||
proxyDecorator: (child, _, animation) => AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Material(
|
||||
color: Colors.transparent,
|
||||
elevation: 6 * animation.value,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
FSelectMenuTile(
|
||||
groupController: controller.defaultGlobalPayTableC,
|
||||
validator: (value) => value == null ? '请选择' : null,
|
||||
autoHide: true,
|
||||
prefixIcon: appPrefixIcon(context, FAssets.icons.settings),
|
||||
suffixIcon: appSuffixIcon(context, FAssets.icons.audioLines),
|
||||
title: appPrefixTitle(context, "默认支付方式"),
|
||||
menu: OrderPayDisplayType.values.map((v) {
|
||||
return FSelectTile(
|
||||
title: Text(
|
||||
v.title,
|
||||
style: theme.typography.xl,
|
||||
),
|
||||
value: v.code,
|
||||
);
|
||||
}).toList(),
|
||||
details: ListenableBuilder(
|
||||
listenable: controller.defaultGlobalPayTableC,
|
||||
builder: (context, _) {
|
||||
final v = controller.defaultGlobalPayTableC.value.firstOrNull;
|
||||
if (v == null) return const SizedBox();
|
||||
final payType = OrderPayDisplayType.values.fromCode(
|
||||
v,
|
||||
);
|
||||
if (payType == null) return const SizedBox();
|
||||
return Text(
|
||||
payType.title,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
].toColumnSpace(space: 20.h);
|
||||
});
|
||||
itemBuilder: (context, index) {
|
||||
final type = controller.payTypeOrder[index];
|
||||
final visible = enabled.contains(type);
|
||||
final reidentifiable =
|
||||
controller.cashierReidentifiablePayTypes.contains(type);
|
||||
return Padding(
|
||||
key: ValueKey(type.code),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == controller.payTypeOrder.length - 1
|
||||
? 0
|
||||
: XfjDesignTokens.cardGap.h,
|
||||
),
|
||||
child: _PayRow(
|
||||
type: type,
|
||||
index: index,
|
||||
visible: visible,
|
||||
reidentifiable: reidentifiable,
|
||||
isDefault: type == defaultType,
|
||||
onVisibleChanged: (_) => controller.togglePayType(type),
|
||||
onDefaultChanged: (value) =>
|
||||
controller.toggleDefaultPayType(type, value),
|
||||
onReidentifiableChanged: (_) =>
|
||||
controller.togglePayTypeReidentification(type),
|
||||
onMoveUp: index == 0
|
||||
? null
|
||||
: () => controller.reorderPayType(index, index - 1),
|
||||
onMoveDown: index == controller.payTypeOrder.length - 1
|
||||
? null
|
||||
: () => controller.reorderPayType(index, index + 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable();
|
||||
});
|
||||
}
|
||||
|
||||
class _PayEditorHeader extends StatelessWidget {
|
||||
/// 创建包含使用说明与还原操作的支付配置标题区。
|
||||
const _PayEditorHeader({required this.onReset});
|
||||
|
||||
/// 恢复默认支付配置的回调。
|
||||
final VoidCallback onReset;
|
||||
|
||||
/// 构建不会挤压说明文字的标题与次级操作。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'支付方式',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'拖动手柄或上下按钮调整顺序;重新识别开启后,可按当前支付方式重新确认人员。',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w * XfjDesignTokens.buttonSizeScale,
|
||||
vertical: 8.h * XfjDesignTokens.buttonSizeScale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
FIcon(
|
||||
FAssets.icons.gripVertical,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 18.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'拖动手柄',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
FButton(
|
||||
style: FButtonStyle.outline,
|
||||
prefix: FIcon(
|
||||
FAssets.icons.rotateCcw,
|
||||
color: XfjDesignTokens.ink,
|
||||
size: 18.sp,
|
||||
),
|
||||
label: Text(
|
||||
'还原',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
onPress: onReset,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PayColumnHeader extends StatelessWidget {
|
||||
/// 创建与支付方式数据行严格对齐的列标题。
|
||||
const _PayColumnHeader();
|
||||
|
||||
/// 构建支付名称、显隐、默认、重新识别和离线能力五组列标题。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
final style = theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 48.w),
|
||||
SizedBox(width: 8.w),
|
||||
SizedBox(width: 32.w),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(width: 22.w),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(child: Text('支付名称', style: style)),
|
||||
SizedBox(width: 120.w),
|
||||
SizedBox(
|
||||
width: _visibilityColumnWidth,
|
||||
child: Text('是否显示', textAlign: TextAlign.center, style: style),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _defaultColumnWidth,
|
||||
child: Text('是否默认', textAlign: TextAlign.center, style: style),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _reidentifiableColumnWidth,
|
||||
child: Text(
|
||||
'是否允许重新识别',
|
||||
textAlign: TextAlign.center,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _offlineColumnWidth,
|
||||
child: Text(
|
||||
'是否支持离线支付',
|
||||
textAlign: TextAlign.center,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付行沿用左侧菜单的拖动、精确移动和显隐反馈,并展示默认、重新识别与离线能力。
|
||||
class _PayRow extends StatelessWidget {
|
||||
/// 创建单个支付方式配置行。
|
||||
const _PayRow({
|
||||
required this.type,
|
||||
required this.index,
|
||||
required this.visible,
|
||||
required this.reidentifiable,
|
||||
required this.isDefault,
|
||||
required this.onVisibleChanged,
|
||||
required this.onDefaultChanged,
|
||||
required this.onReidentifiableChanged,
|
||||
this.onMoveUp,
|
||||
this.onMoveDown,
|
||||
});
|
||||
|
||||
/// 当前行对应的支付方式及跨端稳定编码。
|
||||
final OrderPayDisplayType type;
|
||||
|
||||
/// 当前支付方式在完整排序中的零基索引。
|
||||
final int index;
|
||||
|
||||
/// 当前支付方式是否在收银页展示。
|
||||
final bool visible;
|
||||
|
||||
/// 当前支付方式是否允许重新识别人员。
|
||||
final bool reidentifiable;
|
||||
|
||||
/// 当前支付方式是否为默认方式。
|
||||
final bool isDefault;
|
||||
|
||||
/// 显隐开关变化回调。
|
||||
final ValueChanged<bool> onVisibleChanged;
|
||||
|
||||
/// 默认开关变化回调;开启即设为默认,关闭当前默认会被拦截。
|
||||
final ValueChanged<bool> onDefaultChanged;
|
||||
|
||||
/// 重新识别开关变化回调。
|
||||
final ValueChanged<bool> onReidentifiableChanged;
|
||||
|
||||
/// 精确上移回调;为空表示已经位于首行。
|
||||
final VoidCallback? onMoveUp;
|
||||
|
||||
/// 精确下移回调;为空表示已经位于末行。
|
||||
final VoidCallback? onMoveDown;
|
||||
|
||||
/// 构建固定高度的数据行,所有开关同时使用文字表达状态。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: visible ? XfjDesignTokens.surface : XfjDesignTokens.control,
|
||||
border: Border.all(color: XfjDesignTokens.line),
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: SizedBox(
|
||||
width: 48.w,
|
||||
height: 48.h,
|
||||
child: FIcon(
|
||||
FAssets.icons.gripVertical,
|
||||
color: XfjDesignTokens.muted,
|
||||
size: 22.sp,
|
||||
).center(),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Container(
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: visible
|
||||
? XfjDesignTokens.primarySoft
|
||||
: XfjDesignTokens.surface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color:
|
||||
visible ? XfjDesignTokens.primary : XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
FIcon(
|
||||
_payIcon(type),
|
||||
color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
|
||||
size: 22.sp,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
type.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.base.copyWith(
|
||||
color:
|
||||
visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isDefault) ...[
|
||||
SizedBox(width: 10.w),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 5.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'默认',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_PayMoveButton(
|
||||
semanticLabel: '${type.title}上移',
|
||||
icon: FAssets.icons.chevronUp,
|
||||
onTap: onMoveUp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
_PayMoveButton(
|
||||
semanticLabel: '${type.title}下移',
|
||||
icon: FAssets.icons.chevronDown,
|
||||
onTap: onMoveDown,
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
SizedBox(
|
||||
width: _visibilityColumnWidth,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
visible ? '显示' : '隐藏',
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: visible
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
FSwitch(
|
||||
semanticLabel: '${type.title}${visible ? '已显示' : '已隐藏'}',
|
||||
value: visible,
|
||||
onChange: onVisibleChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _defaultColumnWidth,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isDefault ? '默认' : '非默认',
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: isDefault
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
FSwitch(
|
||||
semanticLabel: '${type.title}${isDefault ? '默认' : '非默认'}',
|
||||
value: isDefault,
|
||||
onChange: onDefaultChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _reidentifiableColumnWidth,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
reidentifiable ? '允许' : '不允许',
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: reidentifiable
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
FSwitch(
|
||||
semanticLabel:
|
||||
'${type.title}${reidentifiable ? '允许重新识别' : '不允许重新识别'}',
|
||||
value: reidentifiable,
|
||||
onChange: onReidentifiableChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: _offlineColumnWidth,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
color: type.supportOffLine
|
||||
? XfjDesignTokens.success.withValues(alpha: 0.1)
|
||||
: XfjDesignTokens.control,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
type.supportOffLine ? '支持' : '不支持',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: type.supportOffLine
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.muted,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PayMoveButton extends StatelessWidget {
|
||||
const _PayMoveButton({
|
||||
required this.semanticLabel,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String semanticLabel;
|
||||
final SvgAsset icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onTap != null;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: semanticLabel,
|
||||
child: Material(
|
||||
color: enabled ? XfjDesignTokens.control : XfjDesignTokens.canvas,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: SizedBox(
|
||||
width: 48.w,
|
||||
height: 48.h,
|
||||
child: FIcon(
|
||||
icon,
|
||||
color: enabled ? XfjDesignTokens.ink : XfjDesignTokens.line,
|
||||
size: 18.sp,
|
||||
).center(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SvgAsset _payIcon(OrderPayDisplayType type) {
|
||||
return switch (type) {
|
||||
OrderPayDisplayType.walletCard => FAssets.icons.idCard,
|
||||
OrderPayDisplayType.qrCode => FAssets.icons.qrCode,
|
||||
OrderPayDisplayType.qrCodeScan => FAssets.icons.scanLine,
|
||||
OrderPayDisplayType.face => FAssets.icons.scanFace,
|
||||
OrderPayDisplayType.cash => FAssets.icons.banknote,
|
||||
_ => FAssets.icons.creditCard,
|
||||
};
|
||||
}
|
||||
@@ -88,9 +88,22 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
Future<void> doPay() async {
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
final snapshot = _createCheckoutSnapshot();
|
||||
// 创建本地订单
|
||||
await createSqlOrder();
|
||||
selectPayTypeDialog();
|
||||
await selectPayTypeDialog(snapshot: snapshot);
|
||||
}
|
||||
|
||||
/// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。
|
||||
CashierCheckoutSnapshot _createCheckoutSnapshot() {
|
||||
return CashierCheckoutSnapshot.capture(
|
||||
cartTitle: '当前购物车商品',
|
||||
cartItems: shopCardList.toList(growable: false),
|
||||
originalAmount: totalPrice.value,
|
||||
discountAmount: 0,
|
||||
surchargeAmount: amountMealPrice.value,
|
||||
payableAmount: totalPrice.value + amountMealPrice.value,
|
||||
);
|
||||
}
|
||||
|
||||
// |下单|点击取消下单
|
||||
@@ -311,6 +324,7 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
orderPayType,
|
||||
orderPayDisplayType,
|
||||
qrCode,
|
||||
faceRecognition,
|
||||
) async {
|
||||
final log =
|
||||
"|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;";
|
||||
@@ -321,18 +335,22 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
this.globalQrCode = qrCode;
|
||||
payUser.value = user;
|
||||
final validPayUser = _validatePayUser();
|
||||
if (!validPayUser) return;
|
||||
if (!validPayUser) return false;
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
if (!validShopCar) return false;
|
||||
EasyLoading.show(
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
await updateSqlOrder();
|
||||
await createApiOrder();
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
EasyLoading.dismiss();
|
||||
// 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
_clearShopCard();
|
||||
putOffSendSubScreen();
|
||||
return true;
|
||||
};
|
||||
// |支付方式|点击|取消|
|
||||
payTypeSelect.onCancel = () {};
|
||||
@@ -346,8 +364,11 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
if (!validShopCar) return;
|
||||
final orderRes =
|
||||
await createApiOrder<ServiceHardCreateHardQrCodeScanOrder>();
|
||||
if (orderRes == null) {
|
||||
throw StateError("创建二维码支付订单失败");
|
||||
}
|
||||
TtsManage.speak("请扫描屏幕二维码");
|
||||
await onOrderCreate.call(orderRes!);
|
||||
await onOrderCreate.call(orderRes);
|
||||
};
|
||||
|
||||
// |"二维码扫一扫支付" "支付成功"|
|
||||
|
||||
@@ -143,9 +143,8 @@ class TakeFoodPage extends GetView<TakeFoodController> {
|
||||
id: "take_food",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供。
|
||||
header: FHeader(title: SizedBox.shrink()),
|
||||
content: _buildView(context).marginOnly(top: 20.h),
|
||||
// 标题与返回入口由终端外壳顶栏统一提供,内容区直接贴紧顶栏。
|
||||
content: _buildView(context),
|
||||
resizeToAvoidBottomInset: false,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/sync_logs/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/sync_logs/view.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/view.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/supermarket_order/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/supermarket_order/view.dart';
|
||||
@@ -329,4 +331,13 @@ final Map<String, _TerminalRouteConfig> _terminalRoutes = {
|
||||
Get.lazyPut(() => FaceSettingController(), fenix: true);
|
||||
}),
|
||||
),
|
||||
RouteNames.settingSyncLogs: _TerminalRouteConfig(
|
||||
name: RouteNames.settingSyncLogs,
|
||||
title: '定时任务日志',
|
||||
page: () => const ScheduledTaskLogsPage(),
|
||||
binding: BindingsBuilder(() {
|
||||
// 任务类型由页面首帧从 RouteSettings 注入后再触发首查。
|
||||
Get.lazyPut(() => ScheduledTaskLogsController(), fenix: true);
|
||||
}),
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user