diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index d358186..7e59015 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -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"); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java index 4e019cf..ef43d83 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -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 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> boxes, String errorCode) { Map 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 event; + synchronized (sessionLock) { + event = snapshot(); + event.put("message", message); + event.put("faceBoxes", new ArrayList<>()); + } + for (Listener listener : listeners) listener.onSessionEvent(event); + } + private Map snapshot() { Map 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; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index e67f62e..76f19af 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -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; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java new file mode 100644 index 0000000..ac3d6bf --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java @@ -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 toMap(RecognizeConfiguration configuration) { + Map 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; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java index b4147ef..792a8b0 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java @@ -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); diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index 289f266..8339553 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -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) { diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index 277e23c..8349029 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -61,6 +61,185 @@ class FaceConfig { Map 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 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 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 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; diff --git a/lib/common/models/plugs/face_recognition_session_models.dart b/lib/common/models/plugs/face_recognition_session_models.dart index 8aa7e8e..e548fe8 100644 --- a/lib/common/models/plugs/face_recognition_session_models.dart +++ b/lib/common/models/plugs/face_recognition_session_models.dart @@ -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; diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart index 26bc41b..2de5298 100644 --- a/lib/common/services/face_recognition_session_service.dart +++ b/lib/common/services/face_recognition_session_service.dart @@ -26,6 +26,10 @@ class FaceRecognitionSessionHandle { /// Camera 与虹软热保持由 Android 负责;Dart 结束会话只关闭当前业务和 UI, /// 不会让连续排队的下一位顾客重新打开摄像头。 class FaceRecognitionSessionService extends GetxService { + final cameraOpen = false.obs; + final cameraStatus = FaceRecognitionSessionStatus.cold.obs; + + StreamSubscription? _cameraStateSubscription; StreamSubscription? _nativeStateSubscription; StreamSubscription? _faceSubscription; StreamController? _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 _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(); } diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 871c30d..da63453 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -93,6 +93,23 @@ abstract class FaceDiscernPlugs { return _invokeSession('getRecognitionState'); } + static Future getRecognizeConfiguration() { + return _invokeRecognizeConfiguration('getRecognizeConfiguration'); + } + + static Future setRecognizeConfiguration( + FaceRecognitionConfiguration configuration, + ) { + return _invokeRecognizeConfiguration( + 'setRecognizeConfiguration', + configuration.toMap(), + ); + } + + static Future resetRecognizeConfiguration() { + return _invokeRecognizeConfiguration('resetRecognizeConfiguration'); + } + static Stream get sessionStream { _sessionStream ??= _sessionEventChannel .receiveBroadcastStream() @@ -111,6 +128,15 @@ abstract class FaceDiscernPlugs { return FaceRecognitionSessionEvent.fromMap(map); } + static Future _invokeRecognizeConfiguration( + String method, [ + Map? arguments, + ]) async { + final map = await _channel.invokeMethod(method, arguments); + if (map is! Map) throw StateError('原生未返回有效的人脸识别配置'); + return FaceRecognitionConfiguration.fromMap(map); + } + static Future getConfig() async { try { final Map? map = diff --git a/lib/common/widgets/app/terminal_device_status.dart b/lib/common/widgets/app/terminal_device_status.dart index 060f2f1..e14d34a 100644 --- a/lib/common/widgets/app/terminal_device_status.dart +++ b/lib/common/widgets/app/terminal_device_status.dart @@ -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() + ? Get.find() + : 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, diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 93486cf..290a266 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -296,7 +296,7 @@ class _TerminalNetworkStatusState extends State { 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 { ), 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 { 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()], ), ), diff --git a/lib/common/widgets/face/face_recognition_preview_dialog.dart b/lib/common/widgets/face/face_recognition_preview_dialog.dart index 870131f..71ab678 100644 --- a/lib/common/widgets/face/face_recognition_preview_dialog.dart +++ b/lib/common/widgets/face/face_recognition_preview_dialog.dart @@ -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; diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index 8f83521..1254cb2 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -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 recognized(String value) async { if (_closed || generation != _requestGeneration || _recognitionLocked) { diff --git a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart index 4b11963..7267cc5 100644 --- a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart @@ -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(); diff --git a/lib/pages/terminal/setting/face_setting_controller.dart b/lib/pages/terminal/setting/face_setting_controller.dart index e1475cc..cb91f84 100644 --- a/lib/pages/terminal/setting/face_setting_controller.dart +++ b/lib/pages/terminal/setting/face_setting_controller.dart @@ -21,6 +21,11 @@ class FaceSettingController extends GetxController { final errorMessage = ''.obs; final permissionCanOpenSettings = false.obs; final showFullParameters = false.obs; + final recognitionConfiguration = Rxn(); + final savedRecognitionConfiguration = Rxn(); + final isLoadingRecognitionConfiguration = false.obs; + final isSavingRecognitionConfiguration = false.obs; + final showAdvancedRecognitionConfiguration = false.obs; StreamSubscription? _progressSubscription; @@ -34,6 +39,7 @@ class FaceSettingController extends GetxController { }, ); refreshStatus(); + refreshRecognitionConfiguration(); } @override @@ -63,6 +69,96 @@ class FaceSettingController extends GetxController { } } + Future 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 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() && + Get.find().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 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() && + Get.find().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 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; diff --git a/lib/pages/terminal/setting/widgets/setting_face_plugin.dart b/lib/pages/terminal/setting/widgets/setting_face_plugin.dart index 96cf56d..4bb7be1 100644 --- a/lib/pages/terminal/setting/widgets/setting_face_plugin.dart +++ b/lib/pages/terminal/setting/widgets/setting_face_plugin.dart @@ -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 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 options; + final bool enabled; + final ValueChanged 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}); diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index cab79b1..7f9a465 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -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); diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 01101a2..dbe9195 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -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 = Function(String)>[]; final cardFailures = []; + final faceCallbacks = Function(String)>[]; + final faceFailures = []; int stopAllCalls = 0; Future emitCardAt(int index, String value) { return cardCallbacks[index](value); } + Future emitFaceAt(int index, String value) { + return faceCallbacks[index](value); + } + @override Future startCard({ required Future Function(String cardNumber) onRecognized, @@ -188,7 +227,10 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway { Future startFace({ required Future Function(String userId) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, - }) async {} + }) async { + faceCallbacks.add(onRecognized); + faceFailures.add(onFailure); + } @override Future stopAll() async { diff --git a/test/sub_screen/reservation_query_coordinator_test.dart b/test/sub_screen/reservation_query_coordinator_test.dart index 5d37447..4d6721a 100644 --- a/test/sub_screen/reservation_query_coordinator_test.dart +++ b/test/sub_screen/reservation_query_coordinator_test.dart @@ -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 _reservationEvent({