487 lines
17 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|