Files
biz_xfj_cloud/lib/common/widgets/face/face_recognition_panel.dart
T
dzc 67950343a7 feat:使用ui区分单品、套餐
feat:增加"交易记录"、"核销记录"的全部数据table
2026-09-07 12:08:41 +08:00

487 lines
17 KiB
Dart

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),
),
);
}
}