feat:添加识别策略

This commit is contained in:
dzc committed 2026-07-24 20:41:17 +08:00
1 parent 0d0924c314
commit 1c348fc18e
20 files changed
+1500 -74

No files matched your search

@@ -25,6 +25,8 @@ import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager;
import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
import io.flutter.plugins.facediscern.ui.FaceDiscern;
import io.flutter.view.TextureRegistry;
@@ -199,6 +201,29 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
case "getRecognitionState":
result.success(sessionManager.getState());
break;
case "getRecognizeConfiguration":
result.success(RecognizeConfigurationStore.toMap(RecognizeConfigurationStore.load(context)));
break;
case "setRecognizeConfiguration":
try {
RecognizeConfiguration configuration = RecognizeConfigurationStore.save(context, call.arguments instanceof Map ? (Map<?, ?>) call.arguments : null);
sessionManager.onRecognitionConfigurationChanged();
result.success(RecognizeConfigurationStore.toMap(configuration));
} catch (IllegalArgumentException e) {
result.error("INVALID_RECOGNIZE_CONFIGURATION", e.getMessage(), null);
} catch (Exception e) {
result.error("SAVE_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
}
break;
case "resetRecognizeConfiguration":
try {
RecognizeConfiguration configuration = RecognizeConfigurationStore.reset(context);
sessionManager.onRecognitionConfigurationChanged();
result.success(RecognizeConfigurationStore.toMap(configuration));
} catch (Exception e) {
result.error("RESET_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
}
break;
case "active":
String appId = stringArgument(call, "appId");
String activeKey = stringArgument(call, "activeKey");
@@ -10,8 +10,6 @@ import android.hardware.Camera;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;
import androidx.core.content.ContextCompat;
@@ -103,6 +101,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
private long revision;
private long sessionGeneration;
private long lastFaceBoxEmitAt;
private boolean recognitionConfigurationDirty;
private Handler warmReleaseHandler = new Handler(Looper.getMainLooper());
private final Runnable warmReleaseTask = this::releaseWarmResources;
@@ -152,6 +151,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
public Map<String, Object> startRecognition(String sessionId, String scene, Listener owner) throws Exception {
if (sessionId == null || sessionId.trim().isEmpty()) throw new IllegalArgumentException("缺少人脸会话编号");
boolean reloadConfiguration;
synchronized (sessionLock) {
if (activeSessionId != null && !Objects.equals(activeSessionId, sessionId)) {
throw new SessionBusyException(activeSessionId);
@@ -163,9 +163,12 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
activeOwner = owner;
revision = 0;
state = State.INITIALIZING;
reloadConfiguration = recognitionConfigurationDirty;
emitState("正在启动摄像头", null, null);
}
try {
// 保存配置时不打断上一位顾客;下一次 Session 启动前再重建引擎并读取新值。
if (reloadConfiguration) releaseResources(false, false);
ensureWarmResources();
resetProcessorForSession(sessionId);
} catch (Exception error) {
@@ -175,7 +178,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
emitState(error.getMessage() == null ? "人脸识别初始化失败" : error.getMessage(), null, "INITIALIZATION_FAILED");
}
}
releaseResources(true, true);
// 初始化失败仍保留业务 Session,用户点击重试时可以在同一 Session 内重新冷启动。
releaseResources(false, false);
throw error;
}
synchronized (sessionLock) {
@@ -225,6 +229,11 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
processorToStop = detachActiveSession();
}
stopProcessor(processorToStop);
if (isRecognitionConfigurationDirty()) {
releaseResources(true, true);
} else {
emitResourceState("摄像头热保持中");
}
synchronized (sessionLock) {
return snapshot();
}
@@ -238,6 +247,11 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
processorToStop = detachActiveSession();
}
stopProcessor(processorToStop);
if (isRecognitionConfigurationDirty()) {
releaseResources(true, true);
} else {
emitResourceState("摄像头热保持中");
}
synchronized (sessionLock) {
return snapshot();
}
@@ -262,6 +276,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
}
}
/**
* 空闲时立即释放热资源,让下次识别读取新配置;活动识别则延迟到当前顾客结束,避免中断支付。
*/
public void onRecognitionConfigurationChanged() {
boolean releaseNow;
synchronized (sessionLock) {
recognitionConfigurationDirty = true;
releaseNow = activeSessionId == null;
}
// 设置页没有预览纹理占用,空闲时立即释放可避免下一次创建纹理后再重建 Renderer。
if (releaseNow) releaseResources(true, true);
}
public void releaseForLowMemory() {
synchronized (sessionLock) {
if (activeSessionId != null) {
@@ -314,7 +341,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
mirror = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT;
displayOrientation = calculateDisplayOrientation(info, currentDisplayRotation());
// OES SurfaceTexture 默认不会应用 Camera.setDisplayOrientation,Flutter 端只需补偿传感器方向。
displayOrientation = info.orientation;
camera = Camera.open(cameraId);
Camera.Parameters parameters = camera.getParameters();
previewSize = choosePreviewSize(parameters.getSupportedPreviewSizes());
@@ -329,7 +357,9 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
}
}
camera.setParameters(parameters);
camera.setDisplayOrientation(displayOrientation);
// OES 输出保持传感器原始方向,主、副两个 Flutter Texture 统一按 rotation 字段旋转;
// 若此处同时 setDisplayOrientation,会在部分设备上和 Flutter RotatedBox 叠加成双重旋转。
camera.setDisplayOrientation(0);
Camera openedCamera = camera;
camera.setErrorCallback((error, callbackCamera) -> mainHandler.post(() -> {
synchronized (sessionLock) {
@@ -547,6 +577,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
previewRenderer = null;
previewSize = null;
warmResourcesReady = false;
recognitionConfigurationDirty = false;
if (clearActiveSession) {
activeSessionId = null;
activeScene = null;
@@ -573,6 +604,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
}
}
if (rendererToRelease != null) rendererToRelease.release();
// Camera 真正释放后再广播资源快照,避免顶栏在 20 分钟超时后仍显示为开启。
emitResourceState(state == State.COLD ? "摄像头已关闭" : "摄像头资源已释放");
}
private void ensureOwner(String sessionId) {
@@ -581,6 +614,12 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
}
}
private boolean isRecognitionConfigurationDirty() {
synchronized (sessionLock) {
return recognitionConfigurationDirty;
}
}
private void emitState(String message, List<Map<String, Double>> boxes, String errorCode) {
Map<String, Object> event = snapshot();
event.put("message", message);
@@ -590,6 +629,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
for (Listener listener : listeners) listener.onSessionEvent(event);
}
/**
* 资源状态不增加业务 revision,活动 Session 会忽略它,常驻设备状态仍能获知 Camera 开关变化。
*/
private void emitResourceState(String message) {
Map<String, Object> event;
synchronized (sessionLock) {
event = snapshot();
event.put("message", message);
event.put("faceBoxes", new ArrayList<>());
}
for (Listener listener : listeners) listener.onSessionEvent(event);
}
private Map<String, Object> snapshot() {
Map<String, Object> result = new HashMap<>();
result.put("sessionId", activeSessionId == null ? "" : activeSessionId);
@@ -635,22 +687,6 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
return best;
}
@SuppressWarnings("deprecation")
private int calculateDisplayOrientation(Camera.CameraInfo info, int rotation) {
int degrees = rotation * 90;
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
int result = (info.orientation + degrees) % 360;
return (360 - result) % 360;
}
return (info.orientation - degrees + 360) % 360;
}
@SuppressWarnings("deprecation")
private int currentDisplayRotation() {
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
return windowManager == null ? Surface.ROTATION_0 : windowManager.getDefaultDisplay().getRotation();
}
public static final class SessionBusyException extends Exception {
private final String activeSessionId;
@@ -6,6 +6,19 @@ import com.arcsoft.face.LivenessParam;
* 识别相关的配置项
*/
public class RecognizeConfiguration {
public static final int DEFAULT_EXTRACT_RETRY_COUNT = 3;
public static final int DEFAULT_LIVENESS_RETRY_COUNT = 3;
public static final int DEFAULT_MAX_DETECT_FACES = 1;
public static final int DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL = 0;
public static final int DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL = 0;
public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F;
public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F;
public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F;
public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F;
public static final boolean DEFAULT_ENABLE_LIVENESS = false;
public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true;
public static final boolean DEFAULT_KEEP_MAX_FACE = true;
/**
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
*/
@@ -100,23 +113,23 @@ public class RecognizeConfiguration {
//TODO: demo不实现所有配置,若以下项也需要进行自定义配置,可参考其他配置项实现
public static class Builder {
private int extractRetryCount = 3;
private int livenessRetryCount = 3;
private int maxDetectFaces = 3;
private int recognizeFailedRetryInterval = 0;
private int livenessFailedRetryInterval = 0;
private float similarThreshold = 0.8f;
private float imageQualityNoMaskRecognizeThreshold = 0.49f;
private float imageQualityMaskRecognizeThreshold = 0.29f;
private boolean enableLiveness = false;
private int extractRetryCount = DEFAULT_EXTRACT_RETRY_COUNT;
private int livenessRetryCount = DEFAULT_LIVENESS_RETRY_COUNT;
private int maxDetectFaces = DEFAULT_MAX_DETECT_FACES;
private int recognizeFailedRetryInterval = DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL;
private int livenessFailedRetryInterval = DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL;
private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD;
private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD;
private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD;
private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS;
private boolean enableFaceAreaLimit = false;
private boolean enableImageQuality = false;
private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY;
private boolean enableFaceSizeLimit = false;
private boolean enableFaceMoveLimit = false;
private int faceSizeLimit = 0;
private int faceMoveLimit = 0;
private boolean keepMaxFace = false;
private LivenessParam livenessParam;
private boolean keepMaxFace = DEFAULT_KEEP_MAX_FACE;
private LivenessParam livenessParam = new LivenessParam(DEFAULT_RGB_LIVENESS_THRESHOLD, DEFAULT_RGB_LIVENESS_THRESHOLD);
public Builder recognizeFailedRetryInterval(int val) {
this.recognizeFailedRetryInterval = val;
@@ -0,0 +1,138 @@
package io.flutter.plugins.facediscern.tool.face.model;
import android.content.Context;
import android.content.SharedPreferences;
import com.arcsoft.face.LivenessParam;
import java.util.HashMap;
import java.util.Map;
/**
* 保存终端本机的人脸识别策略,不写入业务数据库,避免配置升级影响 Floor 与人脸共享库。
*/
public final class RecognizeConfigurationStore {
private static final String PREFERENCES_NAME = "face_recognize_configuration";
private RecognizeConfigurationStore() {
}
public static RecognizeConfiguration load(Context context) {
SharedPreferences preferences = preferences(context);
return new RecognizeConfiguration.Builder()
.enableLiveness(preferences.getBoolean("enableLiveness", RecognizeConfiguration.DEFAULT_ENABLE_LIVENESS))
.similarThreshold(preferences.getFloat("similarThreshold", RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD))
.livenessParam(new LivenessParam(
preferences.getFloat("rgbLivenessThreshold", RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD),
RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD))
.enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY))
.imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD))
.imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD))
.keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE))
.maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES))
.extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT))
.livenessRetryCount(preferences.getInt("livenessRetryCount", RecognizeConfiguration.DEFAULT_LIVENESS_RETRY_COUNT))
.recognizeFailedRetryInterval(preferences.getInt("recognizeFailedRetryInterval", RecognizeConfiguration.DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL))
.livenessFailedRetryInterval(preferences.getInt("livenessFailedRetryInterval", RecognizeConfiguration.DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL))
.build();
}
/**
* Flutter 只传允许现场调整的白名单字段,原生再次校验范围,防止异常参数拖垮识别线程。
*/
public static RecognizeConfiguration save(Context context, Map<?, ?> values) {
RecognizeConfiguration current = load(context);
boolean enableLiveness = booleanValue(values, "enableLiveness", current.isEnableLiveness());
float similarThreshold = floatValue(values, "similarThreshold", current.getSimilarThreshold(), 0.5F, 0.99F);
float rgbLivenessThreshold = floatValue(values, "rgbLivenessThreshold", rgbLivenessThreshold(current), 0.3F, 0.9F);
boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality());
float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F);
float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F);
boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace());
int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3);
int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10);
int livenessRetryCount = intValue(values, "livenessRetryCount", current.getLivenessRetryCount(), 0, 10);
int recognizeRetryInterval = intValue(values, "recognizeFailedRetryInterval", current.getRecognizeFailedRetryInterval(), 0, 5000);
int livenessRetryInterval = intValue(values, "livenessFailedRetryInterval", current.getLivenessFailedRetryInterval(), 0, 5000);
boolean saved = preferences(context).edit()
.putBoolean("enableLiveness", enableLiveness)
.putFloat("similarThreshold", similarThreshold)
.putFloat("rgbLivenessThreshold", rgbLivenessThreshold)
.putBoolean("enableImageQuality", enableImageQuality)
.putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold)
.putFloat("imageQualityMaskRecognizeThreshold", maskThreshold)
.putBoolean("keepMaxFace", keepMaxFace)
.putInt("maxDetectFaces", maxDetectFaces)
.putInt("extractRetryCount", extractRetryCount)
.putInt("livenessRetryCount", livenessRetryCount)
.putInt("recognizeFailedRetryInterval", recognizeRetryInterval)
.putInt("livenessFailedRetryInterval", livenessRetryInterval)
.commit();
if (!saved) throw new IllegalStateException("人脸识别配置保存失败");
return load(context);
}
public static RecognizeConfiguration reset(Context context) {
if (!preferences(context).edit().clear().commit()) {
throw new IllegalStateException("人脸识别配置恢复失败");
}
return load(context);
}
public static Map<String, Object> toMap(RecognizeConfiguration configuration) {
Map<String, Object> result = new HashMap<>();
result.put("enableLiveness", configuration.isEnableLiveness());
result.put("similarThreshold", configuration.getSimilarThreshold());
result.put("rgbLivenessThreshold", rgbLivenessThreshold(configuration));
result.put("enableImageQuality", configuration.isEnableImageQuality());
result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold());
result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold());
result.put("keepMaxFace", configuration.isKeepMaxFace());
result.put("maxDetectFaces", configuration.getMaxDetectFaces());
result.put("extractRetryCount", configuration.getExtractRetryCount());
result.put("livenessRetryCount", configuration.getLivenessRetryCount());
result.put("recognizeFailedRetryInterval", configuration.getRecognizeFailedRetryInterval());
result.put("livenessFailedRetryInterval", configuration.getLivenessFailedRetryInterval());
result.put("livenessType", "RGB");
return result;
}
private static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
}
private static float rgbLivenessThreshold(RecognizeConfiguration configuration) {
LivenessParam param = configuration.getLivenessParam();
return param == null ? RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD : param.getRgbThreshold();
}
private static boolean booleanValue(Map<?, ?> values, String key, boolean fallback) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Boolean)) throw new IllegalArgumentException(key + " 必须为布尔值");
return (Boolean) value;
}
private static float floatValue(Map<?, ?> values, String key, float fallback, float min, float max) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为数字");
float result = ((Number) value).floatValue();
if (Float.isNaN(result) || Float.isInfinite(result) || result < min || result > max) {
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
}
return result;
}
private static int intValue(Map<?, ?> values, String key, int fallback, int min, int max) {
Object value = values == null ? null : values.get(key);
if (value == null) return fallback;
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为整数");
int result = ((Number) value).intValue();
if (result < min || result > max) {
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
}
return result;
}
}
@@ -36,6 +36,7 @@ import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView;
import io.flutter.plugins.facediscern.ui.FaceDiscern;
@@ -141,37 +142,30 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
setContext(context);
boolean switchCamera = false;
previewConfig = new PreviewConfig(switchCamera ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK, switchCamera ? Camera.CameraInfo.CAMERA_FACING_BACK : Camera.CameraInfo.CAMERA_FACING_FRONT, 0, 0);
// 填入在设置界面设置好的配置信息
boolean enableLive = false;
boolean enableFaceQualityDetect = true;
boolean enableFaceMoveLimit = false;
boolean enableFaceSizeLimit = false;
int faceSizeLimit = 160;
int faceMoveLimit = 20;
int maxDetectFaces = 1;
boolean keepMaxFace = true;
float similarThreshold = 0.8F;
float imageQualityNoMaskRecognizeThreshold = 0.49F;
float imageQualityMaskRecognizeThreshold = 0.29F;
RecognizeConfiguration configuration = new RecognizeConfiguration.Builder().enableFaceMoveLimit(enableFaceMoveLimit).enableFaceSizeLimit(enableFaceSizeLimit).faceSizeLimit(faceSizeLimit).faceMoveLimit(faceMoveLimit).enableLiveness(enableLive).enableImageQuality(enableFaceQualityDetect).maxDetectFaces(maxDetectFaces).keepMaxFace(keepMaxFace).similarThreshold(similarThreshold).imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold).imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold).livenessParam(new LivenessParam(0.5F, 0.5F)).build();
// 单摄像头终端仅支持 RGB 活体,配置由设置页持久化并在每次冷启动识别引擎时读取。
livenessType = LivenessType.RGB;
RecognizeConfiguration configuration = RecognizeConfigurationStore.load(context);
boolean enableLive = configuration.isEnableLiveness();
boolean enableFaceQualityDetect = configuration.isEnableImageQuality();
int maxDetectFaces = configuration.getMaxDetectFaces();
ftEngine = new FaceEngine();
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_FACE_DETECT));
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_FACE_DETECT));
maskEngine = new FaceEngine();
ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_MASK_DETECT));
ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_MASK_DETECT));
frEngine = new FaceEngine();
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
if (enableFaceQualityDetect) {
frEngineMask |= FaceEngine.ASF_IMAGEQUALITY;
}
frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, 10, frEngineMask));
frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, maxDetectFaces, frEngineMask));
//启用活体检测时,才初始化活体引擎
if (enableLive) {
flEngine = new FaceEngine();
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
LivenessParam livenessParam = new LivenessParam(0.5F, 0.5F);
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, flEngineMask));
LivenessParam livenessParam = configuration.getLivenessParam();
flEngine.setLivenessParam(livenessParam);
}
recognizeConfiguration.setValue(configuration);
@@ -179,8 +173,11 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
}
public boolean isEngineReady() {
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
return Objects.equals(ftInitCode.getValue(), ErrorInfo.MOK)
&& Objects.equals(frInitCode.getValue(), ErrorInfo.MOK);
&& Objects.equals(frInitCode.getValue(), ErrorInfo.MOK)
&& (configuration == null || !configuration.isEnableLiveness()
|| Objects.equals(flInitCode.getValue(), ErrorInfo.MOK));
}
private void initFaceHelper(Camera.Size lastPreviewSize) {
@@ -193,7 +190,7 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
}
int horizontalOffset = 0;
int verticalOffset = 0;
int maxDetectFaceNum = 1;
int maxDetectFaceNum = recognizeConfiguration.getValue().getMaxDetectFaces();
faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(faceHelperCallback).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> {
FaceInfo irFaceInfo = new FaceInfo(faceInfo);
irFaceInfo.getRect().offset(horizontalOffset, verticalOffset);
@@ -14,6 +14,7 @@ import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin
import io.flutter.plugins.facediscern.flutter.FlutterFaceSubScreenPlugin
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager
import io.flutter.plugin.common.MethodChannel
import com.rex.flutter_subscreen_plugin.FlutterSubScreenProvider
import com.rex.flutter_subscreen_plugin.FlutterSubscreenPlugin
class MainActivity : FlutterActivity() {
@@ -69,6 +70,25 @@ class MainActivity : FlutterActivity() {
KioskModeManager.enterIfAvailable(this)
}
override fun onDestroy() {
val subScreenProvider = FlutterSubScreenProvider.instance
try {
// Android 12 以下副屏使用 Overlay Presentation,任务被划掉时必须在
// Activity 销毁前同步移除窗口并销毁独立 Engine,不能只依赖 Dart 生命周期回调。
try {
subScreenProvider.closeSubDisplay()
} finally {
try {
subScreenProvider.flutterEngine?.destroy()
} finally {
subScreenProvider.onDispose()
}
}
} finally {
super.onDestroy()
}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_RUNNING_LOW) {
@@ -61,6 +61,185 @@ class FaceConfig {
Map<String, dynamic> toJson() => _$FaceConfigToJson(this);
}
/// 终端本机的人脸识别策略,对应 Android `RecognizeConfiguration` 的可调白名单。
class FaceRecognitionConfiguration {
const FaceRecognitionConfiguration({
required this.enableLiveness,
required this.similarThreshold,
required this.rgbLivenessThreshold,
required this.enableImageQuality,
required this.imageQualityNoMaskRecognizeThreshold,
required this.imageQualityMaskRecognizeThreshold,
required this.keepMaxFace,
required this.maxDetectFaces,
required this.extractRetryCount,
required this.livenessRetryCount,
required this.recognizeFailedRetryInterval,
required this.livenessFailedRetryInterval,
required this.livenessType,
});
const FaceRecognitionConfiguration.defaults()
: enableLiveness = false,
similarThreshold = 0.8,
rgbLivenessThreshold = 0.5,
enableImageQuality = true,
imageQualityNoMaskRecognizeThreshold = 0.49,
imageQualityMaskRecognizeThreshold = 0.29,
keepMaxFace = true,
maxDetectFaces = 1,
extractRetryCount = 3,
livenessRetryCount = 3,
recognizeFailedRetryInterval = 0,
livenessFailedRetryInterval = 0,
livenessType = 'RGB';
final bool enableLiveness;
final double similarThreshold;
final double rgbLivenessThreshold;
final bool enableImageQuality;
final double imageQualityNoMaskRecognizeThreshold;
final double imageQualityMaskRecognizeThreshold;
final bool keepMaxFace;
final int maxDetectFaces;
final int extractRetryCount;
final int livenessRetryCount;
final int recognizeFailedRetryInterval;
final int livenessFailedRetryInterval;
final String livenessType;
factory FaceRecognitionConfiguration.fromMap(Map<dynamic, dynamic> map) {
const defaults = FaceRecognitionConfiguration.defaults();
return FaceRecognitionConfiguration(
enableLiveness: _asBoolOr(map['enableLiveness'], defaults.enableLiveness),
similarThreshold:
_asDouble(map['similarThreshold'], defaults.similarThreshold),
rgbLivenessThreshold: _asDouble(
map['rgbLivenessThreshold'],
defaults.rgbLivenessThreshold,
),
enableImageQuality:
_asBoolOr(map['enableImageQuality'], defaults.enableImageQuality),
imageQualityNoMaskRecognizeThreshold: _asDouble(
map['imageQualityNoMaskRecognizeThreshold'],
defaults.imageQualityNoMaskRecognizeThreshold,
),
imageQualityMaskRecognizeThreshold: _asDouble(
map['imageQualityMaskRecognizeThreshold'],
defaults.imageQualityMaskRecognizeThreshold,
),
keepMaxFace: _asBoolOr(map['keepMaxFace'], defaults.keepMaxFace),
maxDetectFaces: _asIntOr(map['maxDetectFaces'], defaults.maxDetectFaces),
extractRetryCount:
_asIntOr(map['extractRetryCount'], defaults.extractRetryCount),
livenessRetryCount:
_asIntOr(map['livenessRetryCount'], defaults.livenessRetryCount),
recognizeFailedRetryInterval: _asIntOr(
map['recognizeFailedRetryInterval'],
defaults.recognizeFailedRetryInterval,
),
livenessFailedRetryInterval: _asIntOr(
map['livenessFailedRetryInterval'],
defaults.livenessFailedRetryInterval,
),
livenessType: map['livenessType']?.toString() ?? defaults.livenessType,
);
}
Map<String, dynamic> toMap() => {
'enableLiveness': enableLiveness,
'similarThreshold': similarThreshold,
'rgbLivenessThreshold': rgbLivenessThreshold,
'enableImageQuality': enableImageQuality,
'imageQualityNoMaskRecognizeThreshold':
imageQualityNoMaskRecognizeThreshold,
'imageQualityMaskRecognizeThreshold':
imageQualityMaskRecognizeThreshold,
'keepMaxFace': keepMaxFace,
'maxDetectFaces': maxDetectFaces,
'extractRetryCount': extractRetryCount,
'livenessRetryCount': livenessRetryCount,
'recognizeFailedRetryInterval': recognizeFailedRetryInterval,
'livenessFailedRetryInterval': livenessFailedRetryInterval,
};
bool get usesDefaults =>
this == const FaceRecognitionConfiguration.defaults();
FaceRecognitionConfiguration copyWith({
bool? enableLiveness,
double? similarThreshold,
double? rgbLivenessThreshold,
bool? enableImageQuality,
double? imageQualityNoMaskRecognizeThreshold,
double? imageQualityMaskRecognizeThreshold,
bool? keepMaxFace,
int? maxDetectFaces,
int? extractRetryCount,
int? livenessRetryCount,
int? recognizeFailedRetryInterval,
int? livenessFailedRetryInterval,
}) {
return FaceRecognitionConfiguration(
enableLiveness: enableLiveness ?? this.enableLiveness,
similarThreshold: similarThreshold ?? this.similarThreshold,
rgbLivenessThreshold: rgbLivenessThreshold ?? this.rgbLivenessThreshold,
enableImageQuality: enableImageQuality ?? this.enableImageQuality,
imageQualityNoMaskRecognizeThreshold:
imageQualityNoMaskRecognizeThreshold ??
this.imageQualityNoMaskRecognizeThreshold,
imageQualityMaskRecognizeThreshold: imageQualityMaskRecognizeThreshold ??
this.imageQualityMaskRecognizeThreshold,
keepMaxFace: keepMaxFace ?? this.keepMaxFace,
maxDetectFaces: maxDetectFaces ?? this.maxDetectFaces,
extractRetryCount: extractRetryCount ?? this.extractRetryCount,
livenessRetryCount: livenessRetryCount ?? this.livenessRetryCount,
recognizeFailedRetryInterval:
recognizeFailedRetryInterval ?? this.recognizeFailedRetryInterval,
livenessFailedRetryInterval:
livenessFailedRetryInterval ?? this.livenessFailedRetryInterval,
livenessType: livenessType,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FaceRecognitionConfiguration &&
enableLiveness == other.enableLiveness &&
similarThreshold == other.similarThreshold &&
rgbLivenessThreshold == other.rgbLivenessThreshold &&
enableImageQuality == other.enableImageQuality &&
imageQualityNoMaskRecognizeThreshold ==
other.imageQualityNoMaskRecognizeThreshold &&
imageQualityMaskRecognizeThreshold ==
other.imageQualityMaskRecognizeThreshold &&
keepMaxFace == other.keepMaxFace &&
maxDetectFaces == other.maxDetectFaces &&
extractRetryCount == other.extractRetryCount &&
livenessRetryCount == other.livenessRetryCount &&
recognizeFailedRetryInterval == other.recognizeFailedRetryInterval &&
livenessFailedRetryInterval == other.livenessFailedRetryInterval &&
livenessType == other.livenessType;
@override
int get hashCode => Object.hash(
enableLiveness,
similarThreshold,
rgbLivenessThreshold,
enableImageQuality,
imageQualityNoMaskRecognizeThreshold,
imageQualityMaskRecognizeThreshold,
keepMaxFace,
maxDetectFaces,
extractRetryCount,
livenessRetryCount,
recognizeFailedRetryInterval,
livenessFailedRetryInterval,
livenessType,
);
}
@JsonSerializable()
class RegisterAllStorageFaceRes {
// 注册成功数量
@@ -141,10 +320,7 @@ class FacePluginStatus {
final FaceRegistrationResult? lastRegistrationResult;
bool get ready =>
activated &&
engineReady &&
!registrationInProgress &&
libraryConsistent;
activated && engineReady && !registrationInProgress && libraryConsistent;
factory FacePluginStatus.fromMap(Map<dynamic, dynamic> map) {
return FacePluginStatus(
@@ -277,6 +453,15 @@ bool _asBool(dynamic value) => value == true;
int _asInt(dynamic value) => value is num ? value.toInt() : 0;
int _asIntOr(dynamic value, int fallback) =>
value is num ? value.toInt() : fallback;
double _asDouble(dynamic value, double fallback) =>
value is num ? value.toDouble() : fallback;
bool _asBoolOr(dynamic value, bool fallback) =>
value is bool ? value : fallback;
String _asString(dynamic value, String fallback) {
final result = value?.toString();
return result == null || result.isEmpty ? fallback : result;
@@ -139,6 +139,11 @@ class FaceRecognitionFailure implements Exception {
final String message;
bool get cancelled => code == 'CANCELLED';
bool get terminatesSession =>
cancelled ||
code == 'LOW_MEMORY' ||
code == 'SESSION_SUSPENDED' ||
code == 'SESSION_STREAM_ERROR';
@override
String toString() => message;
@@ -26,6 +26,10 @@ class FaceRecognitionSessionHandle {
/// Camera 与虹软热保持由 Android 负责;Dart 结束会话只关闭当前业务和 UI,
/// 不会让连续排队的下一位顾客重新打开摄像头。
class FaceRecognitionSessionService extends GetxService {
final cameraOpen = false.obs;
final cameraStatus = FaceRecognitionSessionStatus.cold.obs;
StreamSubscription<FaceRecognitionSessionEvent>? _cameraStateSubscription;
StreamSubscription<FaceRecognitionSessionEvent>? _nativeStateSubscription;
StreamSubscription<FaceStream?>? _faceSubscription;
StreamController<FaceRecognitionSessionEvent>? _eventController;
@@ -45,6 +49,30 @@ class FaceRecognitionSessionService extends GetxService {
bool get isActive => _activeSessionId != null;
String? get activeSessionId => _activeSessionId;
@override
void onInit() {
super.onInit();
// 顶栏需要跨业务 Session 展示 Camera 热状态,因此常驻订阅不能跟随弹框关闭。
_cameraStateSubscription = FaceDiscernPlugs.sessionStream.listen(
_trackCameraState,
onError: (_) => cameraOpen.value = false,
);
unawaited(_refreshCameraState());
}
Future<void> _refreshCameraState() async {
try {
_trackCameraState(await FaceDiscernPlugs.getRecognitionState());
} catch (error) {
logger.w('|人脸识别|读取摄像头状态失败:$error');
}
}
void _trackCameraState(FaceRecognitionSessionEvent event) {
cameraOpen.value = event.warm;
cameraStatus.value = event.status;
}
bool owns(String? sessionId) =>
sessionId != null && sessionId == _activeSessionId;
@@ -129,8 +157,10 @@ class FaceRecognitionSessionService extends GetxService {
);
if (_activeSessionId == sessionId) {
_nativeStarted = true;
_nativeRevision = initial.revision;
_eventController?.add(initial);
if (initial.revision > _nativeRevision) {
_nativeRevision = initial.revision;
_eventController?.add(initial);
}
}
} on PlatformException catch (error) {
if (_activeSessionId != sessionId) return;
@@ -365,6 +395,8 @@ class FaceRecognitionSessionService extends GetxService {
@override
void onClose() {
unawaited(_cameraStateSubscription?.cancel());
_cameraStateSubscription = null;
unawaited(suspend());
super.onClose();
}
@@ -93,6 +93,23 @@ abstract class FaceDiscernPlugs {
return _invokeSession('getRecognitionState');
}
static Future<FaceRecognitionConfiguration> getRecognizeConfiguration() {
return _invokeRecognizeConfiguration('getRecognizeConfiguration');
}
static Future<FaceRecognitionConfiguration> setRecognizeConfiguration(
FaceRecognitionConfiguration configuration,
) {
return _invokeRecognizeConfiguration(
'setRecognizeConfiguration',
configuration.toMap(),
);
}
static Future<FaceRecognitionConfiguration> resetRecognizeConfiguration() {
return _invokeRecognizeConfiguration('resetRecognizeConfiguration');
}
static Stream<FaceRecognitionSessionEvent> get sessionStream {
_sessionStream ??= _sessionEventChannel
.receiveBroadcastStream()
@@ -111,6 +128,15 @@ abstract class FaceDiscernPlugs {
return FaceRecognitionSessionEvent.fromMap(map);
}
static Future<FaceRecognitionConfiguration> _invokeRecognizeConfiguration(
String method, [
Map<String, dynamic>? arguments,
]) async {
final map = await _channel.invokeMethod<dynamic>(method, arguments);
if (map is! Map) throw StateError('原生未返回有效的人脸识别配置');
return FaceRecognitionConfiguration.fromMap(map);
}
static Future<FaceConfig?> getConfig() async {
try {
final Map<Object?, Object?>? map =
@@ -4,6 +4,8 @@ import 'package:forui/forui.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.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/utils/app/app_device_status_tool.dart';
/// 顶栏统一订阅设备状态快照,避免各业务页面分别维护硬件在线标记。
@@ -14,6 +16,12 @@ class TerminalDeviceStatus extends StatelessWidget {
Widget build(BuildContext context) {
return Obx(() {
final status = AppDeviceStatusTool.status.value;
final faceService = Get.isRegistered<FaceRecognitionSessionService>()
? Get.find<FaceRecognitionSessionService>()
: null;
final cameraOpen = faceService?.cameraOpen.value ?? false;
final cameraStatus =
faceService?.cameraStatus.value ?? FaceRecognitionSessionStatus.cold;
return Row(
children: [
TerminalStatusPill(
@@ -40,12 +48,142 @@ class TerminalDeviceStatus extends StatelessWidget {
active: status.subScreenAvailable,
showBorder: false,
),
FaceCameraStatusCard(
cameraOpen: cameraOpen,
status: cameraStatus,
),
],
);
});
}
}
/// 摄像头既有识别中状态,也有连续客流所需的热保持状态,因此在同一行保留具体状态文字。
class FaceCameraStatusCard extends StatelessWidget {
const FaceCameraStatusCard({
super.key,
required this.cameraOpen,
required this.status,
});
final bool cameraOpen;
final FaceRecognitionSessionStatus status;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final sizeScale = XfjDesignTokens.buttonSizeScale;
final itemPadding = 12.w * sizeScale;
final visual = _FaceCameraStatusVisual.resolve(
cameraOpen: cameraOpen,
status: status,
);
return Tooltip(
message: visual.description,
child: SizedBox(
key: ValueKey('terminal-camera-${visual.label}'),
height: 36.h,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: itemPadding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FIcon(
cameraOpen ? FAssets.icons.camera : FAssets.icons.cameraOff,
size: 19.w,
color: XfjDesignTokens.ink,
),
SizedBox(width: 8.w),
Text(
'摄像头',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.ink,
),
),
SizedBox(width: 8.w),
Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visual.accent,
),
),
SizedBox(width: 8.w),
Text(
visual.label,
style: theme.typography.xs.copyWith(
color: visual.accent,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class _FaceCameraStatusVisual {
const _FaceCameraStatusVisual({
required this.label,
required this.description,
required this.accent,
});
final String label;
final String description;
final Color accent;
static _FaceCameraStatusVisual resolve({
required bool cameraOpen,
required FaceRecognitionSessionStatus status,
}) {
if (!cameraOpen) {
final failed = status == FaceRecognitionSessionStatus.failed;
return _FaceCameraStatusVisual(
label: failed ? '异常关闭' : '已关闭',
description: failed ? '摄像头或识别引擎异常,当前已释放资源' : '摄像头当前未开启',
accent: failed ? XfjDesignTokens.danger : XfjDesignTokens.muted,
);
}
if (status == FaceRecognitionSessionStatus.initializing) {
return const _FaceCameraStatusVisual(
label: '启动中',
description: '摄像头已打开,正在初始化虹软识别引擎',
accent: XfjDesignTokens.primary,
);
}
if (status == FaceRecognitionSessionStatus.detecting) {
return const _FaceCameraStatusVisual(
label: '识别中',
description: '摄像头已打开,正在实时识别人脸',
accent: XfjDesignTokens.success,
);
}
if (status == FaceRecognitionSessionStatus.failed) {
return const _FaceCameraStatusVisual(
label: '等待重试',
description: '摄像头仍保持开启,可直接重试当前人脸会话',
accent: XfjDesignTokens.warning,
);
}
if (status == FaceRecognitionSessionStatus.success) {
return const _FaceCameraStatusVisual(
label: '识别成功',
description: '摄像头已打开,正在处理本次识别结果',
accent: XfjDesignTokens.success,
);
}
return const _FaceCameraStatusVisual(
label: '热保持',
description: '摄像头已打开,连续 20 分钟无新的人脸会话后自动关闭',
accent: Color(0xFF0F766E),
);
}
}
class TerminalStatusPill extends StatelessWidget {
const TerminalStatusPill({
super.key,
+7 -5
View File
@@ -296,7 +296,7 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
width: 410.w,
width: 270.w,
constraints: BoxConstraints(minHeight: 48.h),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
@@ -320,9 +320,9 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
),
SizedBox(width: 7.w),
SizedBox(
width: 124.w,
width: 100.w,
child: Text(
_connected ? '$_networkType在线' : '网络离线',
_connected ? _networkType : '网络离线',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink),
@@ -331,11 +331,13 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
SizedBox(width: 8.w),
Expanded(
child: Text(
'↓$_downloadSpeed ↑$_uploadSpeed',
maxLines: 1,
'↓$_downloadSpeed\n↑$_uploadSpeed',
maxLines: 2,
overflow: TextOverflow.clip,
style: theme.typography.xs.copyWith(
color: XfjDesignTokens.muted,
fontSize: (theme.typography.xs.fontSize ?? 12) * 0.82,
height: 1.05,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
@@ -95,7 +95,7 @@ class _FaceRecognitionPreviewDialogState
Navigator.of(context).pop(result);
} on FaceRecognitionFailure catch (failure) {
if (!mounted || _closing) return;
if (failure.cancelled) {
if (failure.terminatesSession) {
_closing = true;
Navigator.of(context).pop();
return;
@@ -114,7 +114,11 @@ class ReservationMenuQueryController extends GetxController {
currentPage.value = 1;
errorMessage.value = '';
status.value = ReservationMenuQueryStatus.recognizing;
await _syncSubScreen('loading');
// 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、
// 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。
if (method.value == ReservationRecognitionMethod.card) {
await _syncSubScreen('loading');
}
Future<void> recognized(String value) async {
if (_closed || generation != _requestGeneration || _recognitionLocked) {
@@ -178,7 +178,17 @@ class TerminalReservationRecognitionGateway
service: service,
handle: handle,
);
if (result == null || _faceSessionId != handle.sessionId) return;
if (_faceSessionId != handle.sessionId) return;
if (result == null) {
final sessionStillActive = service.owns(handle.sessionId);
onFailure(ReservationRecognitionFailure(
sessionStillActive
? ReservationRecognitionFailureType.device
: ReservationRecognitionFailureType.cancelled,
sessionStillActive ? '人脸识别已中断,请检查设备后重试' : '已取消人脸识别',
));
return;
}
// 成功提示已停留 700ms,此时先结束原生业务 Session,再进入查询流程。
// Controller 的 recognized() 会统一 stopAll;提前清空可避免 success 被误发成 cancel。
if (service.owns(handle.sessionId)) await service.finish();
@@ -21,6 +21,11 @@ class FaceSettingController extends GetxController {
final errorMessage = ''.obs;
final permissionCanOpenSettings = false.obs;
final showFullParameters = false.obs;
final recognitionConfiguration = Rxn<FaceRecognitionConfiguration>();
final savedRecognitionConfiguration = Rxn<FaceRecognitionConfiguration>();
final isLoadingRecognitionConfiguration = false.obs;
final isSavingRecognitionConfiguration = false.obs;
final showAdvancedRecognitionConfiguration = false.obs;
StreamSubscription<FaceRegistrationProgress>? _progressSubscription;
@@ -34,6 +39,7 @@ class FaceSettingController extends GetxController {
},
);
refreshStatus();
refreshRecognitionConfiguration();
}
@override
@@ -63,6 +69,96 @@ class FaceSettingController extends GetxController {
}
}
Future<void> refreshRecognitionConfiguration() async {
if (isLoadingRecognitionConfiguration.value) return;
isLoadingRecognitionConfiguration.value = true;
try {
final value = await FaceDiscernPlugs.getRecognizeConfiguration();
savedRecognitionConfiguration.value = value;
recognitionConfiguration.value = value;
} on PlatformException catch (e, stack) {
logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: e.message ?? '读取人脸识别策略失败');
} catch (e, stack) {
logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: '读取人脸识别策略失败');
} finally {
isLoadingRecognitionConfiguration.value = false;
}
}
void updateRecognitionConfiguration(
FaceRecognitionConfiguration Function(FaceRecognitionConfiguration value)
update,
) {
final current = recognitionConfiguration.value;
if (current == null || isSavingRecognitionConfiguration.value) return;
recognitionConfiguration.value = update(current);
}
Future<void> saveRecognitionConfiguration() async {
final current = recognitionConfiguration.value;
if (current == null || isSavingRecognitionConfiguration.value) return;
isSavingRecognitionConfiguration.value = true;
try {
final saved = await FaceDiscernPlugs.setRecognizeConfiguration(current);
savedRecognitionConfiguration.value = saved;
recognitionConfiguration.value = saved;
final active = Get.isRegistered<FaceRecognitionSessionService>() &&
Get.find<FaceRecognitionSessionService>().isActive;
Toast.success(
msg: active ? '配置已保存,将在当前识别结束后生效' : '人脸识别配置已保存',
);
} on PlatformException catch (e, stack) {
logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: e.message ?? '保存人脸识别配置失败');
} catch (e, stack) {
logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: '保存人脸识别配置失败');
} finally {
isSavingRecognitionConfiguration.value = false;
}
}
Future<void> resetRecognitionConfiguration() async {
if (isSavingRecognitionConfiguration.value) return;
final confirmed = await appMessageDialog(
Get.context!,
'是否恢复默认人脸识别策略?活体检测将恢复为关闭,相似度阈值恢复为 80%。',
);
if (!confirmed) return;
isSavingRecognitionConfiguration.value = true;
try {
final restored = await FaceDiscernPlugs.resetRecognizeConfiguration();
savedRecognitionConfiguration.value = restored;
recognitionConfiguration.value = restored;
final active = Get.isRegistered<FaceRecognitionSessionService>() &&
Get.find<FaceRecognitionSessionService>().isActive;
Toast.success(
msg: active ? '已恢复默认配置,将在当前识别结束后生效' : '已恢复默认人脸识别配置',
);
} on PlatformException catch (e, stack) {
logger.e('|$_tag|恢复识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: e.message ?? '恢复默认配置失败');
} catch (e, stack) {
logger.e('|$_tag|恢复识别策略失败error:|$e|stack:|$stack|');
Toast.error(msg: '恢复默认配置失败');
} finally {
isSavingRecognitionConfiguration.value = false;
}
}
void discardRecognitionConfigurationChanges() {
recognitionConfiguration.value = savedRecognitionConfiguration.value;
}
void toggleAdvancedRecognitionConfiguration() {
showAdvancedRecognitionConfiguration.toggle();
}
bool get recognitionConfigurationChanged =>
recognitionConfiguration.value != savedRecognitionConfiguration.value;
Future<void> syncConfig() async {
if (isBusy) return;
if (!AppNetworkTool.isAutoNetworkMsg()) return;
@@ -172,7 +268,8 @@ class FaceSettingController extends GetxController {
isLoading.value ||
isActivating.value ||
isRegistering.value ||
isSyncingConfig.value;
isSyncingConfig.value ||
isSavingRecognitionConfiguration.value;
void _handleProgress(FaceRegistrationProgress value) {
progress.value = value;
@@ -22,6 +22,8 @@ Widget settingFacePlugin(BuildContext context) {
children: [
_FaceStatusGroup(controller: controller),
SizedBox(height: XfjDesignTokens.cardGap.h),
_RecognitionConfigurationGroup(controller: controller),
SizedBox(height: XfjDesignTokens.cardGap.h),
_ActivationParameterGroup(controller: controller),
if ((controller.progress.value != null &&
controller.progress.value!.phase != 'idle') ||
@@ -40,6 +42,390 @@ Widget settingFacePlugin(BuildContext context) {
});
}
/// 现场只开放能明确解释、且原生已做范围校验的识别策略;保存不会打断正在支付的顾客。
class _RecognitionConfigurationGroup extends StatelessWidget {
const _RecognitionConfigurationGroup({required this.controller});
final FaceSettingController controller;
@override
Widget build(BuildContext context) {
// 子组件的 build 晚于外层 Obx 执行,必须在这里读取 Rx,才能让开关、滑块和展开项即时刷新。
return Obx(() {
final configuration = controller.recognitionConfiguration.value;
if (configuration == null) {
return SettingGroup(
title: '识别策略',
child: SizedBox(
height: 100.h,
child: controller.isLoadingRecognitionConfiguration.value
? const Center(child: CircularProgressIndicator())
: SettingInfoRow(
icon: FAssets.icons.triangleAlert,
label: '配置读取失败',
subtitle: '未能从 Android 人脸插件读取识别参数',
actionLabel: '重新读取',
onTap: controller.refreshRecognitionConfiguration,
showDivider: false,
),
),
);
}
final saving = controller.isSavingRecognitionConfiguration.value;
final changed = controller.recognitionConfigurationChanged;
return SettingGroup(
title: '识别策略',
child: Column(
children: [
SettingInfoRow(
icon: configuration.enableLiveness
? FAssets.icons.shieldCheck
: FAssets.icons.shieldOff,
label: 'RGB 活体认证',
subtitle: '防止照片或屏幕翻拍;当前单摄像头方案仅支持 RGB 活体',
value: configuration.enableLiveness ? '已开启' : '已关闭',
trailing: FSwitch(
semanticLabel: '开启 RGB 活体认证',
value: configuration.enableLiveness,
enabled: !saving,
onChange: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(enableLiveness: value),
),
),
),
_RecognitionValueRow(
icon: FAssets.icons.scanFace,
label: '识别成功相似度',
subtitle: '候选人员相似度高于该值才算识别成功;阈值越高越严格',
value: configuration.similarThreshold,
min: 0.5,
max: 0.99,
step: 0.01,
formatter: _percent,
enabled: !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(similarThreshold: value),
),
),
_RecognitionValueRow(
icon: FAssets.icons.activity,
label: 'RGB 活体阈值',
subtitle: configuration.enableLiveness
? '活体分数达到该值才继续进行人脸比对;阈值越高越严格'
: '开启活体认证后生效',
value: configuration.rgbLivenessThreshold,
min: 0.3,
max: 0.9,
step: 0.01,
formatter: _percent,
enabled: configuration.enableLiveness && !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(rgbLivenessThreshold: value),
),
),
SettingInfoRow(
icon: FAssets.icons.image,
label: '图像质量检测',
subtitle: '过滤过暗、模糊或质量不足的人脸,降低误识别风险',
value: configuration.enableImageQuality ? '已开启' : '已关闭',
trailing: FSwitch(
semanticLabel: '开启人脸图像质量检测',
value: configuration.enableImageQuality,
enabled: !saving,
onChange: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(enableImageQuality: value),
),
),
),
SettingInfoRow(
icon: configuration.keepMaxFace
? FAssets.icons.userRoundCheck
: FAssets.icons.users,
label: '仅识别最大人脸',
subtitle: '多人进入画面时优先识别距离摄像头最近的人,适合排队支付',
value: configuration.keepMaxFace ? '已开启' : '已关闭',
trailing: FSwitch(
semanticLabel: '仅识别画面中最大人脸',
value: configuration.keepMaxFace,
enabled: !saving,
onChange: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(keepMaxFace: value),
),
),
),
SettingInfoRow(
icon: FAssets.icons.slidersHorizontal,
label: '高级参数',
subtitle: '图像质量阈值、重试次数和失败重试间隔',
value: controller.showAdvancedRecognitionConfiguration.value
? '已展开'
: '已收起',
actionLabel: controller.showAdvancedRecognitionConfiguration.value
? '收起'
: '展开',
onTap: controller.toggleAdvancedRecognitionConfiguration,
showDivider:
!controller.showAdvancedRecognitionConfiguration.value,
),
if (controller.showAdvancedRecognitionConfiguration.value) ...[
_RecognitionValueRow(
icon: FAssets.icons.userRound,
label: '无口罩图像质量阈值',
subtitle: '未佩戴口罩时,图像质量低于该值将继续等待更清晰画面',
value: configuration.imageQualityNoMaskRecognizeThreshold,
min: 0,
max: 1,
step: 0.01,
formatter: _decimal,
enabled: configuration.enableImageQuality && !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(
imageQualityNoMaskRecognizeThreshold: value,
),
),
),
_RecognitionValueRow(
icon: FAssets.icons.userRound,
label: '戴口罩图像质量阈值',
subtitle: '佩戴口罩时使用独立阈值,避免沿用无口罩标准导致持续等待',
value: configuration.imageQualityMaskRecognizeThreshold,
min: 0,
max: 1,
step: 0.01,
formatter: _decimal,
enabled: configuration.enableImageQuality && !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(
imageQualityMaskRecognizeThreshold: value,
),
),
),
_RecognitionIntegerSelector(
icon: FAssets.icons.scanFace,
label: '最大检测人脸数',
subtitle: '单识别线程最多跟踪的人脸数量;排队支付建议保持 1',
value: configuration.maxDetectFaces,
options: const [1, 2, 3],
enabled: !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(maxDetectFaces: value),
),
),
_RecognitionIntegerSelector(
icon: FAssets.icons.rotateCcw,
label: '特征提取重试次数',
subtitle: '提取失败后允许继续尝试的次数',
value: configuration.extractRetryCount,
options: const [1, 2, 3, 5, 8],
enabled: !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(extractRetryCount: value),
),
),
_RecognitionIntegerSelector(
icon: FAssets.icons.shieldCheck,
label: '活体失败重试次数',
subtitle: '活体引擎连续报错达到该次数后重置检测状态',
value: configuration.livenessRetryCount,
options: const [1, 2, 3, 5, 8],
enabled: configuration.enableLiveness && !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(livenessRetryCount: value),
),
),
_RecognitionValueRow(
icon: FAssets.icons.timerReset,
label: '识别失败重试间隔',
subtitle: '未注册或相似度不足后,等待多久再次尝试',
value: configuration.recognizeFailedRetryInterval.toDouble(),
min: 0,
max: 5000,
step: 100,
formatter: _milliseconds,
enabled: !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(
recognizeFailedRetryInterval: value.round(),
),
),
),
_RecognitionValueRow(
icon: FAssets.icons.timerReset,
label: '活体失败重试间隔',
subtitle: '活体未通过后,等待多久重新检测',
value: configuration.livenessFailedRetryInterval.toDouble(),
min: 0,
max: 5000,
step: 100,
formatter: _milliseconds,
enabled: configuration.enableLiveness && !saving,
onChanged: (value) => controller.updateRecognitionConfiguration(
(current) => current.copyWith(
livenessFailedRetryInterval: value.round(),
),
),
showDivider: false,
),
],
_CompactActionBar(
children: [
SettingActionButton(
label: '恢复默认',
icon: FAssets.icons.rotateCcw,
enabled: !saving && !configuration.usesDefaults,
onTap: controller.resetRecognitionConfiguration,
),
if (changed)
SettingActionButton(
label: '放弃修改',
icon: FAssets.icons.x,
enabled: !saving,
onTap: controller.discardRecognitionConfigurationChanges,
),
SettingActionButton(
label: saving ? '保存中...' : '保存配置',
icon: FAssets.icons.save,
primary: true,
enabled: changed && !saving,
onTap: controller.saveRecognitionConfiguration,
),
],
),
],
),
);
});
}
}
class _RecognitionValueRow extends StatelessWidget {
const _RecognitionValueRow({
required this.icon,
required this.label,
required this.subtitle,
required this.value,
required this.min,
required this.max,
required this.step,
required this.formatter,
required this.enabled,
required this.onChanged,
this.showDivider = true,
});
final SvgAsset icon;
final String label;
final String subtitle;
final double value;
final double min;
final double max;
final double step;
final String Function(double value) formatter;
final bool enabled;
final ValueChanged<double> onChanged;
final bool showDivider;
@override
Widget build(BuildContext context) {
final normalized = ((value - min) / (max - min)).clamp(0.0, 1.0);
return SettingInfoRow(
icon: icon,
label: label,
subtitle: subtitle,
value: formatter(value),
showDivider: showDivider,
trailing: SizedBox(
width: 300.w,
child: Slider(
value: normalized,
divisions: ((max - min) / step).round(),
label: formatter(value),
onChanged: enabled
? (offset) {
final raw = min + (offset * (max - min));
final rounded = (raw / step).round() * step;
onChanged(rounded.clamp(min, max));
}
: null,
),
),
);
}
}
class _RecognitionIntegerSelector extends StatelessWidget {
const _RecognitionIntegerSelector({
required this.icon,
required this.label,
required this.subtitle,
required this.value,
required this.options,
required this.enabled,
required this.onChanged,
});
final SvgAsset icon;
final String label;
final String subtitle;
final int value;
final List<int> options;
final bool enabled;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return SettingInfoRow(
icon: icon,
label: label,
subtitle: subtitle,
value: '$value',
trailing: Wrap(
spacing: 6.w,
children: options.map((option) {
final selected = option == value;
return InkWell(
onTap: enabled ? () => onChanged(option) : null,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Container(
width: 48.w,
height: 42.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? XfjDesignTokens.primarySoft
: XfjDesignTokens.surface,
border: Border.all(
color:
selected ? XfjDesignTokens.primary : XfjDesignTokens.line,
),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Text(
'$option',
style: theme.typography.sm.copyWith(
color:
selected ? XfjDesignTokens.primary : XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
),
);
}).toList(growable: false),
),
);
}
}
String _percent(double value) => '${(value * 100).round()}%';
String _decimal(double value) => value.toStringAsFixed(2);
String _milliseconds(double value) => '${value.round()} ms';
/// 按“基本信息”的字段行组织状态,避免大屏终端出现横向仪表盘和重复操作入口。
class _FaceStatusGroup extends StatelessWidget {
const _FaceStatusGroup({required this.controller});
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/face_recognition_display_models.dart';
import '../models/reservation_query_display_models.dart';
import 'customer_display_coordinator.dart';
import 'face_recognition_coordinator.dart';
import 'reservation_query_coordinator.dart';
@@ -27,6 +29,7 @@ class SubDisplayCoordinator extends ChangeNotifier {
static const _reservationUpdate = 'reservationQueryUpdate';
static const _reservationClear = 'reservationQueryClear';
static const _faceStart = 'faceRecognitionStart';
static const _settlementActions = {
'updateCard',
'jumpSubPage',
@@ -43,6 +46,10 @@ class SubDisplayCoordinator extends ChangeNotifier {
SubDisplayMode _mode = SubDisplayMode.settlement;
SubDisplayMode _modeBeforeFace = SubDisplayMode.settlement;
Timer? _faceRestoreTimer;
String? _reservationSessionAtFaceStart;
int _reservationRevisionAtFaceStart = -1;
bool _reservationSuccessDelayPassed = false;
bool _reservationResultReady = false;
bool _disposed = false;
SubDisplayMode get mode => _mode;
@@ -52,21 +59,42 @@ class SubDisplayCoordinator extends ChangeNotifier {
if (action.startsWith('faceRecognition')) {
final result = faceRecognitionCoordinator.handleAction(action, arguments);
if (!result.accepted) return false;
final faceState = faceRecognitionCoordinator.state;
switch (result.transition) {
case FaceRecognitionDisplayTransition.show:
_faceRestoreTimer?.cancel();
if (action == _faceStart) {
_resetReservationFaceHandoff();
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
_beginReservationFaceHandoff();
}
}
if (_mode != SubDisplayMode.faceRecognition) _modeBeforeFace = _mode;
_replaceMode(SubDisplayMode.faceRecognition);
break;
case FaceRecognitionDisplayTransition.restoreNow:
_faceRestoreTimer?.cancel();
_resetReservationFaceHandoff();
_replaceMode(_modeBeforeFace);
break;
case FaceRecognitionDisplayTransition.restoreAfterSuccess:
_faceRestoreTimer?.cancel();
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
if (!_disposed) _replaceMode(_modeBeforeFace);
});
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
// 预约查询必须在人脸成功提示展示满 700ms 且查询拿到最终结果后再恢复,
// 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。
_reservationSuccessDelayPassed = false;
_reservationResultReady = _hasNewReservationTerminalState();
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
if (_disposed) return;
_reservationSuccessDelayPassed = true;
_restoreReservationAfterFaceIfReady();
});
} else {
_resetReservationFaceHandoff();
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
if (!_disposed) _replaceMode(_modeBeforeFace);
});
}
break;
case FaceRecognitionDisplayTransition.none:
break;
@@ -75,12 +103,18 @@ class SubDisplayCoordinator extends ChangeNotifier {
}
if (action == _reservationUpdate) {
final accepted = reservationCoordinator.handleUpdate(arguments);
if (accepted) _showOrRememberMode(SubDisplayMode.reservationQuery);
if (accepted) {
_showOrRememberMode(SubDisplayMode.reservationQuery);
_handleReservationUpdateDuringFace();
}
return accepted;
}
if (action == _reservationClear) {
final accepted = reservationCoordinator.handleClear(arguments);
if (accepted) _showOrRememberMode(SubDisplayMode.reservationQuery);
if (accepted) {
_showOrRememberMode(SubDisplayMode.reservationQuery);
_handleReservationClearDuringFace();
}
return accepted;
}
if (_settlementActions.contains(action)) {
@@ -98,6 +132,73 @@ class SubDisplayCoordinator extends ChangeNotifier {
return true;
}
void _beginReservationFaceHandoff() {
final reservationState = reservationCoordinator.state;
_reservationSessionAtFaceStart = reservationState.sessionId;
_reservationRevisionAtFaceStart = reservationState.revision;
_reservationSuccessDelayPassed = false;
_reservationResultReady = false;
}
void _handleReservationUpdateDuringFace() {
if (_mode != SubDisplayMode.faceRecognition) return;
final faceState = faceRecognitionCoordinator.state;
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
faceState?.status != FaceRecognitionDisplayStatus.failed) {
return;
}
if (!_hasNewReservationTerminalState()) return;
_reservationResultReady = true;
if (faceState?.status == FaceRecognitionDisplayStatus.failed) {
// 可重试的人脸失败不会产生预约结果;只有业务已收口为 error 时才离开人脸页。
_faceRestoreTimer?.cancel();
_restoreReservationAfterFace();
return;
}
_restoreReservationAfterFaceIfReady();
}
void _handleReservationClearDuringFace() {
if (_mode != SubDisplayMode.faceRecognition) return;
final faceState = faceRecognitionCoordinator.state;
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
faceState?.status != FaceRecognitionDisplayStatus.failed) {
return;
}
// 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。
_restoreReservationAfterFace();
}
bool _hasNewReservationTerminalState() {
final state = reservationCoordinator.state;
final isNewer = state.sessionId != _reservationSessionAtFaceStart ||
state.revision > _reservationRevisionAtFaceStart;
if (!isNewer) return false;
return state.scenario == ReservationQueryDisplayScenario.success ||
state.scenario == ReservationQueryDisplayScenario.empty ||
state.scenario == ReservationQueryDisplayScenario.error;
}
void _restoreReservationAfterFaceIfReady() {
if (!_reservationSuccessDelayPassed || !_reservationResultReady) return;
_restoreReservationAfterFace();
}
void _restoreReservationAfterFace() {
_faceRestoreTimer?.cancel();
_replaceMode(SubDisplayMode.reservationQuery);
_resetReservationFaceHandoff();
}
void _resetReservationFaceHandoff() {
_reservationSessionAtFaceStart = null;
_reservationRevisionAtFaceStart = -1;
_reservationSuccessDelayPassed = false;
_reservationResultReady = false;
}
/// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。
/// 这样 success 的 700ms 提示和 failed 的重试引导不会被金额或查询消息提前顶掉。
bool _showOrRememberMode(SubDisplayMode next) {
@@ -118,6 +219,7 @@ class SubDisplayCoordinator extends ChangeNotifier {
if (_disposed) return;
_disposed = true;
_faceRestoreTimer?.cancel();
_resetReservationFaceHandoff();
customerCoordinator.removeListener(_notifyFromChild);
reservationCoordinator.removeListener(_notifyFromChild);
faceRecognitionCoordinator.removeListener(_notifyFromChild);
@@ -122,6 +122,39 @@ void main() {
await onlineController.clearForPrivacy();
});
testWidgets('人脸方式先显示识别页,识别成功后才发送预约查询状态', (tester) async {
final repository = _ImmediateRepository(_result('张*明', 1));
final recognition = _FakeRecognitionGateway();
final subScreen = _FakeSubScreenGateway();
final controller = ReservationMenuQueryController(
repository: repository,
recognitionGateway: recognition,
subScreenGateway: subScreen,
networkAvailable: () => true,
);
addTearDown(controller.onClose);
controller.onInit();
await tester.pump();
subScreen.updates.clear();
await controller.selectMethod(ReservationRecognitionMethod.face);
expect(recognition.faceCallbacks, hasLength(1));
expect(
subScreen.updates,
isEmpty,
reason: '人脸识别开始后由统一人脸协议接管副屏,不能提前发送预约 loading',
);
await recognition.emitFaceAt(0, '1001');
expect(repository.calls, ['1001']);
expect(
subScreen.updates.map((event) => event.scenario),
['loading', 'success'],
);
await controller.clearForPrivacy();
});
test('预约日期模型按 5、10、11 天正确分页', () {
for (final entry in const {5: 1, 10: 1, 11: 2}.entries) {
final display = ReservationMenuDisplayData.fromService(
@@ -169,12 +202,18 @@ class _ImmediateRepository implements ReservationMenuRepository {
class _FakeRecognitionGateway implements ReservationRecognitionGateway {
final cardCallbacks = <Future<void> Function(String)>[];
final cardFailures = <void Function(ReservationRecognitionFailure failure)>[];
final faceCallbacks = <Future<void> Function(String)>[];
final faceFailures = <void Function(ReservationRecognitionFailure failure)>[];
int stopAllCalls = 0;
Future<void> emitCardAt(int index, String value) {
return cardCallbacks[index](value);
}
Future<void> emitFaceAt(int index, String value) {
return faceCallbacks[index](value);
}
@override
Future<void> startCard({
required Future<void> Function(String cardNumber) onRecognized,
@@ -188,7 +227,10 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway {
Future<void> startFace({
required Future<void> Function(String userId) onRecognized,
required void Function(ReservationRecognitionFailure failure) onFailure,
}) async {}
}) async {
faceCallbacks.add(onRecognized);
faceFailures.add(onFailure);
}
@override
Future<void> stopAll() async {
@@ -128,6 +128,174 @@ void main() {
coordinator.dispose();
});
testWidgets('预约人脸成功后等待当前用户查询结果再恢复预约菜单', (tester) async {
final now = DateTime(2026, 7, 24, 8);
final coordinator = SubDisplayCoordinator(
reservationCoordinator: ReservationQueryCoordinator(now: () => now),
);
addTearDown(coordinator.dispose);
expect(
coordinator.handleAction(
'reservationQueryUpdate',
const {
'sessionId': 'query-1',
'revision': 1,
'scenario': 'idle',
},
),
isTrue,
);
expect(coordinator.mode, SubDisplayMode.reservationQuery);
expect(
coordinator.handleAction(
'faceRecognitionStart',
const {
'sessionId': 'face-1',
'revision': 1,
'scene': 'reservation',
'message': '正在启动摄像头',
},
),
isTrue,
);
coordinator.handleAction(
'faceRecognitionSuccess',
const {
'sessionId': 'face-1',
'revision': 2,
'scene': 'reservation',
'message': '识别成功,正在读取信息',
},
);
expect(coordinator.mode, SubDisplayMode.faceRecognition);
coordinator.handleAction(
'reservationQueryUpdate',
const {
'sessionId': 'query-1',
'revision': 2,
'scenario': 'loading',
},
);
await tester.pump(const Duration(milliseconds: 700));
expect(
coordinator.mode,
SubDisplayMode.faceRecognition,
reason: '中间 loading 不能提前覆盖人脸成功页',
);
expect(
coordinator.handleAction(
'reservationQueryUpdate',
_reservationEvent(
sessionId: 'query-1',
revision: 3,
expiresAt: now.add(const Duration(seconds: 60)),
),
),
isTrue,
);
expect(coordinator.mode, SubDisplayMode.reservationQuery);
expect(
coordinator.reservationCoordinator.state.scenario,
ReservationQueryDisplayScenario.success,
);
coordinator.dispose();
});
testWidgets('预约查询先返回时仍保证人脸成功提示展示 700ms', (tester) async {
final now = DateTime(2026, 7, 24, 8);
final coordinator = SubDisplayCoordinator(
reservationCoordinator: ReservationQueryCoordinator(now: () => now),
);
addTearDown(coordinator.dispose);
coordinator.handleAction(
'reservationQueryUpdate',
const {
'sessionId': 'query-2',
'revision': 1,
'scenario': 'idle',
},
);
coordinator.handleAction(
'faceRecognitionStart',
const {
'sessionId': 'face-2',
'revision': 1,
'scene': 'reservation',
'message': '正在启动摄像头',
},
);
coordinator.handleAction(
'faceRecognitionSuccess',
const {
'sessionId': 'face-2',
'revision': 2,
'scene': 'reservation',
'message': '识别成功,正在读取信息',
},
);
coordinator.handleAction(
'reservationQueryUpdate',
_reservationEvent(
sessionId: 'query-2',
revision: 2,
expiresAt: now.add(const Duration(seconds: 60)),
),
);
await tester.pump(const Duration(milliseconds: 699));
expect(coordinator.mode, SubDisplayMode.faceRecognition);
await tester.pump(const Duration(milliseconds: 1));
expect(coordinator.mode, SubDisplayMode.reservationQuery);
coordinator.dispose();
});
test('预约查询清屏会结束已完成但仍在等待结果的人脸页', () {
final coordinator = SubDisplayCoordinator();
addTearDown(coordinator.dispose);
coordinator.handleAction(
'reservationQueryUpdate',
const {
'sessionId': 'query-3',
'revision': 1,
'scenario': 'idle',
},
);
coordinator.handleAction(
'faceRecognitionStart',
const {
'sessionId': 'face-3',
'revision': 1,
'scene': 'reservation',
'message': '正在启动摄像头',
},
);
coordinator.handleAction(
'faceRecognitionSuccess',
const {
'sessionId': 'face-3',
'revision': 2,
'scene': 'reservation',
'message': '识别成功,正在读取信息',
},
);
expect(coordinator.mode, SubDisplayMode.faceRecognition);
expect(
coordinator.handleAction(
'reservationQueryClear',
const {'sessionId': 'query-3', 'revision': 2},
),
isTrue,
);
expect(coordinator.mode, SubDisplayMode.reservationQuery);
});
}
Map<String, dynamic> _reservationEvent({