This commit is contained in:
dzc committed 2026-09-09 19:32:19 +08:00
1 parent fe6d988fa3
commit a461b0fda3
29 files changed
+6012 -760

No files matched your search

+1
View File
@@ -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;
}
+2 -2
View File
@@ -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';
-11
View File
@@ -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,
);
}
}
+4
View File
@@ -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),
);
}
}
+4 -2
View File
@@ -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' => '非活体',
_ => '等待活体',
};
+7
View File
@@ -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
+2 -3
View File
@@ -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,
);
},
+41 -5
View File
@@ -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 = () {};
+1 -2
View File
@@ -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,
);
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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,
);
+357 -38
View File
@@ -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 -8
View File
@@ -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,
);
}
/// 切换设置左侧菜单到指定功能。
-1
View File
@@ -6,7 +6,6 @@ import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import '../routes/navigation.dart';
import 'controller.dart';
/// 紧凑型定时任务结构化日志页面。
+3 -3
View File
@@ -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,
);
+450 -145
View File
@@ -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)}';
}
+545 -76
View File
@@ -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,
};
}
+26 -5
View File
@@ -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);
};
// |"二维码扫一扫支付" "支付成功"|
+2 -3
View File
@@ -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,
);
},
+11
View File
@@ -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);
}),
),
};