feat:摄像头选择优化
This commit is contained in:
1 parent
c6f9ffb998
commit
ef75bb918f
26 files changed
+2661
-495
No files matched your search
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
|
||||
## flutter打包
|
||||
|
||||
```
|
||||
flutter build apk --build-name 2.1.9 --build-number 219
|
||||
flutter build apk --build-name 2.2.0 --build-number 220
|
||||
--build-name为版本号(展示)
|
||||
--build-number为版本号(内部比较)
|
||||
```
|
||||
|
||||
+67
-9
@@ -27,6 +27,7 @@ import io.flutter.plugin.common.EventChannel;
|
||||
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.camera.config.CameraSelectionStore;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
|
||||
import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer;
|
||||
@@ -160,6 +161,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
qualityAuditEventSink = null;
|
||||
unregisterLegacyReceiver();
|
||||
if (sessionManager != null) {
|
||||
sessionManager.releaseCameraSelectionForOwner(this);
|
||||
for (Long outputId : previewOutputs.values().toArray(new Long[0])) {
|
||||
sessionManager.releasePreviewTexture(outputId);
|
||||
}
|
||||
@@ -260,6 +262,37 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
case "hasCamera":
|
||||
result.success(sessionManager.hasCamera());
|
||||
break;
|
||||
case "getCameraConfiguration":
|
||||
case "refreshCameraList":
|
||||
try {
|
||||
result.success("refreshCameraList".equals(method)
|
||||
? CameraSelectionStore.refresh(context) : CameraSelectionStore.load(context));
|
||||
} catch (Exception e) {
|
||||
result.error("CAMERA_LIST_FAILED", "读取或保存摄像头列表失败,请重试", e.getMessage());
|
||||
}
|
||||
break;
|
||||
case "startCameraSelectionPreview":
|
||||
try {
|
||||
requireMainCameraSettingsEndpoint();
|
||||
result.success(sessionManager.startCameraSelectionPreview(stringArgument(call, "previewSessionId"), optionalCameraId(call), this, textureRegistry));
|
||||
} catch (SecurityException e) {
|
||||
result.error("CAMERA_PERMISSION_DENIED", e.getMessage(), null);
|
||||
} catch (Exception e) {
|
||||
result.error("CAMERA_PREVIEW_FAILED", "无法预览摄像头:" + e.getMessage(), null);
|
||||
}
|
||||
break;
|
||||
case "stopCameraSelectionPreview":
|
||||
sessionManager.stopCameraSelectionPreview(stringArgument(call, "previewSessionId"), this);
|
||||
result.success(true);
|
||||
break;
|
||||
case "setPrimaryCamera":
|
||||
try {
|
||||
requireMainCameraSettingsEndpoint();
|
||||
result.success(sessionManager.savePrimaryCamera(stringArgument(call, "previewSessionId"), optionalCameraId(call), this));
|
||||
} catch (Exception e) {
|
||||
result.error("SAVE_PRIMARY_CAMERA_FAILED", e.getMessage(), null);
|
||||
}
|
||||
break;
|
||||
case "getCameraChoices":
|
||||
// 探测会短暂打开每个摄像头,必须串行进入人脸执行器且不占用主线程。
|
||||
FACE_EXECUTOR.execute(() -> {
|
||||
@@ -273,18 +306,26 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
});
|
||||
break;
|
||||
case "setCameraSelection":
|
||||
Number selectedCameraId = call.argument("cameraId");
|
||||
if (selectedCameraId == null) {
|
||||
result.error("INVALID_CAMERA_SELECTION", "缺少摄像头编号", null);
|
||||
break;
|
||||
try {
|
||||
requireMainCameraSettingsEndpoint();
|
||||
Number selectedCameraId = call.argument("cameraId");
|
||||
if (selectedCameraId == null) throw new IllegalArgumentException("缺少摄像头编号");
|
||||
if (selectedCameraId.intValue() >= 0) {
|
||||
CameraSelectionStore.resolveCameraId(CameraSelectionStore.enumerateCameras(), selectedCameraId.intValue(), false);
|
||||
}
|
||||
FlutterTool.setCameraSelection(context, selectedCameraId.intValue());
|
||||
sessionManager.onCameraSelectionChanged();
|
||||
result.success(true);
|
||||
} catch (Exception e) {
|
||||
result.error("SAVE_CAMERA_SELECTION_FAILED", e.getMessage(), null);
|
||||
}
|
||||
FlutterTool.setCameraSelection(context, selectedCameraId.intValue());
|
||||
// 空闲时立即释放热相机让下次识别按新编号打开;识别中延迟到会话结束。
|
||||
sessionManager.onCameraSelectionChanged();
|
||||
result.success(true);
|
||||
break;
|
||||
case "getCameraSelection":
|
||||
result.success(FlutterTool.getCameraSelection(context));
|
||||
try {
|
||||
result.success(FlutterTool.getCameraSelection(context));
|
||||
} catch (Exception e) {
|
||||
result.error("CAMERA_SELECTION_FAILED", "读取摄像头选择失败,请重试", null);
|
||||
}
|
||||
break;
|
||||
case "cleanupOrderEvidenceFiles":
|
||||
List<?> rawReferencedPaths = call.argument("referencedPaths");
|
||||
@@ -636,6 +677,23 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置入口仅由主屏持有,副屏 Engine 不得抢占候选相机或修改本机偏好。 */
|
||||
private void requireMainCameraSettingsEndpoint() {
|
||||
if (!"main".equals(engineEndpoint)) throw new IllegalStateException("请在主屏设置中选择摄像头");
|
||||
}
|
||||
|
||||
/** null 表示自动选择;通道中的小数、负数和溢出值不能转换成另一个有效相机编号。 */
|
||||
private Integer optionalCameraId(MethodCall call) {
|
||||
Object value = call.argument("cameraId");
|
||||
if (value == null) return null;
|
||||
if (!(value instanceof Number)) throw new IllegalArgumentException("摄像头编号必须为整数");
|
||||
double number = ((Number) value).doubleValue();
|
||||
if (Double.isNaN(number) || number < 0 || number > Integer.MAX_VALUE || number != Math.floor(number)) {
|
||||
throw new IllegalArgumentException("摄像头编号必须为非负整数");
|
||||
}
|
||||
return (int) number;
|
||||
}
|
||||
|
||||
private String stringArgument(MethodCall call, String key) {
|
||||
Object value = call.argument(key);
|
||||
return value == null ? "" : value.toString();
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package io.flutter.plugins.facediscern.session;
|
||||
|
||||
import android.content.Context;
|
||||
import android.hardware.Camera;
|
||||
import android.view.Surface;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.flutter.view.TextureRegistry;
|
||||
|
||||
/** 设置专用的单相机预览;只产生纹理,不创建虹软引擎、识别回调或业务会话。 */
|
||||
@SuppressWarnings("deprecation")
|
||||
final class CameraSelectionPreview {
|
||||
/** 设置预览目标宽度,单位像素;设备不支持时按比例和面积选择已有分辨率。 */
|
||||
private static final int TARGET_WIDTH = 1280;
|
||||
/** 设置预览目标高度,单位像素。 */
|
||||
private static final int TARGET_HEIGHT = 720;
|
||||
/** 当前 Engine 创建的纹理,成功附加后由 Renderer 统一释放。 */
|
||||
private final TextureRegistry.SurfaceTextureEntry texture;
|
||||
/** 与业务预览共用渲染实现,但持有独立实例,避免候选画面流入副屏。 */
|
||||
private final FacePreviewRenderer renderer;
|
||||
/** 已经打开的实际 Camera1 编号,不使用 FRONT/BACK 常量替代编号。 */
|
||||
final int cameraId;
|
||||
/** 当前相机句柄,关闭后为 null。 */
|
||||
private Camera camera;
|
||||
/** 纹理是否已交给 Renderer,决定异常清理时由谁释放。 */
|
||||
private boolean outputAttached;
|
||||
/** 实际预览宽度,单位像素。 */
|
||||
private int width;
|
||||
/** 实际预览高度,单位像素。 */
|
||||
private int height;
|
||||
/** 与业务链相同的显示旋转角度,单位度。 */
|
||||
private int rotation;
|
||||
/** 是否对前置画面做水平镜像。 */
|
||||
private boolean mirror;
|
||||
/** 相机断开后立即置为 false,防止保存一份已经失效的预览。 */
|
||||
private volatile boolean healthy;
|
||||
|
||||
/** 打开候选相机并创建输出;任一步骤失败都释放 Camera、Texture 与渲染线程。 */
|
||||
CameraSelectionPreview(Context context, TextureRegistry registry, int cameraId, Runnable onCameraError) throws Exception {
|
||||
this.cameraId = cameraId;
|
||||
texture = registry.createSurfaceTexture();
|
||||
try {
|
||||
renderer = new FacePreviewRenderer();
|
||||
} catch (RuntimeException error) {
|
||||
texture.release();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(cameraId, info);
|
||||
mirror = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT;
|
||||
WindowManager manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
|
||||
int display = manager == null ? Surface.ROTATION_0 : manager.getDefaultDisplay().getRotation();
|
||||
int degrees = display == Surface.ROTATION_90 ? 90 : display == Surface.ROTATION_180 ? 180 : display == Surface.ROTATION_270 ? 270 : 0;
|
||||
rotation = mirror ? (360 - (info.orientation + degrees) % 360) % 360 : (info.orientation - degrees + 360) % 360;
|
||||
camera = Camera.open(cameraId);
|
||||
Camera.Parameters parameters = camera.getParameters();
|
||||
Camera.Size size = chooseSize(parameters.getSupportedPreviewSizes(), parameters.getPreviewSize());
|
||||
width = size.width;
|
||||
height = size.height;
|
||||
parameters.setPreviewSize(width, height);
|
||||
camera.setParameters(parameters);
|
||||
camera.setDisplayOrientation(0);
|
||||
renderer.setPreviewSize(width, height);
|
||||
renderer.addOutput(texture);
|
||||
outputAttached = true;
|
||||
camera.setPreviewTexture(renderer.getCameraTexture());
|
||||
camera.setErrorCallback((error, failedCamera) -> {
|
||||
healthy = false;
|
||||
onCameraError.run();
|
||||
});
|
||||
camera.startPreview();
|
||||
healthy = true;
|
||||
} catch (Exception error) {
|
||||
close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回设置可展示的实际纹理与方向元数据,sessionId 由独占资源管理器追加。 */
|
||||
Map<String, Object> metadata() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("textureId", texture.id());
|
||||
result.put("cameraId", cameraId);
|
||||
result.put("width", width);
|
||||
result.put("height", height);
|
||||
result.put("rotation", rotation);
|
||||
result.put("mirror", mirror);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 仅允许保存仍持有相机且没有发生断开错误的候选。 */
|
||||
boolean isHealthy() {
|
||||
return healthy && camera != null;
|
||||
}
|
||||
|
||||
/** 幂等释放设置资源;Camera 停止失败也继续释放 EGL 和 Flutter 输出。 */
|
||||
void close() {
|
||||
healthy = false;
|
||||
Camera released = camera;
|
||||
camera = null;
|
||||
if (released != null) {
|
||||
try {
|
||||
released.setErrorCallback(null);
|
||||
released.stopPreview();
|
||||
} catch (RuntimeException ignored) {
|
||||
// 拔出设备时 stopPreview 可能抛错,仍必须继续 release。
|
||||
}
|
||||
try {
|
||||
released.release();
|
||||
} catch (RuntimeException ignored) {
|
||||
// 设备服务已断开时不阻断后续纹理释放。
|
||||
}
|
||||
}
|
||||
renderer.release();
|
||||
if (!outputAttached) {
|
||||
texture.release();
|
||||
outputAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用业务预览相同的比例优先策略,避免设置与识别画面因不同尺寸出现方向判断差异。 */
|
||||
private static Camera.Size chooseSize(List<Camera.Size> sizes, Camera.Size fallback) {
|
||||
if (sizes == null || sizes.isEmpty()) return fallback;
|
||||
Camera.Size best = sizes.get(0);
|
||||
double score = Double.MAX_VALUE;
|
||||
long area = (long) TARGET_WIDTH * TARGET_HEIGHT;
|
||||
for (Camera.Size size : sizes) {
|
||||
double value = Math.abs(size.width / (double) size.height - TARGET_WIDTH / (double) TARGET_HEIGHT) * area * 4
|
||||
+ Math.abs((long) size.width * size.height - area);
|
||||
if (value < score) {
|
||||
best = size;
|
||||
score = value;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
+156
-34
@@ -36,7 +36,6 @@ import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus;
|
||||
import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore;
|
||||
import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig;
|
||||
import io.flutter.plugins.facediscern.ui.viewmodel.FaceDiscernViewModel;
|
||||
import io.flutter.view.TextureRegistry;
|
||||
|
||||
@@ -141,6 +140,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
private Handler warmReleaseHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable warmReleaseTask = this::releaseWarmResources;
|
||||
|
||||
/** 设置候选的独立纹理与 Camera;有值时禁止启动业务识别。 */
|
||||
private CameraSelectionPreview cameraSelectionPreview;
|
||||
/** 当前设置预览的唯一编号,用于拒绝迟到的关闭、保存及错误回调。 */
|
||||
private String cameraSelectionSessionId;
|
||||
/** 独占设置预览的 Flutter 插件端点,副屏或旧弹框不得释放它。 */
|
||||
private Listener cameraSelectionOwner;
|
||||
/** 当前候选的人工编号;null 表示预览默认规则解析出的设备。 */
|
||||
private Integer cameraSelectionRequestedId;
|
||||
/** 保留原生 Activity 的相机所有者,避免旧版入口与新会话、设置预览同时开相机。 */
|
||||
private Object legacyCameraOwner;
|
||||
/** 当前打开时发现的人工主摄失联提示;正常选择时为空字符串。 */
|
||||
private String cameraWarning = "";
|
||||
|
||||
private FaceRecognitionSessionManager(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
@@ -168,6 +180,132 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置预览独占 Camera,但不创建虹软引擎;相同端点可用新编号替换自己的旧候选。 */
|
||||
public Map<String, Object> startCameraSelectionPreview(String previewSessionId, Integer requestedId, Listener owner, TextureRegistry registry) throws Exception {
|
||||
if (previewSessionId == null || previewSessionId.isEmpty()) throw new IllegalArgumentException("缺少摄像头预览编号");
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null || legacyCameraOwner != null) throw new IllegalStateException("请先结束当前人脸识别,再选择主摄");
|
||||
if (cameraSelectionOwner != null && cameraSelectionOwner != owner) throw new IllegalStateException("其他页面正在预览摄像头,请稍后重试");
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
throw new SecurityException("未获得相机权限,请授权后重试");
|
||||
}
|
||||
int selectedId = CameraSelectionStore.resolveCameraId(CameraSelectionStore.enumerateCameras(), requestedId, false);
|
||||
releaseCameraSelectionForOwner(owner);
|
||||
// 旧 Renderer 的主副屏输出会冷释放;下一次业务识别按既有冷启动路径重新创建。
|
||||
releaseResources(true, true);
|
||||
synchronized (sessionLock) {
|
||||
cameraSelectionSessionId = previewSessionId;
|
||||
cameraSelectionOwner = owner;
|
||||
cameraSelectionRequestedId = requestedId;
|
||||
}
|
||||
try {
|
||||
CameraSelectionPreview preview = new CameraSelectionPreview(context, registry, selectedId,
|
||||
() -> mainHandler.post(() -> failCameraSelectionPreview(previewSessionId, "摄像头连接异常,请重新预览或刷新列表")));
|
||||
synchronized (sessionLock) {
|
||||
cameraSelectionPreview = preview;
|
||||
}
|
||||
Map<String, Object> result = preview.metadata();
|
||||
result.put("previewSessionId", previewSessionId);
|
||||
return result;
|
||||
} catch (Exception error) {
|
||||
stopCameraSelectionPreview(previewSessionId, owner);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅释放编号和所有者都匹配的设置候选,旧弹框的迟到关闭不会影响新预览。 */
|
||||
public void stopCameraSelectionPreview(String previewSessionId, Listener owner) {
|
||||
CameraSelectionPreview preview;
|
||||
synchronized (sessionLock) {
|
||||
if (!Objects.equals(cameraSelectionSessionId, previewSessionId) || cameraSelectionOwner != owner) return;
|
||||
preview = cameraSelectionPreview;
|
||||
cameraSelectionPreview = null;
|
||||
cameraSelectionSessionId = null;
|
||||
cameraSelectionOwner = null;
|
||||
cameraSelectionRequestedId = null;
|
||||
}
|
||||
if (preview != null) preview.close();
|
||||
}
|
||||
|
||||
/** 保存仍在本端点预览的候选;先提交偏好再关闭,写入失败保留画面供用户重试。 */
|
||||
public Map<String, Object> savePrimaryCamera(String previewSessionId, Integer requestedId, Listener owner) {
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null || legacyCameraOwner != null) throw new IllegalStateException("请先结束当前人脸识别,再保存主摄");
|
||||
if (cameraSelectionPreview == null || cameraSelectionOwner != owner
|
||||
|| !Objects.equals(cameraSelectionSessionId, previewSessionId)
|
||||
|| !Objects.equals(cameraSelectionRequestedId, requestedId) || !cameraSelectionPreview.isHealthy()) {
|
||||
throw new IllegalStateException("摄像头预览已结束,请重新预览后保存");
|
||||
}
|
||||
int currentId = CameraSelectionStore.resolveCameraId(CameraSelectionStore.enumerateCameras(), requestedId, false);
|
||||
if (currentId != cameraSelectionPreview.cameraId) throw new IllegalStateException("摄像头列表已变化,请重新预览后保存");
|
||||
}
|
||||
Map<String, Object> saved = CameraSelectionStore.savePrimaryCamera(context, requestedId);
|
||||
stopCameraSelectionPreview(previewSessionId, owner);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Engine 销毁时只释放该端点拥有的候选,业务 Texture 的释放仍走原有通道。 */
|
||||
public void releaseCameraSelectionForOwner(Listener owner) {
|
||||
String previewSessionId;
|
||||
synchronized (sessionLock) {
|
||||
if (cameraSelectionOwner != owner) return;
|
||||
previewSessionId = cameraSelectionSessionId;
|
||||
}
|
||||
stopCameraSelectionPreview(previewSessionId, owner);
|
||||
}
|
||||
|
||||
/** 后台、低内存或硬件断开时收回设置相机,并向所属预览发送可恢复的错误状态。 */
|
||||
public void cancelCameraSelectionPreview(String message) {
|
||||
String previewSessionId;
|
||||
synchronized (sessionLock) {
|
||||
previewSessionId = cameraSelectionSessionId;
|
||||
}
|
||||
if (previewSessionId != null) failCameraSelectionPreview(previewSessionId, message);
|
||||
}
|
||||
|
||||
/** 回收指定候选并发送带预览编号的错误,正常业务会话会忽略这条非业务事件。 */
|
||||
private void failCameraSelectionPreview(String previewSessionId, String message) {
|
||||
Listener owner;
|
||||
synchronized (sessionLock) {
|
||||
if (!Objects.equals(previewSessionId, cameraSelectionSessionId)) return;
|
||||
owner = cameraSelectionOwner;
|
||||
}
|
||||
stopCameraSelectionPreview(previewSessionId, owner);
|
||||
Map<String, Object> event;
|
||||
synchronized (sessionLock) {
|
||||
event = snapshot();
|
||||
event.put("eventSequence", ++eventSequence);
|
||||
}
|
||||
event.put("cameraPreviewSessionId", previewSessionId);
|
||||
event.put("message", message);
|
||||
event.put("errorCode", "CAMERA_PREVIEW_ENDED");
|
||||
owner.onSessionEvent(event);
|
||||
}
|
||||
|
||||
/** 旧原生页面获得相机独占权时释放空闲热相机,并统一使用本机主摄偏好。 */
|
||||
public int acquireLegacyCamera(Object owner) {
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null || cameraSelectionSessionId != null
|
||||
|| (legacyCameraOwner != null && legacyCameraOwner != owner)) {
|
||||
throw new IllegalStateException("摄像头正在使用中,请结束当前识别或预览");
|
||||
}
|
||||
}
|
||||
int selectedId = CameraSelectionStore.resolvePrimaryCameraId(context);
|
||||
releaseResources(true, true);
|
||||
synchronized (sessionLock) {
|
||||
legacyCameraOwner = owner;
|
||||
}
|
||||
return selectedId;
|
||||
}
|
||||
|
||||
/** 旧页面实际释放 Camera 后解除独占,页面暂停但仍占用硬件时不能提前解除。 */
|
||||
public void releaseLegacyCamera(Object owner) {
|
||||
synchronized (sessionLock) {
|
||||
if (legacyCameraOwner == owner) legacyCameraOwner = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超过保留截止时间且没有被 Flutter 本地队列引用的现场图。
|
||||
*
|
||||
@@ -197,6 +335,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
result.put("height", previewSize == null ? TARGET_HEIGHT : previewSize.height);
|
||||
result.put("rotation", displayOrientation);
|
||||
result.put("mirror", mirror);
|
||||
result.put("cameraId", camera == null ? null : cameraId);
|
||||
return result;
|
||||
}
|
||||
} catch (Exception error) {
|
||||
@@ -218,6 +357,9 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
Log.i(TAG, "发起原生人脸会话 sessionId=" + sessionId + ";scene=" + scene);
|
||||
boolean reloadConfiguration;
|
||||
synchronized (sessionLock) {
|
||||
if (cameraSelectionSessionId != null || legacyCameraOwner != null) {
|
||||
throw new IllegalStateException("摄像头正在设置预览或旧版页面中使用,请先关闭该页面");
|
||||
}
|
||||
if (activeSessionId != null && !Objects.equals(activeSessionId, sessionId)) {
|
||||
throw new SessionBusyException(activeSessionId);
|
||||
}
|
||||
@@ -338,6 +480,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
|
||||
public Map<String, Object> suspendRecognition(String reason) {
|
||||
cancelCameraSelectionPreview("应用已暂停,请重新预览摄像头");
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null) {
|
||||
state = State.CANCELLED;
|
||||
@@ -380,14 +523,13 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
* 人工切换识别摄像头后让下次识别按新编号开相机;活动识别延迟到当前顾客结束。
|
||||
*/
|
||||
public void onCameraSelectionChanged() {
|
||||
boolean releaseNow;
|
||||
synchronized (sessionLock) {
|
||||
releaseNow = activeSessionId == null;
|
||||
}
|
||||
if (releaseNow) releaseResources(true, true);
|
||||
// 兼容旧保存通道:活动会话结束后必须释放旧相机,不能再复用 20 分钟。
|
||||
onRecognitionConfigurationChanged();
|
||||
}
|
||||
|
||||
/** 低内存同时收回设置预览和业务相机,避免残留独占锁。 */
|
||||
public void releaseForLowMemory() {
|
||||
cancelCameraSelectionPreview("系统资源不足,请重新预览摄像头");
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null) {
|
||||
state = State.FAILED;
|
||||
@@ -443,7 +585,10 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void openCamera() throws Exception {
|
||||
cameraId = findRgbCameraId();
|
||||
cameraId = CameraSelectionStore.resolvePrimaryCameraId(context);
|
||||
Integer requestedCameraId = CameraSelectionStore.primaryCameraId(context);
|
||||
cameraWarning = requestedCameraId != null && requestedCameraId != cameraId
|
||||
? "已选摄像头 " + requestedCameraId + " 已失联,临时使用默认摄像头 " + cameraId : "";
|
||||
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(cameraId, info);
|
||||
mirror = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT;
|
||||
@@ -959,6 +1104,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
processor = null;
|
||||
previewRenderer = null;
|
||||
previewSize = null;
|
||||
cameraId = -1;
|
||||
cameraWarning = "";
|
||||
warmResourcesReady = false;
|
||||
recognitionConfigurationDirty = false;
|
||||
clearFaceBoxCacheLocked();
|
||||
@@ -1050,6 +1197,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
result.put("rotation", displayOrientation);
|
||||
result.put("mirror", mirror);
|
||||
result.put("warm", warmResourcesReady);
|
||||
result.put("cameraId", camera == null ? null : cameraId);
|
||||
result.put("cameraWarning", cameraWarning);
|
||||
FaceDiscernViewModel currentProcessor = processor;
|
||||
result.put("livenessEnabled", currentProcessor != null && currentProcessor.isLivenessEnabled());
|
||||
result.put("keepMaxFace", currentProcessor != null && currentProcessor.isKeepMaxFaceEnabled());
|
||||
@@ -1074,33 +1223,6 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private int findRgbCameraId() {
|
||||
int cameraCount = Camera.getNumberOfCameras();
|
||||
// 双目终端存在厂家把彩色/红外排线装反的情况,facing 枚举角色不可信;
|
||||
// 设置页人工指定的摄像头编号永远优先。
|
||||
int manualId = CameraSelectionStore.loadRgbCameraId(context);
|
||||
if (manualId >= 0 && manualId < cameraCount) {
|
||||
Log.i(TAG, "使用人工选择的识别摄像头 cameraId=" + manualId + ";cameraCount=" + cameraCount);
|
||||
return manualId;
|
||||
}
|
||||
int fallback = -1;
|
||||
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||
for (int i = 0; i < cameraCount; i++) {
|
||||
Camera.getCameraInfo(i, info);
|
||||
if (fallback < 0) fallback = i;
|
||||
// 原虹软插件的 PreviewConfig 明确约定 BACK 为 RGB、FRONT 为 IR。
|
||||
// 双目终端的 facing 表示设备枚举角色而非用户朝向,不能再把 FRONT 当作彩色相机。
|
||||
if (info.facing == PreviewConfig.DEFAULT_RGB_CAMERA_ID) {
|
||||
Log.i(TAG, "选择彩色 RGB 摄像头 cameraId=" + i + ";facing=BACK;cameraCount=" + cameraCount);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
if (fallback < 0) throw new IllegalStateException("未发现可用摄像头");
|
||||
Log.w(TAG, "未发现约定的 BACK RGB 摄像头,回退 cameraId=" + fallback + ";cameraCount=" + cameraCount);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private Camera.Size choosePreviewSize(List<Camera.Size> sizes) {
|
||||
if (sizes == null || sizes.isEmpty()) return camera.getParameters().getPreviewSize();
|
||||
|
||||
+57
-35
@@ -13,16 +13,15 @@ import android.view.View;
|
||||
|
||||
import io.flutter.plugins.facediscern.tool.camera.callback.CameraListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 打开两个相机的辅助类,和{@link CameraListener}共同使用,获取nv21数据等操作
|
||||
* Camera1 预览辅助类,和 {@link CameraListener} 共同获取选定主摄的 NV21 数据。
|
||||
* <p>
|
||||
* 由于IR摄像头和RGB摄像头的默认分辨率可能不同,为了让两者相同,该类做了以下操作:
|
||||
* 保留历史双目尺寸交集查询供兼容调用;当前单摄识别打开主摄时只读取该设备的尺寸,不探测另一台相机。
|
||||
* 1. 获取两者支持的分辨率列表到到静态变量{@link DualCameraHelper#rgbSupportedPreviewSizes}及{@link DualCameraHelper#irSupportedPreviewSizes}中,
|
||||
* 2. 使用{@link DualCameraHelper#getCommonSupportedPreviewSize()}方法取分辨率的交集,
|
||||
* 3. 使用{@link DualCameraHelper#getBestSupportedSize(List, Point)}取最佳分辨率使两个摄像头分辨率尽可能相同
|
||||
@@ -62,6 +61,7 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定视图回调并重置镜像,避免切回后置主摄后沿用前置设备的翻转。 */
|
||||
public void init() {
|
||||
if (previewDisplayView instanceof TextureView) {
|
||||
((TextureView) this.previewDisplayView).setSurfaceTextureListener(textureListener);
|
||||
@@ -69,9 +69,7 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
((SurfaceView) previewDisplayView).getHolder().addCallback(surfaceCallback);
|
||||
}
|
||||
|
||||
if (isMirror) {
|
||||
previewDisplayView.setScaleX(-1);
|
||||
}
|
||||
previewDisplayView.setScaleX(isMirror ? -1 : 1);
|
||||
}
|
||||
|
||||
public List<Camera.Size> getCommonSupportedPreviewSize() {
|
||||
@@ -120,32 +118,29 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
return mCameraId;
|
||||
}
|
||||
|
||||
/** 只打开选定主摄并读取其尺寸,编号失效或硬件异常时通过监听器明确报错。 */
|
||||
public void start() {
|
||||
synchronized (this) {
|
||||
// release 已解绑视图,迟到的 Surface 回调不得再次打开硬件。
|
||||
if (previewDisplayView == null) return;
|
||||
if (mCamera != null) {
|
||||
return;
|
||||
}
|
||||
List<Camera.Size> supportedPreviewSize = getCommonSupportedPreviewSize();
|
||||
//相机数量为2则打开1,1则打开0,相机ID 1为前置,0为后置
|
||||
mCameraId = Camera.getNumberOfCameras() - 1;
|
||||
//若指定了相机ID且该相机存在,则打开指定的相机
|
||||
if (specificCameraId != null && specificCameraId <= mCameraId) {
|
||||
mCameraId = specificCameraId;
|
||||
}
|
||||
//没有相机
|
||||
if (mCameraId == -1) {
|
||||
if (cameraListener != null) {
|
||||
cameraListener.onCameraError(new Exception("camera not found"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mCamera == null) {
|
||||
mCamera = Camera.open(mCameraId);
|
||||
}
|
||||
displayOrientation = getCameraOri(rotation);
|
||||
mCamera.setDisplayOrientation(displayOrientation);
|
||||
try {
|
||||
// 未指定编号的兼容调用仍使用最后一个设备,业务入口总会提供已解析的主摄编号。
|
||||
mCameraId = Camera.getNumberOfCameras() - 1;
|
||||
if (specificCameraId != null) {
|
||||
if (specificCameraId < 0 || specificCameraId > mCameraId) {
|
||||
throw new IllegalStateException("所选摄像头已失联,请刷新主摄列表");
|
||||
}
|
||||
mCameraId = specificCameraId;
|
||||
}
|
||||
if (mCameraId < 0) throw new IllegalStateException("未发现可用摄像头");
|
||||
mCamera = Camera.open(mCameraId);
|
||||
displayOrientation = getCameraOri(rotation);
|
||||
mCamera.setDisplayOrientation(displayOrientation);
|
||||
Camera.Parameters parameters = mCamera.getParameters();
|
||||
List<Camera.Size> supportedPreviewSize = parameters.getSupportedPreviewSizes();
|
||||
parameters.setPreviewFormat(ImageFormat.NV21);
|
||||
//预览大小设置
|
||||
previewSize = parameters.getPreviewSize();
|
||||
@@ -268,16 +263,32 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放操作
|
||||
* 释放硬件和预览视图监听;相机尚未打开或已经断开时也必须解除回调,防止退出后迟到的 Surface 再次开相机。
|
||||
*/
|
||||
public void release() {
|
||||
synchronized (this) {
|
||||
stop();
|
||||
try {
|
||||
stop();
|
||||
} catch (RuntimeException error) {
|
||||
Log.w(TAG, "停止相机失败,继续释放资源", error);
|
||||
}
|
||||
if (previewDisplayView instanceof TextureView) {
|
||||
TextureView view = (TextureView) previewDisplayView;
|
||||
if (view.getSurfaceTextureListener() == textureListener) view.setSurfaceTextureListener(null);
|
||||
} else if (previewDisplayView instanceof SurfaceView) {
|
||||
((SurfaceView) previewDisplayView).getHolder().removeCallback(surfaceCallback);
|
||||
}
|
||||
if (cameraListener != null) {
|
||||
cameraListener.onCameraClosed();
|
||||
}
|
||||
mCamera.release();
|
||||
mCamera = null;
|
||||
if (mCamera != null) {
|
||||
try {
|
||||
mCamera.release();
|
||||
} catch (RuntimeException error) {
|
||||
Log.w(TAG, "相机已断开,继续清理视图", error);
|
||||
}
|
||||
mCamera = null;
|
||||
}
|
||||
previewDisplayView = null;
|
||||
specificCameraId = null;
|
||||
cameraListener = null;
|
||||
@@ -375,12 +386,18 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
private TextureView.SurfaceTextureListener textureListener = new TextureView.SurfaceTextureListener() {
|
||||
@Override
|
||||
public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) {
|
||||
// start();
|
||||
// 页面恢复时 Camera 已释放;Surface 就绪后才真正重新打开。
|
||||
if (mCamera == null) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
if (mCamera != null) {
|
||||
try {
|
||||
mCamera.setPreviewTexture(surfaceTexture);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
// Surface 销毁时只暂停了相机,重新绑定后必须恢复预览帧。
|
||||
resumePreview();
|
||||
} catch (Exception e) {
|
||||
if (cameraListener != null) cameraListener.onCameraError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,12 +421,17 @@ public class DualCameraHelper implements Camera.PreviewCallback {
|
||||
private SurfaceHolder.Callback surfaceCallback = new SurfaceHolder.Callback() {
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
// start();
|
||||
// 页面恢复时 Camera 已释放;Surface 就绪后才真正重新打开。
|
||||
if (mCamera == null) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
if (mCamera != null) {
|
||||
try {
|
||||
mCamera.setPreviewDisplay(holder);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
resumePreview();
|
||||
} catch (Exception e) {
|
||||
if (cameraListener != null) cameraListener.onCameraError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
-19
@@ -2,39 +2,185 @@ package io.flutter.plugins.facediscern.tool.camera.config;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.hardware.Camera;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* 保存终端本机的彩色识别摄像头人工选择。
|
||||
*
|
||||
* 双目人脸设备的彩色/红外摄像头只靠 facing 枚举角色区分,厂家排线装反时
|
||||
* 枚举为 BACK 的摄像头实际输出红外黑白画面,元数据层面无法判定,因此提供
|
||||
* 人工指定入口。-1 表示自动(维持按 facing 枚举选择的既有行为)。
|
||||
*/
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** 本机摄像头目录与主摄偏好;独立于业务数据库及虹软识别策略,重启后仍保留。 */
|
||||
@SuppressWarnings("deprecation")
|
||||
public final class CameraSelectionStore {
|
||||
/** 本机相机配置文件,不随服务端配置同步或识别策略重置而清除。 */
|
||||
private static final String PREFERENCES_NAME = "face_camera_selection";
|
||||
private static final String KEY_RGB_CAMERA_ID = "rgb_camera_id";
|
||||
/** 旧版和自动模式的持久化值,必须保持 -1 以兼容已安装终端。 */
|
||||
public static final int AUTO_CAMERA_ID = -1;
|
||||
/** 完整枚举快照的 JSON 数组键;空数组表示最近确实未发现相机。 */
|
||||
private static final String CAMERAS_KEY = "cameras";
|
||||
/** 沿用旧版人工选择键;缺少或 -1 表示自动,升级不得改名。 */
|
||||
private static final String PRIMARY_CAMERA_KEY = "rgb_camera_id";
|
||||
/** 最近一次成功枚举并持久化的 Unix 毫秒时间。 */
|
||||
private static final String REFRESHED_AT_KEY = "refreshedAt";
|
||||
/** 只记录编号和回退原因,不记录相机画面或人脸数据。 */
|
||||
private static final String TAG = "CameraConfiguration";
|
||||
|
||||
/** 配置由静态入口管理,禁止创建各自持有状态的实例。 */
|
||||
private CameraSelectionStore() {
|
||||
}
|
||||
|
||||
/** 返回人工选择的彩色摄像头编号;-1 表示未指定,按设备枚举自动选择。 */
|
||||
public static int loadRgbCameraId(Context context) {
|
||||
/** 读取完整缓存;首次使用或缓存损坏时重新枚举,枚举失败由调用方展示重试。 */
|
||||
public static synchronized Map<String, Object> load(Context context) {
|
||||
SharedPreferences preferences = preferences(context);
|
||||
if (!preferences.contains(CAMERAS_KEY)) return refresh(context);
|
||||
try {
|
||||
return preferences(context).getInt(KEY_RGB_CAMERA_ID, AUTO_CAMERA_ID);
|
||||
} catch (Exception error) {
|
||||
return AUTO_CAMERA_ID;
|
||||
return snapshot(context, readCameras(preferences.getString(CAMERAS_KEY, "[]")), preferences.getLong(REFRESHED_AT_KEY, 0L));
|
||||
} catch (JSONException | ClassCastException error) {
|
||||
Log.w(TAG, "摄像头缓存损坏,重新枚举", error);
|
||||
return refresh(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入人工选择;负数一律归一化为自动。 */
|
||||
public static void saveRgbCameraId(Context context, int cameraId) {
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putInt(KEY_RGB_CAMERA_ID, cameraId < 0 ? AUTO_CAMERA_ID : cameraId)
|
||||
.apply();
|
||||
/** 完整枚举成功后才替换缓存;异常不会把旧目录覆盖成空列表,人工偏好始终保留。 */
|
||||
public static synchronized Map<String, Object> refresh(Context context) {
|
||||
List<Map<String, Object>> cameras = enumerateCameras();
|
||||
long refreshedAt = System.currentTimeMillis();
|
||||
SharedPreferences preferences = preferences(context);
|
||||
Object previousValue = preferences.getAll().get(CAMERAS_KEY);
|
||||
Object previousTimeValue = preferences.getAll().get(REFRESHED_AT_KEY);
|
||||
String previous = previousValue instanceof String ? (String) previousValue : null;
|
||||
long previousTime = previousTimeValue instanceof Long ? (Long) previousTimeValue : 0L;
|
||||
String encoded = new JSONArray(cameras).toString();
|
||||
if (!preferences.edit().putString(CAMERAS_KEY, encoded).putLong(REFRESHED_AT_KEY, refreshedAt).commit()) {
|
||||
// commit 失败时 Android 仍可能更新进程内缓存,恢复旧值,避免界面误报持久化成功。
|
||||
SharedPreferences.Editor rollback = preferences.edit().putLong(REFRESHED_AT_KEY, previousTime);
|
||||
if (previous == null) rollback.remove(CAMERAS_KEY); else rollback.putString(CAMERAS_KEY, previous);
|
||||
rollback.commit();
|
||||
throw new IllegalStateException("摄像头列表保存失败,请重试");
|
||||
}
|
||||
return snapshot(context, cameras, refreshedAt);
|
||||
}
|
||||
|
||||
/** 查询系统 Camera1 编号、朝向及角度,不打开设备、不请求权限,也不判断彩色或红外。 */
|
||||
public static List<Map<String, Object>> enumerateCameras() {
|
||||
int count = Camera.getNumberOfCameras();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (int id = 0; id < count; id++) {
|
||||
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(id, info);
|
||||
Map<String, Object> device = new HashMap<>();
|
||||
device.put("cameraId", id);
|
||||
device.put("facing", info.facing);
|
||||
device.put("orientation", info.orientation);
|
||||
result.add(device);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 返回旧版保存的编号;继续读取同一键,避免升级后已有选择丢失。 */
|
||||
public static synchronized int loadRgbCameraId(Context context) {
|
||||
return loadRgbCameraId(preferences(context));
|
||||
}
|
||||
|
||||
/** 只接受有效的历史整数;未选择或负数继续表示自动,不改写磁盘。 */
|
||||
static int loadRgbCameraId(SharedPreferences preferences) {
|
||||
Object value = preferences.getAll().get(PRIMARY_CAMERA_KEY);
|
||||
return value instanceof Integer && (Integer) value >= 0 ? (Integer) value : AUTO_CAMERA_ID;
|
||||
}
|
||||
|
||||
/** 兼容旧通道的保存入口;必须确认落盘后才能向界面报告成功。 */
|
||||
public static synchronized void saveRgbCameraId(Context context, int cameraId) {
|
||||
saveRgbCameraId(preferences(context), cameraId);
|
||||
}
|
||||
|
||||
/** 提交失败时恢复进程内旧值;Android 的 commit 失败也可能已修改内存缓存。 */
|
||||
static void saveRgbCameraId(SharedPreferences preferences, int cameraId) {
|
||||
Object previous = preferences.getAll().get(PRIMARY_CAMERA_KEY);
|
||||
int selected = cameraId < 0 ? AUTO_CAMERA_ID : cameraId;
|
||||
if (!preferences.edit().putInt(PRIMARY_CAMERA_KEY, selected).commit()) {
|
||||
SharedPreferences.Editor rollback = preferences.edit();
|
||||
if (previous instanceof Integer) rollback.putInt(PRIMARY_CAMERA_KEY, (Integer) previous);
|
||||
else rollback.remove(PRIMARY_CAMERA_KEY);
|
||||
rollback.commit();
|
||||
throw new IllegalStateException("摄像头选择保存失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
/** null 表示自动;临时失联不删除人工偏好,下次恢复连接后仍优先使用。 */
|
||||
public static synchronized Integer primaryCameraId(Context context) {
|
||||
int selected = loadRgbCameraId(context);
|
||||
return selected < 0 ? null : selected;
|
||||
}
|
||||
|
||||
/** 只保存设置中已预览的候选;与旧通道共用原偏好键,不产生第二份主摄配置。 */
|
||||
public static synchronized Map<String, Object> savePrimaryCamera(Context context, Integer cameraId) {
|
||||
if (cameraId != null && cameraId < 0) throw new IllegalArgumentException("摄像头编号必须为非负整数");
|
||||
Map<String, Object> result = load(context);
|
||||
saveRgbCameraId(context, cameraId == null ? AUTO_CAMERA_ID : cameraId);
|
||||
result.put("primaryCameraId", cameraId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 每次打开前实时枚举;人工编号有效时优先,否则按 BACK、首个设备回退,绝不把朝向当作彩色证明。 */
|
||||
public static int resolvePrimaryCameraId(Context context) {
|
||||
Integer requested = primaryCameraId(context);
|
||||
int resolved = resolveCameraId(enumerateCameras(), requested, true);
|
||||
if (requested != null && requested != resolved) {
|
||||
Log.w(TAG, "人工主摄 " + requested + " 已失联,临时使用默认摄像头 " + resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 为预览和识别使用同一选择规则;预览指定设备失联时必须报错,不能偷偷展示另一台。 */
|
||||
public static int resolveCameraId(List<Map<String, Object>> cameras, Integer requested, boolean allowFallback) {
|
||||
Integer first = null;
|
||||
Integer back = null;
|
||||
for (Map<String, Object> device : cameras) {
|
||||
int id = ((Number) device.get("cameraId")).intValue();
|
||||
if (requested != null && id == requested) return id;
|
||||
if (first == null) first = id;
|
||||
if (back == null && ((Number) device.get("facing")).intValue() == Camera.CameraInfo.CAMERA_FACING_BACK) back = id;
|
||||
}
|
||||
if (requested != null && !allowFallback) throw new IllegalArgumentException("所选摄像头已不存在,请刷新列表");
|
||||
if (first == null) throw new IllegalStateException("未发现可用摄像头,请检查设备后刷新列表");
|
||||
return back == null ? first : back;
|
||||
}
|
||||
|
||||
/** 将缓存 JSON 恢复为与 MethodChannel 一致的整数目录;不完整或重复的记录视为损坏。 */
|
||||
private static List<Map<String, Object>> readCameras(String encoded) throws JSONException {
|
||||
JSONArray array = new JSONArray(encoded);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
for (int index = 0; index < array.length(); index++) {
|
||||
JSONObject value = array.getJSONObject(index);
|
||||
int id = value.getInt("cameraId");
|
||||
if (id < 0 || !ids.add(id)) throw new JSONException("摄像头编号无效或重复");
|
||||
Map<String, Object> device = new HashMap<>();
|
||||
device.put("cameraId", id);
|
||||
device.put("facing", value.getInt("facing"));
|
||||
device.put("orientation", value.getInt("orientation"));
|
||||
result.add(device);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 组合缓存目录与独立人工偏好,目录中缺少主摄时仍返回原编号供页面明确提示。 */
|
||||
private static Map<String, Object> snapshot(Context context, List<Map<String, Object>> cameras, long refreshedAt) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("cameras", cameras);
|
||||
result.put("primaryCameraId", primaryCameraId(context));
|
||||
result.put("refreshedAt", refreshedAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 使用应用私有偏好文件,主副 Flutter Engine 共享同一份本机配置。 */
|
||||
private static SharedPreferences preferences(Context context) {
|
||||
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.util.DisplayMetrics;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
@@ -22,6 +23,8 @@ import com.example.pj_supermarket_cloud.tool.FlutterRouterManager;
|
||||
import io.flutter.Log;
|
||||
import io.flutter.embedding.android.FlutterActivity;
|
||||
import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin;
|
||||
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager;
|
||||
import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore;
|
||||
import io.flutter.plugins.facediscern.tool.camera.DualCameraHelper;
|
||||
import io.flutter.plugins.facediscern.tool.camera.callback.CameraListener;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.LivenessType;
|
||||
@@ -38,6 +41,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** 保留的原生人脸页面,与 Flutter 会话共用主摄偏好及相机独占规则。 */
|
||||
public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
private static final String TAG = "人脸识别页面";
|
||||
@@ -55,6 +59,9 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
|
||||
private Boolean previewLock = false;
|
||||
|
||||
/** 旧页面是否处于前台,阻止暂停后迟到的布局任务重新申请相机。 */
|
||||
private boolean cameraPageResumed;
|
||||
|
||||
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
||||
|
||||
@Override
|
||||
@@ -93,39 +100,42 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
initRgbCamera();
|
||||
}
|
||||
|
||||
/** 销毁旧页面时归还相机独占权并释放识别处理器。 */
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (rgbCameraHelper != null) {
|
||||
rgbCameraHelper.release();
|
||||
rgbCameraHelper = null;
|
||||
}
|
||||
releaseRgbCamera();
|
||||
if (binding != null) binding = null;
|
||||
viewModel.destroy();
|
||||
Log.e(TAG, "相机触发onDestroy");
|
||||
}
|
||||
|
||||
/** 离开旧页面时真实释放相机,允许基本设置接管设备。 */
|
||||
@Override
|
||||
protected void onPause() {
|
||||
cameraPageResumed = false;
|
||||
super.onPause();
|
||||
if (rgbCameraHelper != null) {
|
||||
rgbCameraHelper.stop();
|
||||
}
|
||||
// stop 只暂停画面但仍占用 Camera;回到主屏设置前必须真实释放硬件。
|
||||
releaseRgbCamera();
|
||||
if (viewModel != null) viewModel.stop();
|
||||
binding.dualCameraFaceRectView.clearFaceInfo();
|
||||
previewLock = true;
|
||||
Log.i(TAG, "相机onPause");
|
||||
}
|
||||
|
||||
/** 回到前台后按最新持久化主摄重新打开,不复用暂停前的硬件句柄。 */
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
cameraPageResumed = true;
|
||||
MethodChannel channel = FlutterFaceDiscernPlugin.staticChannel;
|
||||
if (Objects.nonNull(channel)) {
|
||||
channel.invokeMethod("faceDiscernPageInit", null);
|
||||
}
|
||||
if (rgbCameraHelper != null) {
|
||||
rgbCameraHelper.resumePreview();
|
||||
if (binding != null) {
|
||||
binding.dualCameraTexturePreviewRgb.post(() -> {
|
||||
if (cameraPageResumed && checkPermissions(NEEDED_PERMISSIONS)) initRgbCamera();
|
||||
});
|
||||
}
|
||||
scheduler.schedule(() -> {
|
||||
try {
|
||||
@@ -190,14 +200,26 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
binding.dualCameraTexturePreviewRgb.getViewTreeObserver().addOnGlobalLayoutListener(FaceDiscern.this);
|
||||
}
|
||||
|
||||
/** 按统一主摄偏好打开旧版入口,相机独占权与主副屏业务会话、设置候选互斥。 */
|
||||
private void initRgbCamera() {
|
||||
if (rgbCameraHelper != null || !cameraPageResumed) return;
|
||||
final int selectedCameraId;
|
||||
try {
|
||||
selectedCameraId = FaceRecognitionSessionManager.getInstance(this).acquireLegacyCamera(this);
|
||||
} catch (RuntimeException error) {
|
||||
Toast.makeText(this, error.getMessage(), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
CameraListener cameraListener = new CameraListener() {
|
||||
/** 按实际相机朝向映射人脸框,编号与朝向不能混用。 */
|
||||
@Override
|
||||
public void onCameraOpened(Camera camera, int cameraId, int displayOrientation, boolean isMirror) {
|
||||
runOnUiThread(() -> {
|
||||
Camera.Size previewSizeRgb = camera.getParameters().getPreviewSize();
|
||||
Camera.CameraInfo actualInfo = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(cameraId, actualInfo);
|
||||
ViewGroup.LayoutParams layoutParams = adjustPreviewViewSize(binding.dualCameraTexturePreviewRgb, binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView, previewSizeRgb, displayOrientation, 1.0f);
|
||||
rgbFaceRectTransformer = new FaceRectTransformer(previewSizeRgb.width, previewSizeRgb.height, layoutParams.width, layoutParams.height, displayOrientation, cameraId, isMirror, true, true);
|
||||
rgbFaceRectTransformer = new FaceRectTransformer(previewSizeRgb.width, previewSizeRgb.height, layoutParams.width, layoutParams.height, displayOrientation, actualInfo.facing, isMirror, true, true);
|
||||
viewModel.onRgbCameraOpened(camera);
|
||||
viewModel.setRgbFaceRectTransformer(rgbFaceRectTransformer);
|
||||
Log.e(TAG, "相机打开,宽:" + previewSizeRgb.width + ",高:" + previewSizeRgb.height + ";layout宽:" + layoutParams.width + ",layout高:" + layoutParams.height + ";");
|
||||
@@ -219,9 +241,14 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
Log.e(TAG, "相机关闭");
|
||||
}
|
||||
|
||||
/** 打开失败时释放所有者及 Surface 监听,避免错误设备永久占用入口。 */
|
||||
@Override
|
||||
public void onCameraError(Exception e) {
|
||||
Log.e(TAG, "相机错误error", e);
|
||||
runOnUiThread(() -> {
|
||||
releaseRgbCamera();
|
||||
Toast.makeText(FaceDiscern.this, "摄像头打开失败,请检查设备或重新选择主摄", Toast.LENGTH_LONG).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -229,22 +256,38 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
|
||||
}
|
||||
};
|
||||
// 使用前置相机
|
||||
int mCameraId = 0;
|
||||
int numberOfCameras = Camera.getNumberOfCameras();
|
||||
Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
|
||||
for (int i = 0; i < numberOfCameras; i++) {
|
||||
Camera.getCameraInfo(i, cameraInfo);
|
||||
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
|
||||
mCameraId = i;
|
||||
break;
|
||||
try {
|
||||
// FRONT/BACK 只参与默认规则和方向计算,不能用于断言设备是彩色传感器。
|
||||
Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(selectedCameraId, cameraInfo);
|
||||
Integer requested = CameraSelectionStore.primaryCameraId(this);
|
||||
if (requested != null && requested != selectedCameraId) {
|
||||
Toast.makeText(this, "所选主摄已失联,临时使用默认摄像头 " + selectedCameraId, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
Point previewSize = new Point(binding.dualCameraTexturePreviewRgb.getMeasuredWidth(), binding.dualCameraTexturePreviewRgb.getMeasuredHeight());
|
||||
rgbCameraHelper = new DualCameraHelper.Builder().previewViewSize(previewSize).previewSize(previewSize).rotation(getWindowManager().getDefaultDisplay().getRotation()).additionalRotation(0).specificCameraId(selectedCameraId).isMirror(cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT).previewOn(binding.dualCameraTexturePreviewRgb).cameraListener(cameraListener).build();
|
||||
rgbCameraHelper.init();
|
||||
// 恢复前台时 Surface 可能尚未重建,由 Helper 的 available 回调再启动预览。
|
||||
if (binding.dualCameraTexturePreviewRgb.isAvailable()) rgbCameraHelper.start();
|
||||
viewModel.setRgbCameraHelper(rgbCameraHelper);
|
||||
} catch (RuntimeException error) {
|
||||
releaseRgbCamera();
|
||||
Log.e(TAG, "初始化旧版主摄失败", error);
|
||||
Toast.makeText(this, "摄像头初始化失败,请检查设备或刷新主摄列表", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
/** 暂停、失败或销毁时先关闭真实相机,再交还独占权,允许主屏继续预览或识别。 */
|
||||
private void releaseRgbCamera() {
|
||||
DualCameraHelper helper = rgbCameraHelper;
|
||||
rgbCameraHelper = null;
|
||||
try {
|
||||
if (helper != null) helper.release();
|
||||
} catch (RuntimeException error) {
|
||||
Log.w(TAG, "释放旧版人脸相机失败", error);
|
||||
} finally {
|
||||
FaceRecognitionSessionManager.getInstance(this).releaseLegacyCamera(this);
|
||||
}
|
||||
Point previewSize = new Point(binding.dualCameraTexturePreviewRgb.getMeasuredWidth(), binding.dualCameraTexturePreviewRgb.getMeasuredHeight());
|
||||
rgbCameraHelper = new DualCameraHelper.Builder().previewViewSize(previewSize).previewSize(previewSize).rotation(getWindowManager().getDefaultDisplay().getRotation()).additionalRotation(0).specificCameraId(mCameraId).isMirror(false).previewOn(binding.dualCameraTexturePreviewRgb).cameraListener(cameraListener).build();
|
||||
rgbCameraHelper.init();
|
||||
rgbCameraHelper.start();
|
||||
viewModel.setRgbCameraHelper(rgbCameraHelper);
|
||||
}
|
||||
/* ***************** end end end ***************** */
|
||||
|
||||
@@ -303,4 +346,4 @@ public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlob
|
||||
binding.dualCameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,13 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 主屏失去前台时立即收回设置预览,Dart 暂停通知迟到时也不能泄漏相机。 */
|
||||
override fun onPause() {
|
||||
FaceRecognitionSessionManager.getInstance(applicationContext)
|
||||
.cancelCameraSelectionPreview("应用已暂停,请重新预览摄像头")
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
/** 系统报告低内存时释放当前人脸识别会话的高占用资源。 */
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package io.flutter.plugins.facediscern.tool.camera.config;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/** 验证真实存储入口的旧键兼容、落盘失败回滚及识别/预览共用选择规则,无需相机硬件。 */
|
||||
public class CameraSelectionStoreTest {
|
||||
/** 历史安装保存的编号必须直接恢复,保存后重新创建偏好实例仍能读取。 */
|
||||
@Test public void existingKeyAndProcessRestartKeepSavedCamera() {
|
||||
MemoryPreferences storage = new MemoryPreferences();
|
||||
storage.disk.put("rgb_camera_id", 1);
|
||||
assertEquals(1, CameraSelectionStore.loadRgbCameraId(storage.open()));
|
||||
CameraSelectionStore.saveRgbCameraId(storage.open(), 0);
|
||||
assertEquals(0, CameraSelectionStore.loadRgbCameraId(storage.open()));
|
||||
assertEquals(0, storage.disk.get("rgb_camera_id"));
|
||||
}
|
||||
|
||||
/** commit 失败也更新内存时必须恢复旧值,不能让界面回显一份重启即消失的新选择。 */
|
||||
@Test public void failedCommitRestoresPreviousMemoryAndDiskValue() {
|
||||
MemoryPreferences storage = new MemoryPreferences();
|
||||
storage.disk.put("rgb_camera_id", 1);
|
||||
SharedPreferences preferences = storage.open();
|
||||
storage.failNextCommit = true;
|
||||
expectSaveFailure(preferences, 0);
|
||||
assertEquals(1, CameraSelectionStore.loadRgbCameraId(preferences));
|
||||
assertEquals(1, CameraSelectionStore.loadRgbCameraId(storage.open()));
|
||||
}
|
||||
|
||||
/** 首次保存失败不能留下新的偏好键,原来的自动模式应继续生效。 */
|
||||
@Test public void firstSaveFailureRestoresAbsentKey() {
|
||||
MemoryPreferences storage = new MemoryPreferences();
|
||||
SharedPreferences preferences = storage.open();
|
||||
storage.failNextCommit = true;
|
||||
expectSaveFailure(preferences, 1);
|
||||
assertEquals(-1, CameraSelectionStore.loadRgbCameraId(preferences));
|
||||
assertFalse(storage.disk.containsKey("rgb_camera_id"));
|
||||
}
|
||||
|
||||
/** 自动模式仍采用旧版 -1 编码,目录键不会被选择操作清空。 */
|
||||
@Test public void automaticSelectionPreservesLegacyEncodingAndCatalog() {
|
||||
MemoryPreferences storage = new MemoryPreferences();
|
||||
storage.disk.put("rgb_camera_id", 1);
|
||||
storage.disk.put("cameras", "cached-camera-list");
|
||||
CameraSelectionStore.saveRgbCameraId(storage.open(), -3);
|
||||
assertEquals(-1, CameraSelectionStore.loadRgbCameraId(storage.open()));
|
||||
assertEquals(-1, storage.disk.get("rgb_camera_id"));
|
||||
assertEquals("cached-camera-list", storage.disk.get("cameras"));
|
||||
}
|
||||
|
||||
/** 人工编号优先于前后置;失联只临时回退,预览则必须明确失败。 */
|
||||
@Test public void manualSelectionAndMissingCameraUseSharedRules() {
|
||||
List<Map<String, Object>> cameras = Arrays.asList(camera(0, 0), camera(1, 1));
|
||||
assertEquals(1, CameraSelectionStore.resolveCameraId(cameras, 1, true));
|
||||
assertEquals(1, CameraSelectionStore.resolveCameraId(cameras, 1, false));
|
||||
assertEquals(0, CameraSelectionStore.resolveCameraId(cameras, null, false));
|
||||
assertEquals(0, CameraSelectionStore.resolveCameraId(cameras, 7, true));
|
||||
try {
|
||||
CameraSelectionStore.resolveCameraId(cameras, 7, false);
|
||||
fail("失联候选不能显示另一台设备的画面");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
assertEquals("所选摄像头已不存在,请刷新列表", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认后置优先,否则首个;空设备列表必须报错,不能把 -1 当有效相机打开。 */
|
||||
@Test public void defaultRuleHandlesFrontFirstAndEmptyCatalog() {
|
||||
assertEquals(1, CameraSelectionStore.resolveCameraId(Arrays.asList(camera(0, 1), camera(1, 0)), null, true));
|
||||
assertEquals(0, CameraSelectionStore.resolveCameraId(Collections.singletonList(camera(0, 1)), null, true));
|
||||
try {
|
||||
CameraSelectionStore.resolveCameraId(Collections.emptyList(), null, true);
|
||||
fail("无相机应报告硬件不可用");
|
||||
} catch (IllegalStateException expected) {
|
||||
assertEquals("未发现可用摄像头,请检查设备后刷新列表", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一核对保存失败向调用方传播,而不是静默报告成功。 */
|
||||
private static void expectSaveFailure(SharedPreferences preferences, int cameraId) {
|
||||
try {
|
||||
CameraSelectionStore.saveRgbCameraId(preferences, cameraId);
|
||||
fail("落盘失败必须抛出错误");
|
||||
} catch (IllegalStateException expected) {
|
||||
assertEquals("摄像头选择保存失败,请重试", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建驱动元数据,前后置取 Android 常量原始值 0/1。 */
|
||||
private static Map<String, Object> camera(int id, int facing) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("cameraId", id);
|
||||
result.put("facing", facing);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 模拟 Android 的内存缓存与磁盘分离;重新 open 等价于进程重建后读取磁盘。 */
|
||||
private static final class MemoryPreferences {
|
||||
/** 已成功提交的磁盘数据,失败提交不会写入。 */
|
||||
final Map<String, Object> disk = new HashMap<>();
|
||||
/** 下次提交在更新进程内缓存后报告失败,贴近 Android 的实际语义。 */
|
||||
boolean failNextCommit;
|
||||
|
||||
/** 创建新的进程内偏好视图,不执行 Android 桩方法。 */
|
||||
SharedPreferences open() {
|
||||
Map<String, Object> memory = new HashMap<>(disk);
|
||||
return (SharedPreferences) Proxy.newProxyInstance(SharedPreferences.class.getClassLoader(),
|
||||
new Class<?>[]{SharedPreferences.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getAll")) return new HashMap<>(memory);
|
||||
if (method.getName().equals("edit")) return editor(memory);
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅支持正式保存路径使用的同步提交,意外退回 apply 会使测试立即失败。 */
|
||||
SharedPreferences.Editor editor(Map<String, Object> memory) {
|
||||
Map<String, Object> pending = new HashMap<>();
|
||||
return (SharedPreferences.Editor) Proxy.newProxyInstance(SharedPreferences.Editor.class.getClassLoader(),
|
||||
new Class<?>[]{SharedPreferences.Editor.class}, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "putInt":
|
||||
pending.put((String) args[0], args[1]);
|
||||
return proxy;
|
||||
case "remove":
|
||||
pending.put((String) args[0], null);
|
||||
return proxy;
|
||||
case "commit":
|
||||
for (Map.Entry<String, Object> entry : pending.entrySet()) {
|
||||
if (entry.getValue() == null) memory.remove(entry.getKey());
|
||||
else memory.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
if (failNextCommit) {
|
||||
failNextCommit = false;
|
||||
return false;
|
||||
}
|
||||
disk.clear();
|
||||
disk.putAll(memory);
|
||||
return true;
|
||||
default:
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export './database/database_enum_models.dart';
|
||||
export './database/database_models.dart';
|
||||
|
||||
export './plugs/face_discern_models.dart';
|
||||
export './plugs/face_camera_models.dart';
|
||||
export './plugs/face_recognition_session_models.dart';
|
||||
export './plugs/face_startup_preparation_models.dart';
|
||||
export './plugs/serial_card_enum_models.dart';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'face_recognition_session_models.dart';
|
||||
|
||||
/// Android Camera1 的设备目录项;系统朝向不能证明传感器是彩色或红外。
|
||||
class FaceCameraDevice {
|
||||
/// 使用驱动报告的真实编号、朝向和传感器角度创建目录项。
|
||||
const FaceCameraDevice({
|
||||
required this.cameraId,
|
||||
required this.facing,
|
||||
required this.orientation,
|
||||
});
|
||||
|
||||
/// 本机 Camera1 编号;换线或驱动枚举变化后应重新预览确认。
|
||||
final int cameraId;
|
||||
|
||||
/// 系统朝向:0 为后置,1 为前置,其余视为未知。
|
||||
final int facing;
|
||||
|
||||
/// 驱动报告的传感器安装角度,单位度。
|
||||
final int orientation;
|
||||
|
||||
/// 面向现场操作人员的摄像头名称,不猜测彩色或红外属性。
|
||||
String get displayName => '摄像头 #$cameraId';
|
||||
|
||||
/// 朝向与安装角度仅用于核对目录,选择仍以实时画面为准。
|
||||
String get description => '${switch (facing) {
|
||||
0 => '系统后置',
|
||||
1 => '系统前置',
|
||||
_ => '系统朝向未知',
|
||||
}} · $orientation°';
|
||||
|
||||
/// 将 MethodChannel 的动态键目录转换为本机设备信息。
|
||||
factory FaceCameraDevice.fromMap(Map<dynamic, dynamic> map) =>
|
||||
FaceCameraDevice(
|
||||
cameraId: (map['cameraId'] as num).toInt(),
|
||||
facing: (map['facing'] as num).toInt(),
|
||||
orientation: (map['orientation'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 本机已保存的识别摄像头偏好及最近一次成功刷新的目录。
|
||||
class FaceCameraConfiguration {
|
||||
/// 无人工编号表示自动选择;空目录表示最近没有发现设备。
|
||||
const FaceCameraConfiguration({
|
||||
this.cameras = const [],
|
||||
this.primaryCameraId,
|
||||
this.refreshedAt = 0,
|
||||
});
|
||||
|
||||
/// 最近一次完整枚举快照,打开设备前原生仍会实时核验。
|
||||
final List<FaceCameraDevice> cameras;
|
||||
|
||||
/// 人工保存的识别摄像头编号;null 表示自动,失联也保留原编号。
|
||||
final int? primaryCameraId;
|
||||
|
||||
/// 最近成功刷新时间,单位 Unix 毫秒;0 表示尚未刷新。
|
||||
final int refreshedAt;
|
||||
|
||||
/// 目录中是否缺少人工选择,用于提示失联,不会自动覆盖原偏好。
|
||||
bool get primaryMissing =>
|
||||
primaryCameraId != null &&
|
||||
!cameras.any((camera) => camera.cameraId == primaryCameraId);
|
||||
|
||||
/// 设置页回显的已保存选择,与弹窗中的未保存候选分别维护。
|
||||
String get primaryLabel => primaryCameraId == null
|
||||
? '自动(默认规则)'
|
||||
: '摄像头 #$primaryCameraId${primaryMissing ? '(列表中未发现)' : ''}';
|
||||
|
||||
/// 读取原生持久化快照,兼容 StandardMessageCodec 的嵌套动态 Map。
|
||||
factory FaceCameraConfiguration.fromMap(Map<dynamic, dynamic> map) {
|
||||
final cameras = map['cameras'];
|
||||
return FaceCameraConfiguration(
|
||||
cameras: cameras is List
|
||||
? cameras
|
||||
.whereType<Map>()
|
||||
.map(FaceCameraDevice.fromMap)
|
||||
.toList(growable: false)
|
||||
: const [],
|
||||
primaryCameraId: (map['primaryCameraId'] as num?)?.toInt(),
|
||||
refreshedAt: (map['refreshedAt'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页独占的候选画面,只预览,不创建虹软识别或支付会话。
|
||||
class FaceCameraSelectionPreview {
|
||||
/// 创建仅由本次弹窗拥有的实际相机与纹理元数据。
|
||||
const FaceCameraSelectionPreview({
|
||||
required this.previewSessionId,
|
||||
required this.textureId,
|
||||
required this.cameraId,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.rotation,
|
||||
required this.mirror,
|
||||
});
|
||||
|
||||
/// 候选唯一编号,用于拒绝迟到的保存、关闭及设备错误。
|
||||
final String previewSessionId;
|
||||
|
||||
/// 当前 Flutter Engine 内可用的纹理编号。
|
||||
final int textureId;
|
||||
|
||||
/// 实际打开的 Camera1 编号,自动模式也返回解析后的编号。
|
||||
final int cameraId;
|
||||
|
||||
/// 原始预览宽度,单位像素。
|
||||
final int width;
|
||||
|
||||
/// 原始预览高度,单位像素。
|
||||
final int height;
|
||||
|
||||
/// 传感器与屏幕方向共同决定的显示角度,单位度。
|
||||
final int rotation;
|
||||
|
||||
/// 是否需要水平镜像前置摄像头画面。
|
||||
final bool mirror;
|
||||
|
||||
/// 复用既有画面组件,资源释放仍由设置专用通道负责。
|
||||
FaceRecognitionPreviewTexture get texture => FaceRecognitionPreviewTexture(
|
||||
textureId: textureId,
|
||||
cameraId: cameraId,
|
||||
width: width,
|
||||
height: height,
|
||||
rotation: rotation,
|
||||
mirror: mirror,
|
||||
);
|
||||
|
||||
/// 将原生候选元数据转换为当前弹窗可展示的预览。
|
||||
factory FaceCameraSelectionPreview.fromMap(Map<dynamic, dynamic> map) =>
|
||||
FaceCameraSelectionPreview(
|
||||
previewSessionId: map['previewSessionId'] as String,
|
||||
textureId: (map['textureId'] as num).toInt(),
|
||||
cameraId: (map['cameraId'] as num).toInt(),
|
||||
width: (map['width'] as num).toInt(),
|
||||
height: (map['height'] as num).toInt(),
|
||||
rotation: (map['rotation'] as num).toInt(),
|
||||
mirror: map['mirror'] == true,
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,9 @@ class FaceRecognitionSessionEvent {
|
||||
required this.message,
|
||||
required this.faceBoxes,
|
||||
this.errorCode,
|
||||
this.cameraId,
|
||||
this.cameraWarning = '',
|
||||
this.cameraPreviewSessionId,
|
||||
});
|
||||
|
||||
/// 当前业务会话唯一编号,用于过滤上一会话迟到事件。
|
||||
@@ -175,6 +178,15 @@ class FaceRecognitionSessionEvent {
|
||||
/// 原生结构化错误码;没有错误时为空。
|
||||
final String? errorCode;
|
||||
|
||||
/// 当前实际打开的摄像头编号,关闭时为空;不等同于已保存的人工选择。
|
||||
final int? cameraId;
|
||||
|
||||
/// 人工设备失联时临时回退的提示,不覆盖已保存的偏好。
|
||||
final String cameraWarning;
|
||||
|
||||
/// 设置预览专属错误的候选编号,业务识别应忽略此类事件。
|
||||
final String? cameraPreviewSessionId;
|
||||
|
||||
/// 会话是否仍处于初始化或实时检测阶段。
|
||||
bool get isActive =>
|
||||
status == FaceRecognitionSessionStatus.initializing ||
|
||||
@@ -212,6 +224,9 @@ class FaceRecognitionSessionEvent {
|
||||
.toList(growable: false)
|
||||
: const [],
|
||||
errorCode: map['errorCode']?.toString(),
|
||||
cameraId: (map['cameraId'] as num?)?.toInt(),
|
||||
cameraWarning: map['cameraWarning']?.toString() ?? '',
|
||||
cameraPreviewSessionId: map['cameraPreviewSessionId']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,6 +236,7 @@ class FaceRecognitionPreviewTexture {
|
||||
/// 创建预览纹理元数据。
|
||||
const FaceRecognitionPreviewTexture({
|
||||
required this.textureId,
|
||||
this.cameraId,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.rotation,
|
||||
@@ -230,6 +246,9 @@ class FaceRecognitionPreviewTexture {
|
||||
/// 当前 Flutter Engine 可渲染的 Texture 编号。
|
||||
final int textureId;
|
||||
|
||||
/// 当前实际相机编号,旧原生版本未提供时为空。
|
||||
final int? cameraId;
|
||||
|
||||
/// 原始预览宽度,单位为像素。
|
||||
final int width;
|
||||
|
||||
@@ -246,6 +265,7 @@ class FaceRecognitionPreviewTexture {
|
||||
factory FaceRecognitionPreviewTexture.fromMap(Map<dynamic, dynamic> map) {
|
||||
return FaceRecognitionPreviewTexture(
|
||||
textureId: (map['textureId'] as num).toInt(),
|
||||
cameraId: (map['cameraId'] as num?)?.toInt(),
|
||||
width: (map['width'] as num?)?.toInt() ?? 1280,
|
||||
height: (map['height'] as num?)?.toInt() ?? 720,
|
||||
rotation: (map['rotation'] as num?)?.toInt() ?? 0,
|
||||
|
||||
@@ -23,6 +23,7 @@ class RoutePages {
|
||||
binding: BindingsBuilder(() {
|
||||
Get.lazyPut(() => PanelController(), fenix: true);
|
||||
Get.lazyPut(() => SettingController(), fenix: true);
|
||||
Get.lazyPut(() => CameraSettingController(), fenix: true);
|
||||
}),
|
||||
),
|
||||
// 终端工作台一级路由:顶部状态栏常驻外壳,业务页面在 TerminalPage
|
||||
|
||||
@@ -282,7 +282,8 @@ class FaceRecognitionSessionService extends GetxService {
|
||||
|
||||
void _handleNativeState(FaceRecognitionSessionEvent event) {
|
||||
final sessionId = _activeSessionId;
|
||||
if (sessionId == null ||
|
||||
if (event.cameraPreviewSessionId != null ||
|
||||
sessionId == null ||
|
||||
event.sessionId != sessionId ||
|
||||
event.revision <= _nativeRevision) {
|
||||
return;
|
||||
|
||||
@@ -152,6 +152,53 @@ abstract class FaceDiscernPlugs {
|
||||
return _invokeRecognizeConfiguration('resetRecognizeConfiguration');
|
||||
}
|
||||
|
||||
/// 读取本机已保存的识别摄像头及目录,首次使用时枚举但不打开硬件。
|
||||
static Future<FaceCameraConfiguration> getCameraConfiguration() =>
|
||||
_invokeCameraConfiguration('getCameraConfiguration');
|
||||
|
||||
/// 完整刷新设备目录;失败时保留原目录与人工偏好。
|
||||
static Future<FaceCameraConfiguration> refreshCameraList() =>
|
||||
_invokeCameraConfiguration('refreshCameraList');
|
||||
|
||||
/// 打开设置专用候选;null 表示预览默认规则,不运行人脸识别。
|
||||
static Future<FaceCameraSelectionPreview> startCameraSelectionPreview({
|
||||
required String previewSessionId,
|
||||
required int? cameraId,
|
||||
}) async {
|
||||
final value = await _channel.invokeMethod<dynamic>(
|
||||
'startCameraSelectionPreview',
|
||||
{'previewSessionId': previewSessionId, 'cameraId': cameraId},
|
||||
);
|
||||
if (value is! Map) throw StateError('原生未返回有效的摄像头预览');
|
||||
return FaceCameraSelectionPreview.fromMap(value);
|
||||
}
|
||||
|
||||
/// 仅释放编号及所属 Engine 都匹配的候选,迟到关闭不影响新画面。
|
||||
static Future<void> stopCameraSelectionPreview(String previewSessionId) =>
|
||||
_channel.invokeMethod<void>('stopCameraSelectionPreview', {
|
||||
'previewSessionId': previewSessionId,
|
||||
});
|
||||
|
||||
/// 保存已经成功预览的候选,原生确认落盘成功后才关闭画面。
|
||||
static Future<FaceCameraConfiguration> setPrimaryCamera({
|
||||
required String previewSessionId,
|
||||
required int? cameraId,
|
||||
}) =>
|
||||
_invokeCameraConfiguration('setPrimaryCamera', {
|
||||
'previewSessionId': previewSessionId,
|
||||
'cameraId': cameraId,
|
||||
});
|
||||
|
||||
/// 解析原生配置,异常由设置页展示,避免将读取失败误认为自动模式。
|
||||
static Future<FaceCameraConfiguration> _invokeCameraConfiguration(
|
||||
String method, [
|
||||
Map<String, dynamic>? arguments,
|
||||
]) async {
|
||||
final value = await _channel.invokeMethod<dynamic>(method, arguments);
|
||||
if (value is! Map) throw StateError('原生未返回有效的摄像头配置');
|
||||
return FaceCameraConfiguration.fromMap(value);
|
||||
}
|
||||
|
||||
/// 枚举终端摄像头并逐一做预览帧色度探测;探测需短暂打开摄像头,耗时数秒。
|
||||
static Future<TerminalCameraSelection> getCameraChoices() async {
|
||||
final map = await _channel.invokeMethod<dynamic>('getCameraChoices');
|
||||
|
||||
@@ -61,8 +61,10 @@ class FaceCameraPreview extends StatelessWidget {
|
||||
),
|
||||
_FaceCameraResolutionLabel(
|
||||
texture: texture,
|
||||
event: event,
|
||||
scale: scale,
|
||||
).positioned(
|
||||
).alignRight().positioned(
|
||||
left: 18 * scale,
|
||||
right: 18 * scale,
|
||||
bottom: 16 * scale,
|
||||
),
|
||||
@@ -187,20 +189,27 @@ class _FaceCameraResolutionLabel extends StatelessWidget {
|
||||
/// 创建分辨率说明;纹理未就绪时使用插件既有默认分辨率。
|
||||
const _FaceCameraResolutionLabel({
|
||||
required this.texture,
|
||||
required this.event,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
/// 当前预览纹理元数据。
|
||||
final FaceRecognitionPreviewTexture? texture;
|
||||
|
||||
/// 实际识别设备与失联回退提示,优先于冷启动时创建的纹理元数据。
|
||||
final FaceRecognitionSessionEvent? event;
|
||||
|
||||
/// 宿主页面计算的尺寸缩放系数。
|
||||
final double scale;
|
||||
|
||||
/// 构建不遮挡主要人脸区域的紧凑标签。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cameraId = event?.cameraId ?? texture?.cameraId;
|
||||
final warning = event?.cameraWarning ?? '';
|
||||
return Text(
|
||||
'彩色摄像头 ${texture?.width ?? 1280} x ${texture?.height ?? 720}',
|
||||
'${cameraId == null ? '摄像头' : '摄像头 #$cameraId'} ${texture?.width ?? 1280} x ${texture?.height ?? 720}${warning.isEmpty ? '' : '\n$warning'}',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: Colors.white, fontSize: 13 * scale),
|
||||
).padding(horizontal: 12 * scale, vertical: 8 * scale).decorated(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 基本设置的摄像头目录、主摄草稿及独占预览状态;不发起任何业务识别。
|
||||
class CameraSettingController extends GetxController
|
||||
with WidgetsBindingObserver {
|
||||
/// 默认订阅原生共享事件;允许注入事件源以验证设备断开和迟到回调。
|
||||
CameraSettingController({Stream<FaceRecognitionSessionEvent>? previewEvents})
|
||||
: _previewEvents = previewEvents ?? FaceDiscernPlugs.sessionStream;
|
||||
|
||||
/// 事件源由调用方或原生通道持有,本控制器只负责取消自己的订阅。
|
||||
final Stream<FaceRecognitionSessionEvent> _previewEvents;
|
||||
|
||||
/// 最近成功读取的本机持久化配置,刷新失败时仍保留旧值。
|
||||
final configuration = Rxn<FaceCameraConfiguration>();
|
||||
|
||||
/// 是否正在读取或刷新目录,防止重复提交和并发覆盖。
|
||||
final isLoading = false.obs;
|
||||
|
||||
/// 目录读取、刷新或持久化失败的可恢复提示。
|
||||
final errorMessage = ''.obs;
|
||||
|
||||
/// 当前是否存在本控制器拥有的主摄选择弹窗。
|
||||
final selectionOpen = false.obs;
|
||||
|
||||
/// 用户当前候选编号,null 表示自动;保存之前不修改本机偏好。
|
||||
final draftCameraId = Rxn<int>();
|
||||
|
||||
/// 原生已打开的候选纹理,切换、暂停或关闭时立即清空。
|
||||
final preview = Rxn<FaceCameraSelectionPreview>();
|
||||
|
||||
/// 是否正在申请权限或切换设备,期间禁用重复选择。
|
||||
final isPreviewing = false.obs;
|
||||
|
||||
/// 是否正在提交主摄配置,提交期间不允许取消或重复保存。
|
||||
final isSaving = false.obs;
|
||||
|
||||
/// 预览、权限或保存失败说明,与列表刷新错误分别显示。
|
||||
final previewError = ''.obs;
|
||||
|
||||
/// 权限永久拒绝时展示系统设置入口,不自动跳出业务页面。
|
||||
final permissionCanOpenSettings = false.obs;
|
||||
|
||||
/// 设置候选的原生错误订阅,基本设置路由销毁时取消。
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _eventSubscription;
|
||||
|
||||
/// 异步操作代次,防止关闭、暂停后迟到的纹理重新占用相机。
|
||||
int _generation = 0;
|
||||
|
||||
/// 正在申请或已经持有的原生候选编号,必须在 MethodChannel 返回前记录。
|
||||
String? _previewSessionId;
|
||||
|
||||
/// 当前是否允许打开硬件;权限页面返回前及应用后台期间均不打开。
|
||||
bool _foreground = true;
|
||||
|
||||
/// 订阅资源异常、注册生命周期并优先恢复持久化目录。
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_eventSubscription = _previewEvents.listen(
|
||||
_handlePreviewEvent,
|
||||
onError: (Object error) {
|
||||
logger.w('|摄像头设置|预览状态监听失败:$error');
|
||||
},
|
||||
);
|
||||
unawaited(loadConfiguration());
|
||||
}
|
||||
|
||||
/// 路由离开后取消订阅并释放预览,迟到的异步创建仍会自行归还资源。
|
||||
@override
|
||||
void onClose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
unawaited(_eventSubscription?.cancel());
|
||||
// 路由回收可能发生在 Navigator 构建阶段,只清理资源,不再通知即将销毁的 Obx。
|
||||
_generation++;
|
||||
_foreground = false;
|
||||
final sessionId = _previewSessionId;
|
||||
_previewSessionId = null;
|
||||
if (sessionId != null) unawaited(_stopPreview(sessionId));
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
/// 后台立即丢弃候选纹理,恢复前台后由用户点击重试,避免未经操作重新打开摄像头。
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) _foreground = true;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_foreground = false;
|
||||
if (selectionOpen.value) {
|
||||
previewError.value = '应用已暂停,请返回前台后重新预览';
|
||||
unawaited(_clearPreview());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认读取缓存;显式刷新完整枚举后才更新页面,错误时保持上次目录和主摄。
|
||||
Future<void> loadConfiguration({bool refresh = false}) async {
|
||||
if (isLoading.value || selectionOpen.value) return;
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
final value = refresh
|
||||
? await FaceDiscernPlugs.refreshCameraList()
|
||||
: await FaceDiscernPlugs.getCameraConfiguration();
|
||||
if (!isClosed) configuration.value = value;
|
||||
} catch (error, stack) {
|
||||
logger.w('|摄像头设置|读取目录失败:$error|$stack');
|
||||
if (!isClosed) errorMessage.value = _message(error, '摄像头列表加载失败,请刷新重试');
|
||||
} finally {
|
||||
if (!isClosed) isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建一次未保存的选择草稿;缓存中已失联的主摄先预览默认规则,但不会提前清除偏好。
|
||||
bool beginSelection() {
|
||||
final current = configuration.value;
|
||||
if (isLoading.value || selectionOpen.value || current == null) return false;
|
||||
if (current.cameras.isEmpty) return false;
|
||||
draftCameraId.value =
|
||||
current.primaryMissing ? null : current.primaryCameraId;
|
||||
previewError.value = '';
|
||||
permissionCanOpenSettings.value = false;
|
||||
selectionOpen.value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 申请真实相机权限;提供可覆写入口以便 Widget 验证覆盖拒绝和永久拒绝状态。
|
||||
Future<bool> requestCameraPermission() async {
|
||||
final status = await Permission.camera.request();
|
||||
if (!isClosed) permissionCanOpenSettings.value = status.isPermanentlyDenied;
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
/// 立即更新选中态后串行关闭旧画面、申请权限、打开候选;迟到结果只释放、不回填。
|
||||
Future<void> previewCamera(int? cameraId) async {
|
||||
if (!selectionOpen.value || isPreviewing.value || isSaving.value) return;
|
||||
final generation = ++_generation;
|
||||
final previous = _previewSessionId;
|
||||
_previewSessionId = null;
|
||||
draftCameraId.value = cameraId;
|
||||
preview.value = null;
|
||||
previewError.value = '';
|
||||
permissionCanOpenSettings.value = false;
|
||||
isPreviewing.value = true;
|
||||
try {
|
||||
if (previous != null) await _stopPreview(previous);
|
||||
if (!_accepts(generation)) return;
|
||||
final granted = await requestCameraPermission();
|
||||
if (!_accepts(generation)) return;
|
||||
if (!granted) {
|
||||
previewError.value = permissionCanOpenSettings.value
|
||||
? '相机权限已被永久拒绝,请在系统设置中开启后重新预览'
|
||||
: '预览需要相机权限,请授权后重试';
|
||||
return;
|
||||
}
|
||||
final sessionId =
|
||||
'camera-setting-${DateTime.now().microsecondsSinceEpoch}-$generation';
|
||||
_previewSessionId = sessionId;
|
||||
final value = await FaceDiscernPlugs.startCameraSelectionPreview(
|
||||
previewSessionId: sessionId,
|
||||
cameraId: cameraId,
|
||||
);
|
||||
if (!_accepts(generation)) {
|
||||
await _stopPreview(sessionId);
|
||||
return;
|
||||
}
|
||||
preview.value = value;
|
||||
} catch (error, stack) {
|
||||
logger.w('|摄像头设置|打开预览失败:$error|$stack');
|
||||
if (_accepts(generation)) {
|
||||
previewError.value = _message(error, '无法预览摄像头,请检查设备后重试');
|
||||
}
|
||||
} finally {
|
||||
if (_accepts(generation)) isPreviewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 有效候选已经打开且没有并发操作时,才允许确认本次主摄选择。
|
||||
bool get canSave =>
|
||||
selectionOpen.value &&
|
||||
preview.value != null &&
|
||||
!isPreviewing.value &&
|
||||
!isSaving.value;
|
||||
|
||||
/// 提交当前已预览候选;写入失败保留草稿和画面,成功后由弹框关闭流程收尾。
|
||||
Future<bool> saveSelection() async {
|
||||
final current = preview.value;
|
||||
if (!canSave || current == null) return false;
|
||||
isSaving.value = true;
|
||||
previewError.value = '';
|
||||
try {
|
||||
final saved = await FaceDiscernPlugs.setPrimaryCamera(
|
||||
previewSessionId: current.previewSessionId,
|
||||
cameraId: draftCameraId.value,
|
||||
);
|
||||
if (isClosed || !selectionOpen.value) return false;
|
||||
configuration.value = saved;
|
||||
preview.value = null;
|
||||
_previewSessionId = null;
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
logger.w('|摄像头设置|保存主摄失败:$error|$stack');
|
||||
if (!isClosed && selectionOpen.value) {
|
||||
previewError.value = _message(error, '主摄保存失败,请重试');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (!isClosed) isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束弹框时只释放候选,不写入偏好,取消后的下一次识别仍使用原主摄。
|
||||
Future<void> endSelection() async {
|
||||
if (!isClosed) selectionOpen.value = false;
|
||||
await _clearPreview();
|
||||
}
|
||||
|
||||
/// 作废所有在途候选并清理已知原生会话编号,暂停、关闭共用这条清理路径。
|
||||
Future<void> _clearPreview() async {
|
||||
_generation++;
|
||||
final sessionId = _previewSessionId;
|
||||
_previewSessionId = null;
|
||||
if (!isClosed) {
|
||||
preview.value = null;
|
||||
isPreviewing.value = false;
|
||||
}
|
||||
if (sessionId != null) await _stopPreview(sessionId);
|
||||
}
|
||||
|
||||
/// 仅接受当前前台弹框的最新请求,避免授权返回或导航销毁后重新占用相机。
|
||||
bool _accepts(int generation) =>
|
||||
!isClosed &&
|
||||
selectionOpen.value &&
|
||||
_foreground &&
|
||||
generation == _generation;
|
||||
|
||||
/// 识别流中的设置专属错误仅能清理相同编号,迟到的旧错误不能关闭新候选。
|
||||
void _handlePreviewEvent(FaceRecognitionSessionEvent event) {
|
||||
if (isClosed ||
|
||||
event.cameraPreviewSessionId == null ||
|
||||
event.cameraPreviewSessionId != _previewSessionId) {
|
||||
return;
|
||||
}
|
||||
_generation++;
|
||||
_previewSessionId = null;
|
||||
preview.value = null;
|
||||
isPreviewing.value = false;
|
||||
previewError.value = event.message;
|
||||
}
|
||||
|
||||
/// 清理异常只记诊断日志;原生端点销毁和 Activity 暂停另有兜底释放。
|
||||
Future<void> _stopPreview(String sessionId) async {
|
||||
try {
|
||||
await FaceDiscernPlugs.stopCameraSelectionPreview(sessionId);
|
||||
} catch (error) {
|
||||
logger.w('|摄像头设置|释放候选预览失败:$error');
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台错误优先展示原生的恢复建议,其余异常使用稳定中文文案。
|
||||
String _message(Object error, String fallback) =>
|
||||
error is PlatformException ? error.message ?? fallback : fallback;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
library;
|
||||
|
||||
export './controller.dart';
|
||||
export './camera_setting_controller.dart';
|
||||
export './face_manage_controller.dart';
|
||||
export './face_setting_controller.dart';
|
||||
export './offline_user_controller.dart';
|
||||
|
||||
+83
-37
@@ -6,12 +6,23 @@ import 'package:get/get.dart';
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/pages/setting/widgets/index.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/widgets/setting_components.dart';
|
||||
|
||||
import 'index.dart';
|
||||
|
||||
class SettingPage extends GetView<SettingController> {
|
||||
const SettingPage({super.key});
|
||||
|
||||
/// 沿用原有业务设置的设备绑定要求,本地配置与诊断入口继续允许未绑定访问。
|
||||
static const _requiresBinding = {
|
||||
SettingMenuType.syncSetting,
|
||||
SettingMenuType.paySetting,
|
||||
SettingMenuType.faceSetting,
|
||||
SettingMenuType.productSetting,
|
||||
SettingMenuType.facePluginSetting,
|
||||
SettingMenuType.versionSetting,
|
||||
};
|
||||
|
||||
// 主视图
|
||||
Widget _buildView() {
|
||||
final context = Get.context!;
|
||||
@@ -28,7 +39,9 @@ class SettingPage extends GetView<SettingController> {
|
||||
Obx(() => _buildDetail(context, controller.cuurentMenuType.value))
|
||||
.marginSymmetric(horizontal: 10.w)
|
||||
.expanded(),
|
||||
].toRow(crossAxisAlignment: CrossAxisAlignment.stretch).height(double.infinity);
|
||||
]
|
||||
.toRow(crossAxisAlignment: CrossAxisAlignment.stretch)
|
||||
.height(double.infinity);
|
||||
}
|
||||
|
||||
Widget _buildMenu() {
|
||||
@@ -44,14 +57,19 @@ class SettingPage extends GetView<SettingController> {
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
Text(
|
||||
v.title,
|
||||
style: theme.typography.xl.copyWith(
|
||||
color: isSelect
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
).paddingHorizontal(20.w),
|
||||
Flexible(
|
||||
// 窄屏放大字体时只约束长菜单标题,不能把确认图标挤出侧栏。
|
||||
child: Text(
|
||||
v.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.xl.copyWith(
|
||||
color: isSelect
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
).paddingHorizontal(20.w),
|
||||
),
|
||||
isSelect
|
||||
? FIcon(
|
||||
FAssets.icons.check,
|
||||
@@ -85,71 +103,99 @@ class SettingPage extends GetView<SettingController> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 绑定校验的等待、失败与未绑定均显示明确状态,避免用空组件掩盖访问原因。
|
||||
Widget _buildDetail(BuildContext context, SettingMenuType type) {
|
||||
return FutureBuilder(
|
||||
return FutureBuilder<bool>(
|
||||
future: controller.checkIsBind(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox.shrink();
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
SizedBox(height: 16.h),
|
||||
Text('正在读取设备配置…', style: FTheme.of(context).typography.base),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return _accessNotice(
|
||||
message: '设备配置读取失败,请重试',
|
||||
actionLabel: '重试',
|
||||
// 重建当前设置页以重新创建读取 Future,不能继续复用失败的快照。
|
||||
onTap: () => controller.update(['setting']),
|
||||
);
|
||||
}
|
||||
if (snapshot.data != true && _requiresBinding.contains(type)) {
|
||||
return _accessNotice(
|
||||
message: '设备尚未绑定商户,请先在“基本信息”中完成绑定',
|
||||
actionLabel: '前往基本信息',
|
||||
onTap: () => controller.openMenu(SettingMenuType.baseInfo),
|
||||
);
|
||||
}
|
||||
final isBand = snapshot.data!;
|
||||
switch (type) {
|
||||
case SettingMenuType.baseInfo:
|
||||
return settingBaseInfo(context);
|
||||
case SettingMenuType.backup:
|
||||
return settingBackup(context);
|
||||
case SettingMenuType.syncSetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingSync(context);
|
||||
case SettingMenuType.paySetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingPay(context);
|
||||
case SettingMenuType.offlineUserSetting:
|
||||
return settingOfflineUser(context);
|
||||
case SettingMenuType.faceSetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingFace(context);
|
||||
case SettingMenuType.mealPeriodSetting:
|
||||
return const SettingMealPeriods();
|
||||
case SettingMenuType.productSetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingProduct(context);
|
||||
case SettingMenuType.logPreview:
|
||||
return const SettingLogPreview();
|
||||
case SettingMenuType.facePluginSetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingFacePlugin(context);
|
||||
case SettingMenuType.versionSetting:
|
||||
if (!isBand) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return settingVersionSetting(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 复用设置页空状态与操作按钮,为未绑定和读取失败提供可执行的恢复入口。
|
||||
Widget _accessNotice({
|
||||
required String message,
|
||||
required String actionLabel,
|
||||
required VoidCallback onTap,
|
||||
}) =>
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SettingEmpty(message: message),
|
||||
SizedBox(height: 16.h),
|
||||
SettingActionButton(
|
||||
label: actionLabel, onTap: onTap, primary: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<SettingController>(
|
||||
id: "setting",
|
||||
builder: (_) {
|
||||
return FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单
|
||||
// 保留在内容区并直接贴紧顶栏。
|
||||
content: _buildView(),
|
||||
contentPad: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
// FScaffold 不会创建 Material;正文与恢复按钮的 InkWell 需要统一的涟漪宿主。
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: FScaffold(
|
||||
// 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单
|
||||
// 保留在内容区并直接贴紧顶栏。
|
||||
content: _buildView(),
|
||||
contentPad: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import '../index.dart';
|
||||
import 'setting_components.dart';
|
||||
import 'setting_camera.dart';
|
||||
|
||||
/// 绑定接口成功即结束主操作,消息服务连接失败由后台定时任务继续恢复,不能反向判定设备绑定失败。
|
||||
Future<void> _refreshMqAfterBinding() async {
|
||||
@@ -187,6 +188,8 @@ Widget settingBaseInfo(BuildContext context) {
|
||||
),
|
||||
),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
const CameraSettingGroup(),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
_buildMqttGroup(controller, sys),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
SettingGroup(
|
||||
@@ -201,7 +204,6 @@ Widget settingBaseInfo(BuildContext context) {
|
||||
? 'Android 虚拟机'
|
||||
: 'Android 真实设备',
|
||||
),
|
||||
const _CameraSelectionRow(),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.smartphone,
|
||||
label: 'Android 版本',
|
||||
@@ -546,236 +548,3 @@ String _maskIdentifier(String? value) {
|
||||
final suffixLength = text.length >= 16 ? 4 : 3;
|
||||
return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}';
|
||||
}
|
||||
|
||||
/// 识别摄像头人工选择行:双目终端排线装反时元数据无法判定彩色/红外,
|
||||
/// 行内只轻量回显当前选择,帧色度探测在选择弹窗打开时才执行。
|
||||
class _CameraSelectionRow extends StatefulWidget {
|
||||
const _CameraSelectionRow();
|
||||
|
||||
@override
|
||||
State<_CameraSelectionRow> createState() => _CameraSelectionRowState();
|
||||
}
|
||||
|
||||
class _CameraSelectionRowState extends State<_CameraSelectionRow> {
|
||||
/// 当前人工选择;null 表示尚未读取完成。
|
||||
int? _selectedCameraId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelection();
|
||||
}
|
||||
|
||||
Future<void> _loadSelection() async {
|
||||
try {
|
||||
final id = await FaceDiscernPlugs.getCameraSelection();
|
||||
if (mounted) setState(() => _selectedCameraId = id);
|
||||
} catch (error, stack) {
|
||||
logger.w('|基本设置|读取摄像头选择失败error:|$error|stack:|$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
String get _valueLabel {
|
||||
final id = _selectedCameraId;
|
||||
if (id == null) return '读取中';
|
||||
return id < 0 ? '自动' : '摄像头 $id';
|
||||
}
|
||||
|
||||
Future<void> _openSelectionDialog() async {
|
||||
await showAppDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _CameraSelectionDialog(
|
||||
onChanged: _loadSelection,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingInfoRow(
|
||||
icon: FAssets.icons.webcam,
|
||||
label: '识别摄像头',
|
||||
subtitle: '预览为黑白画面时请改选另一个摄像头',
|
||||
value: _valueLabel,
|
||||
actionLabel: '选择',
|
||||
onTap: _openSelectionDialog,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别摄像头选择弹窗:打开时先对每个摄像头做预览帧色度探测(数秒),
|
||||
/// 用"彩色/黑白(红外)"标签辅助人工选择;探测不可用时仍可手动改选。
|
||||
class _CameraSelectionDialog extends StatefulWidget {
|
||||
const _CameraSelectionDialog({required this.onChanged});
|
||||
|
||||
/// 选择保存成功后通知外层行刷新回显。
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
State<_CameraSelectionDialog> createState() => _CameraSelectionDialogState();
|
||||
}
|
||||
|
||||
class _CameraSelectionDialogState extends State<_CameraSelectionDialog> {
|
||||
TerminalCameraSelection? _selection;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChoices();
|
||||
}
|
||||
|
||||
Future<void> _loadChoices() async {
|
||||
try {
|
||||
final selection = await FaceDiscernPlugs.getCameraChoices();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selection = selection;
|
||||
_error = null;
|
||||
});
|
||||
} catch (error, stack) {
|
||||
logger.e('|基本设置|探测摄像头失败error:|$error|stack:|$stack|');
|
||||
if (!mounted) return;
|
||||
setState(() => _error = '摄像头探测失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _select(int cameraId) async {
|
||||
try {
|
||||
await FaceDiscernPlugs.setCameraSelection(cameraId);
|
||||
widget.onChanged();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
Toast.success(msg: cameraId < 0 ? '已恢复自动选择摄像头' : '已选择摄像头 $cameraId');
|
||||
} catch (error, stack) {
|
||||
logger.e('|基本设置|保存摄像头选择失败error:|$error|stack:|$stack|');
|
||||
Toast.error(msg: '保存失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
final selection = _selection;
|
||||
return AppDialog(
|
||||
title: const Text('识别摄像头'),
|
||||
width: 560.w,
|
||||
content: selection == null
|
||||
? ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 180.h),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
SizedBox(height: 14.h),
|
||||
Text(
|
||||
_error ?? '正在逐个探测摄像头,请稍候…',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: _error == null ? XfjDesignTokens.muted : XfjDesignTokens.danger,
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
SizedBox(height: 14.h),
|
||||
FButton(
|
||||
style: FButtonStyle.outline,
|
||||
label: const Text('重试'),
|
||||
onPress: () {
|
||||
setState(() => _error = null);
|
||||
_loadChoices();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'双目终端可能存在彩色/红外排线装反,以探测标签为准选择彩色摄像头;'
|
||||
'预览出现黑白画面时改选另一个即可。',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
_buildOption(
|
||||
theme: theme,
|
||||
title: '自动选择',
|
||||
subtitle: '按设备枚举角色选择(默认)',
|
||||
selected: selection.selectedCameraId < 0,
|
||||
onTap: () => _select(-1),
|
||||
),
|
||||
for (final choice in selection.choices) ...[
|
||||
SizedBox(height: 8.h),
|
||||
_buildOption(
|
||||
theme: theme,
|
||||
title: '摄像头 ${choice.cameraId}',
|
||||
subtitle: '${choice.facingLabel} · ${choice.colorKindLabel}',
|
||||
selected: selection.selectedCameraId == choice.cameraId,
|
||||
onTap: () => _select(choice.cameraId),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 14.h),
|
||||
FButton(
|
||||
style: FButtonStyle.outline,
|
||||
label: const Text('取消'),
|
||||
onPress: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOption({
|
||||
required FThemeData theme,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 64.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? XfjDesignTokens.primary.withValues(alpha: 0.4)
|
||||
: XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
<Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
|
||||
),
|
||||
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(),
|
||||
if (selected)
|
||||
FIcon(
|
||||
FAssets.icons.check,
|
||||
size: 22.w,
|
||||
color: XfjDesignTokens.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
import '../camera_setting_controller.dart';
|
||||
import 'setting_components.dart';
|
||||
|
||||
/// 基本设置中的相机目录与主摄入口,异步状态只重建本分组。
|
||||
class CameraSettingGroup extends GetView<CameraSettingController> {
|
||||
/// 创建由基本设置路由 Binding 提供控制器的摄像头分组。
|
||||
const CameraSettingGroup({super.key});
|
||||
|
||||
/// 以基本信息页相同的设置行展示主摄与目录,卡片始终占满正文宽度。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return SettingGroup(
|
||||
title: '摄像头',
|
||||
child: Obx(() {
|
||||
final config = controller.configuration.value;
|
||||
final loading = controller.isLoading.value;
|
||||
final error = controller.errorMessage.value;
|
||||
final busy = loading || controller.selectionOpen.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.camera,
|
||||
label: '识别摄像头',
|
||||
subtitle: '预览确认彩色画面后再保存,重启后保留选择。',
|
||||
value: config?.primaryLabel ?? (loading ? '正在读取…' : '暂不可用'),
|
||||
trailing: SettingActionButton(
|
||||
label: '选择摄像头',
|
||||
icon: FAssets.icons.camera,
|
||||
primary: true,
|
||||
enabled: !busy && config != null && config.cameras.isNotEmpty,
|
||||
onTap: () => showCameraSelectionDialog(context, controller),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.refreshCw,
|
||||
label: '摄像头列表',
|
||||
subtitle:
|
||||
'最近刷新:${config != null && config.refreshedAt > 0 ? DateUtil.formatDateMs(config.refreshedAt, format: 'yyyy-MM-dd HH:mm:ss') : '尚未刷新'}',
|
||||
value: config == null ? '尚未读取' : '已发现 ${config.cameras.length} 个',
|
||||
showDivider: false,
|
||||
trailing: SettingActionButton(
|
||||
label: loading ? '正在刷新…' : '刷新列表',
|
||||
icon: FAssets.icons.refreshCw,
|
||||
enabled: !busy,
|
||||
onTap: () => controller.loadConfiguration(refresh: true),
|
||||
),
|
||||
),
|
||||
<Widget>[
|
||||
if (config != null && config.cameras.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8.w,
|
||||
runSpacing: 8.h,
|
||||
children: [
|
||||
for (final camera in config.cameras)
|
||||
_cameraSummary(
|
||||
context,
|
||||
camera,
|
||||
selected: camera.cameraId == config.primaryCameraId,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (config != null && config.cameras.isEmpty)
|
||||
Text(
|
||||
'未发现摄像头,请检查设备后刷新列表',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'前置 / 后置仅为系统标记,请以预览颜色为准。',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
),
|
||||
),
|
||||
if (config?.primaryMissing == true) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'已选主摄不在列表中,请刷新或重新选择;识别时将临时使用默认设备。',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (error.isNotEmpty) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
error,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]
|
||||
.toColumn(crossAxisAlignment: CrossAxisAlignment.start)
|
||||
// 与设置行的图标后标签起点对齐:20 内边距 + 40 图标 + 16 间距。
|
||||
.padding(left: 76.w, right: 20.w, bottom: 16.h),
|
||||
],
|
||||
);
|
||||
}),
|
||||
).width(double.infinity);
|
||||
}
|
||||
|
||||
/// 目录项为只读标签,已选主摄同时用文字和底色标记,较窄窗口可自动换行。
|
||||
Widget _cameraSummary(
|
||||
BuildContext context,
|
||||
FaceCameraDevice camera, {
|
||||
required bool selected,
|
||||
}) =>
|
||||
Text(
|
||||
'${camera.displayName} · ${camera.description}${selected ? ' · 已选主摄' : ''}',
|
||||
style: FTheme.of(context).typography.sm.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
).padding(horizontal: 12.w, vertical: 6.h).decorated(
|
||||
color: selected
|
||||
? XfjDesignTokens.primarySoft
|
||||
: XfjDesignTokens.control,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
);
|
||||
}
|
||||
|
||||
/// 在基本设置的 Navigator 内打开预览,取消、路由退出均在 finally 中归还候选资源。
|
||||
Future<void> showCameraSelectionDialog(
|
||||
BuildContext context,
|
||||
CameraSettingController controller,
|
||||
) async {
|
||||
if (!controller.beginSelection()) return;
|
||||
unawaited(controller.previewCamera(controller.draftCameraId.value));
|
||||
try {
|
||||
await showAppDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
barrierDismissible: false,
|
||||
canPop: false,
|
||||
builder: (_) => CameraSelectionDialog(controller: controller),
|
||||
);
|
||||
} finally {
|
||||
await controller.endSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/// 可切换候选、确认彩色画面后保存的识别摄像头弹窗。
|
||||
class CameraSelectionDialog extends StatelessWidget {
|
||||
/// 使用分组同一控制器,避免弹框监听与按钮操作不同的 GetX 实例。
|
||||
const CameraSelectionDialog({super.key, required this.controller});
|
||||
|
||||
/// 基本设置路由持有的相机状态及资源所有者。
|
||||
final CameraSettingController controller;
|
||||
|
||||
/// 构建触控列表和实时预览,窄窗口改为纵向滚动,保存期间禁止重复提交和关闭。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return Obx(() {
|
||||
final saving = controller.isSaving.value;
|
||||
return AppDialog(
|
||||
title: Text('选择摄像头', style: theme.typography.xl2),
|
||||
subtitle: const Text('切换摄像头,确认看到彩色画面后保存'),
|
||||
width: size.width * 0.82,
|
||||
maxWidth: size.width * 0.82,
|
||||
minWidth: 0,
|
||||
height: size.height * 0.86,
|
||||
semanticLabel: '识别摄像头与画面预览',
|
||||
onClose: saving ? null : () => Navigator.of(context).pop(),
|
||||
content: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final options = _options(context);
|
||||
final preview = _preview(context);
|
||||
if (constraints.maxWidth < 700) {
|
||||
return ListView(children: [options, const Divider(), preview]);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SingleChildScrollView(child: options)
|
||||
.width(constraints.maxWidth * 0.32),
|
||||
SizedBox(width: 20.w),
|
||||
SingleChildScrollView(child: preview).expanded(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
FButton(
|
||||
style: FButtonStyle.outline,
|
||||
label: const Text('取消'),
|
||||
onPress: saving ? null : () => Navigator.of(context).pop(),
|
||||
),
|
||||
FButton(
|
||||
label: Text(saving ? '正在保存…' : '保存选择'),
|
||||
onPress: controller.canSave
|
||||
? () async {
|
||||
final saved = await controller.saveSelection();
|
||||
if (saved && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Toast.success(msg: '摄像头已保存,下次人脸识别生效');
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 以真实编号列出候选,选中态先更新再请求硬件,切换期间按钮真实禁用。
|
||||
Widget _options(BuildContext context) => Obx(() {
|
||||
final enabled =
|
||||
!controller.isPreviewing.value && !controller.isSaving.value;
|
||||
final selected = controller.draftCameraId.value;
|
||||
final cameras = controller.configuration.value?.cameras ?? const [];
|
||||
return Column(
|
||||
children: [
|
||||
RadioListTile<int?>(
|
||||
title: const Text('自动(默认规则)'),
|
||||
subtitle: const Text('默认后置,否则使用首个设备'),
|
||||
value: null,
|
||||
groupValue: selected,
|
||||
onChanged: enabled ? controller.previewCamera : null,
|
||||
activeColor: XfjDesignTokens.primary,
|
||||
),
|
||||
for (final camera in cameras)
|
||||
RadioListTile<int?>(
|
||||
title: Text(camera.displayName),
|
||||
subtitle: Text(camera.description),
|
||||
value: camera.cameraId,
|
||||
groupValue: selected,
|
||||
onChanged: enabled ? controller.previewCamera : null,
|
||||
activeColor: XfjDesignTokens.primary,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/// 预览只展示纹理和本地状态,绝不启动识别、人脸库或支付副屏消息。
|
||||
Widget _preview(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return Obx(() {
|
||||
final value = controller.preview.value;
|
||||
final error = controller.previewError.value;
|
||||
final loading = controller.isPreviewing.value;
|
||||
final saving = controller.isSaving.value;
|
||||
return <Widget>[
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: FaceCameraPreview(
|
||||
texture: value?.texture,
|
||||
event: null,
|
||||
status: error.isNotEmpty
|
||||
? FaceRecognitionSessionStatus.failed
|
||||
: FaceRecognitionSessionStatus.initializing,
|
||||
statusText: loading ? '正在切换摄像头…' : '仅预览,不运行人脸识别',
|
||||
statusColor: XfjDesignTokens.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'当前预览:${value == null ? '尚未就绪' : '摄像头 #${value.cameraId}'}。保存前请确认画面为彩色。',
|
||||
style: theme.typography.base,
|
||||
),
|
||||
if (error.isNotEmpty) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
error,
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 12.h),
|
||||
Wrap(
|
||||
spacing: 12.w,
|
||||
runSpacing: 8.h,
|
||||
children: [
|
||||
SettingActionButton(
|
||||
label: loading ? '正在预览…' : '重新预览',
|
||||
enabled: !loading && !saving,
|
||||
onTap: () =>
|
||||
controller.previewCamera(controller.draftCameraId.value),
|
||||
),
|
||||
if (controller.permissionCanOpenSettings.value)
|
||||
SettingActionButton(
|
||||
label: '打开系统设置',
|
||||
enabled: !loading && !saving,
|
||||
onTap: openAppSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
].toColumn(crossAxisAlignment: CrossAxisAlignment.start);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
@@ -17,43 +18,131 @@ import 'setting_cron_rule_dialog.dart';
|
||||
Widget settingSync(BuildContext context) {
|
||||
final controller = Get.find<SettingController>();
|
||||
final scheduler = SyncCheckTool.instance;
|
||||
return Obx(() {
|
||||
final configs = [
|
||||
for (final type in ScheduledTaskType.values)
|
||||
if (scheduler.configs[type] != null) scheduler.configs[type]!,
|
||||
];
|
||||
if (configs.isEmpty) return const SettingEmpty(message: '正在加载同步任务');
|
||||
return <Widget>[
|
||||
SettingGroup(
|
||||
title: '自动任务',
|
||||
child: <Widget>[
|
||||
for (var index = 0; index < configs.length; index++)
|
||||
_ScheduledTaskRow(
|
||||
config: configs[index],
|
||||
record: scheduler.records[configs[index].type],
|
||||
running: scheduler.runningTasks.contains(configs[index].type),
|
||||
pending: scheduler.pendingTasks.contains(configs[index].type),
|
||||
saving:
|
||||
controller.savingScheduledTasks.contains(configs[index].type),
|
||||
showDivider: index != configs.length - 1,
|
||||
controller: controller,
|
||||
),
|
||||
].toColumn(),
|
||||
),
|
||||
_terminalLogSummary(context, scheduler.terminalLogSummary.value),
|
||||
const SettingNotice(
|
||||
text:
|
||||
'定时任务仅在应用进程存活时执行。自动空闲任务要求前台连续 60 秒无操作;手动执行和后台拉取只等待支付、人脸、遮罩和业务弹框结束。',
|
||||
),
|
||||
]
|
||||
.toColumnSpace(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
space: XfjDesignTokens.cardGap.h,
|
||||
)
|
||||
.scrollable();
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
return Obx(() {
|
||||
final configs = [
|
||||
for (final type in ScheduledTaskType.values)
|
||||
if (scheduler.configs[type] != null) scheduler.configs[type]!,
|
||||
];
|
||||
if (configs.isEmpty) return const SettingEmpty(message: '正在加载同步任务');
|
||||
final layout = _ScheduledTaskLayout.resolve(context, configs);
|
||||
return <Widget>[
|
||||
SettingGroup(
|
||||
title: '自动任务',
|
||||
child: <Widget>[
|
||||
for (var index = 0; index < configs.length; index++)
|
||||
_ScheduledTaskRow(
|
||||
config: configs[index],
|
||||
record: scheduler.records[configs[index].type],
|
||||
running: scheduler.runningTasks.contains(configs[index].type),
|
||||
pending: scheduler.pendingTasks.contains(configs[index].type),
|
||||
saving: controller.savingScheduledTasks
|
||||
.contains(configs[index].type),
|
||||
showDivider: index != configs.length - 1,
|
||||
controller: controller,
|
||||
layout: layout,
|
||||
),
|
||||
].toColumn(),
|
||||
),
|
||||
_terminalLogSummary(context, scheduler.terminalLogSummary.value),
|
||||
const SettingNotice(
|
||||
text:
|
||||
'定时任务仅在应用进程存活时执行。自动空闲任务要求前台连续 60 秒无操作;手动执行和后台拉取只等待支付、人脸、遮罩和业务弹框结束。',
|
||||
),
|
||||
]
|
||||
.toColumnSpace(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
space: XfjDesignTokens.cardGap.h,
|
||||
)
|
||||
.scrollable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 以实际字体度量统一控制列,屏幕缩放只影响外壳,不能缩窄文字与原生开关。
|
||||
class _ScheduledTaskLayout {
|
||||
/// 保存同组任务共用的列宽及控件高度,避免不同 Cron 文案造成列错位。
|
||||
const _ScheduledTaskLayout({
|
||||
required this.cronWidth,
|
||||
required this.idleWidth,
|
||||
required this.autoWidth,
|
||||
required this.runWidth,
|
||||
required this.controlHeight,
|
||||
});
|
||||
|
||||
/// 控件之间的固定逻辑像素间距,兼顾触控区和紧凑终端布局。
|
||||
static const gap = 8.0;
|
||||
|
||||
/// 日志入口保持 48dp 点击热区,不随 1920 设计稿缩成小图标按钮。
|
||||
static const logWidth = 48.0;
|
||||
|
||||
/// 正常单行至少为任务名称及最近状态保留的逻辑宽度。
|
||||
static const summaryWidth = 200.0;
|
||||
|
||||
/// 主信息与操作区之间的逻辑像素间距。
|
||||
static const sectionGap = 16.0;
|
||||
|
||||
/// Flutter CupertinoSwitch 的固有布局宽度(含轨道外边距),不是轨道的 51dp。
|
||||
static const switchWidth = 59.0;
|
||||
|
||||
/// 同组最长规则摘要的宽度,自定义长表达式最多占 240dp,并可长按查看完整值。
|
||||
final double cronWidth;
|
||||
|
||||
/// “直接执行 / 仅空闲”文字加原生开关所需的真实宽度。
|
||||
final double idleWidth;
|
||||
|
||||
/// “自动”标签加原生开关所需宽度。
|
||||
final double autoWidth;
|
||||
|
||||
/// 按最长的“再执行一次”预留执行按钮,避免状态变化引起换行。
|
||||
final double runWidth;
|
||||
|
||||
/// 控件高度至少 48dp,放大字体时继续容纳一行文字。
|
||||
final double controlHeight;
|
||||
|
||||
/// 日志、规则及两组开关的总宽度。
|
||||
double get optionsWidth =>
|
||||
logWidth + cronWidth + idleWidth + autoWidth + gap * 3;
|
||||
|
||||
/// 包含固定宽度执行按钮的完整操作区宽度。
|
||||
double get actionsWidth => optionsWidth + gap + runWidth;
|
||||
|
||||
/// 使用当前主题、文本缩放和方向计算,匹配应用默认 1.4 倍及最大 1.5 倍字号。
|
||||
factory _ScheduledTaskLayout.resolve(
|
||||
BuildContext context, List<ScheduledTaskConfig> configs) {
|
||||
final style = FTheme.of(context).typography.sm;
|
||||
final strong = style.copyWith(fontWeight: FontWeight.w700);
|
||||
// 渲染规则时启用了等宽数字,测量也必须使用相同特性,否则含 1 的规则会被低估。
|
||||
final cronStyle =
|
||||
strong.copyWith(fontFeatures: const [FontFeature.tabularFigures()]);
|
||||
Size measure(String text, TextStyle textStyle) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: textStyle),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final size = painter.size;
|
||||
painter.dispose();
|
||||
return size;
|
||||
}
|
||||
|
||||
var cronWidth = 136.0;
|
||||
for (final config in configs) {
|
||||
// 图标 18 + 间距 8 + 两侧内边距 24,再留 4dp 给边框和像素取整。
|
||||
cronWidth = math.max(
|
||||
cronWidth, measure(_cronLabel(config.cron), cronStyle).width + 54);
|
||||
}
|
||||
return _ScheduledTaskLayout(
|
||||
cronWidth: cronWidth.clamp(136.0, 240.0),
|
||||
idleWidth: measure('直接执行', strong).width + switchWidth + gap + 4,
|
||||
autoWidth: measure('自动', strong).width + switchWidth + gap + 4,
|
||||
runWidth: math.max(132, measure('再执行一次', style).width + 54),
|
||||
controlHeight: math.max(48, measure('立即执行', style).height + 16),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个定时任务的紧凑配置行,任务标题区域同时作为执行日志入口。
|
||||
class _ScheduledTaskRow extends StatelessWidget {
|
||||
/// 创建由调度器快照和设置控制器共同驱动的任务行。
|
||||
@@ -65,6 +154,7 @@ class _ScheduledTaskRow extends StatelessWidget {
|
||||
required this.saving,
|
||||
required this.showDivider,
|
||||
required this.controller,
|
||||
required this.layout,
|
||||
});
|
||||
|
||||
/// 当前任务的自动开关、Cron 和空闲策略配置。
|
||||
@@ -88,6 +178,9 @@ class _ScheduledTaskRow extends StatelessWidget {
|
||||
/// 当前设置页 Binding 注册的控制器,负责保存配置和立即执行。
|
||||
final SettingController controller;
|
||||
|
||||
/// 当前字号下整组任务共用的列宽与控件高度。
|
||||
final _ScheduledTaskLayout layout;
|
||||
|
||||
/// 构建日志入口、Cron 规则、空闲开关、自动开关和立即执行操作。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -126,79 +219,151 @@ class _ScheduledTaskRow extends StatelessWidget {
|
||||
].toRowSpace(space: 10.w).padding(vertical: 4.h),
|
||||
),
|
||||
);
|
||||
final taskActions = <Widget>[
|
||||
SizedBox(width: 8.w),
|
||||
_TaskLogButton(
|
||||
taskTitle: metadata.title,
|
||||
onTap: () => unawaited(SettingNavigation.openTaskLogs(config.type)),
|
||||
final options = <Widget>[
|
||||
SizedBox(
|
||||
width: _ScheduledTaskLayout.logWidth,
|
||||
height: layout.controlHeight,
|
||||
child: _TaskLogButton(
|
||||
taskTitle: metadata.title,
|
||||
onTap: () => unawaited(SettingNavigation.openTaskLogs(config.type)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
_CronRuleButton(
|
||||
key: ValueKey('scheduled-task-cron-${config.type.name}'),
|
||||
expression: config.cron,
|
||||
width: layout.cronWidth,
|
||||
height: layout.controlHeight,
|
||||
enabled: !saving,
|
||||
onTap: () => _selectCron(context),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
_TaskSwitch(
|
||||
key: ValueKey('scheduled-task-idle-${config.type.name}'),
|
||||
label: config.idleOnly ? '仅空闲' : '直接执行',
|
||||
semanticLabel: '${metadata.title}仅空闲执行',
|
||||
cellWidth: 144.w,
|
||||
cellWidth: layout.idleWidth,
|
||||
height: layout.controlHeight,
|
||||
value: config.idleOnly,
|
||||
enabled: !saving,
|
||||
onChange: (value) =>
|
||||
controller.setScheduledTaskIdleOnly(config.type, value),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
_TaskSwitch(
|
||||
key: ValueKey('scheduled-task-auto-${config.type.name}'),
|
||||
label: '自动',
|
||||
semanticLabel: '${metadata.title}自动执行',
|
||||
cellWidth: 112.w,
|
||||
cellWidth: layout.autoWidth,
|
||||
height: layout.controlHeight,
|
||||
value: config.enabled,
|
||||
enabled: !saving,
|
||||
onChange: (value) =>
|
||||
controller.setScheduledTaskEnabled(config.type, value),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(width: 10.w),
|
||||
FButton(
|
||||
];
|
||||
final runButton = SizedBox(
|
||||
key: ValueKey('scheduled-task-run-${config.type.name}'),
|
||||
width: layout.runWidth,
|
||||
height: layout.controlHeight,
|
||||
// FButton 默认内容 Row 会撑满父级;始终给予确定宽度,不放入无宽度约束的 Wrap。
|
||||
child: FButton.raw(
|
||||
style: FButtonStyle.outline,
|
||||
onPress: saving
|
||||
? null
|
||||
: () => unawaited(
|
||||
controller.runScheduledTaskNow(config.type),
|
||||
),
|
||||
prefix: running
|
||||
? SizedBox.square(
|
||||
dimension: 18.w,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: FIcon(FAssets.icons.refreshCw, size: 18.sp),
|
||||
label: Text(
|
||||
running
|
||||
? '执行中'
|
||||
: pending
|
||||
? '再执行一次'
|
||||
: '立即执行',
|
||||
style: rowTextStyle,
|
||||
),
|
||||
).constrained(minWidth: 138.w, minHeight: 48.h),
|
||||
].toRow();
|
||||
return <Widget>[
|
||||
taskSummary.expanded(flex: 5),
|
||||
taskActions.expanded(flex: 5),
|
||||
]
|
||||
.toRow()
|
||||
.padding(horizontal: 18.w, vertical: 10.h)
|
||||
.constrained(minHeight: 72.h)
|
||||
.decorated(
|
||||
border: showDivider
|
||||
? const Border(
|
||||
bottom: BorderSide(color: XfjDesignTokens.line),
|
||||
: () => unawaited(controller.runScheduledTaskNow(config.type)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (running)
|
||||
const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
else
|
||||
FIcon(FAssets.icons.refreshCw,
|
||||
size: 18,
|
||||
color:
|
||||
saving ? XfjDesignTokens.muted : XfjDesignTokens.ink),
|
||||
const SizedBox(width: _ScheduledTaskLayout.gap),
|
||||
Text(
|
||||
running
|
||||
? '执行中'
|
||||
: pending
|
||||
? '再执行一次'
|
||||
: '立即执行',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: rowTextStyle.copyWith(
|
||||
color:
|
||||
saving ? XfjDesignTokens.muted : XfjDesignTokens.ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
key: ValueKey('scheduled-task-row-${config.type.name}'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
decoration: BoxDecoration(
|
||||
border: showDivider
|
||||
? const Border(bottom: BorderSide(color: XfjDesignTokens.line))
|
||||
: null,
|
||||
),
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
if (constraints.maxWidth >=
|
||||
_ScheduledTaskLayout.summaryWidth +
|
||||
_ScheduledTaskLayout.sectionGap +
|
||||
layout.actionsWidth) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: taskSummary),
|
||||
const SizedBox(width: _ScheduledTaskLayout.sectionGap),
|
||||
..._spaced([...options, runButton]),
|
||||
],
|
||||
);
|
||||
}
|
||||
// 窄屏只在固定边界分两行:第一行信息与执行,第二行规则和开关,不随控件随机折行。
|
||||
final header = constraints.maxWidth >= layout.runWidth + 136
|
||||
? Row(children: [
|
||||
Expanded(child: taskSummary),
|
||||
const SizedBox(width: 16),
|
||||
runButton
|
||||
])
|
||||
: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
taskSummary,
|
||||
const SizedBox(height: 8),
|
||||
Align(alignment: Alignment.centerRight, child: runButton)
|
||||
]);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
header,
|
||||
const SizedBox(height: 8),
|
||||
if (constraints.maxWidth >= layout.optionsWidth)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: _spaced(options))
|
||||
else
|
||||
Wrap(
|
||||
spacing: _ScheduledTaskLayout.gap,
|
||||
runSpacing: 8,
|
||||
children: options),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// 为有确定宽度的控件插入统一间距,不引入会争夺剩余空间的 Spacer。
|
||||
List<Widget> _spaced(List<Widget> children) => [
|
||||
for (var index = 0; index < children.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _ScheduledTaskLayout.gap),
|
||||
children[index],
|
||||
],
|
||||
];
|
||||
|
||||
/// 打开规则弹窗,并在用户确认了不同表达式时交由 Controller 二次校验和保存。
|
||||
Future<void> _selectCron(BuildContext context) async {
|
||||
final expression = await showScheduledCronRuleDialog(
|
||||
@@ -231,8 +396,8 @@ class _TaskLogButton extends StatelessWidget {
|
||||
child: FIcon(
|
||||
FAssets.icons.scrollText,
|
||||
color: XfjDesignTokens.primary,
|
||||
size: 24.sp,
|
||||
).center().constrained(width: 48.w, height: 48.h).decorated(
|
||||
size: 22,
|
||||
).center().decorated(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
@@ -246,7 +411,10 @@ class _TaskLogButton extends StatelessWidget {
|
||||
class _CronRuleButton extends StatelessWidget {
|
||||
/// 创建一个固定尺寸的 Cron 规则按钮。
|
||||
const _CronRuleButton({
|
||||
super.key,
|
||||
required this.expression,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
@@ -254,6 +422,12 @@ class _CronRuleButton extends StatelessWidget {
|
||||
/// 当前任务已保存的六段 Cron 表达式。
|
||||
final String expression;
|
||||
|
||||
/// 同组按最长规则摘要计算的逻辑宽度。
|
||||
final double width;
|
||||
|
||||
/// 与开关和执行按钮对齐的逻辑高度。
|
||||
final double height;
|
||||
|
||||
/// 是否允许打开规则弹窗;配置保存期间为假。
|
||||
final bool enabled;
|
||||
|
||||
@@ -265,7 +439,7 @@ class _CronRuleButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return Tooltip(
|
||||
message: '设置 Cron 定时规则',
|
||||
message: '设置 Cron 定时规则:${_cronLabel(expression)}\n$expression',
|
||||
child: InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
@@ -273,7 +447,7 @@ class _CronRuleButton extends StatelessWidget {
|
||||
FIcon(
|
||||
FAssets.icons.clock,
|
||||
color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted,
|
||||
size: 16.sp,
|
||||
size: 18,
|
||||
),
|
||||
Text(
|
||||
_cronLabel(expression),
|
||||
@@ -288,9 +462,9 @@ class _CronRuleButton extends StatelessWidget {
|
||||
),
|
||||
).expanded(),
|
||||
]
|
||||
.toRowSpace(space: 6.w)
|
||||
.padding(horizontal: 9.w)
|
||||
.constrained(width: 158.w, height: 36.h)
|
||||
.toRowSpace(space: 8)
|
||||
.padding(horizontal: 12)
|
||||
.constrained(width: width, height: height)
|
||||
.decorated(
|
||||
color: XfjDesignTokens.primarySoft,
|
||||
border: Border.all(
|
||||
@@ -299,9 +473,7 @@ class _CronRuleButton extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
)
|
||||
.center()
|
||||
.constrained(width: 164.w, height: 48.h),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -311,7 +483,9 @@ class _CronRuleButton extends StatelessWidget {
|
||||
class _TaskSwitch extends StatelessWidget {
|
||||
/// 创建一个由父任务行管理值与保存行为的开关。
|
||||
const _TaskSwitch({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.height,
|
||||
required this.semanticLabel,
|
||||
required this.cellWidth,
|
||||
required this.value,
|
||||
@@ -328,6 +502,9 @@ class _TaskSwitch extends StatelessWidget {
|
||||
/// 本列固定宽度,分别为执行策略和自动开关保留不换行空间。
|
||||
final double cellWidth;
|
||||
|
||||
/// 当前字号下同组统一的控件高度。
|
||||
final double height;
|
||||
|
||||
/// 当前开关值。
|
||||
final bool value;
|
||||
|
||||
@@ -345,7 +522,7 @@ class _TaskSwitch extends StatelessWidget {
|
||||
// 保留垂直留白,使开关本身仍具有 48dp 的稳定触控高度。
|
||||
final switchStyle = theme.switchStyle.copyWith(
|
||||
labelLayoutStyle: theme.switchStyle.labelLayoutStyle.copyWith(
|
||||
childPadding: EdgeInsets.symmetric(vertical: 4.5.h),
|
||||
childPadding: const EdgeInsets.symmetric(vertical: 4.5),
|
||||
),
|
||||
);
|
||||
return <Widget>[
|
||||
@@ -360,15 +537,18 @@ class _TaskSwitch extends StatelessWidget {
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
).expanded(),
|
||||
SizedBox(width: 6.w),
|
||||
FSwitch(
|
||||
style: switchStyle,
|
||||
semanticLabel: semanticLabel,
|
||||
value: value,
|
||||
enabled: enabled,
|
||||
onChange: onChange,
|
||||
const SizedBox(width: _ScheduledTaskLayout.gap),
|
||||
SizedBox(
|
||||
width: _ScheduledTaskLayout.switchWidth,
|
||||
child: FSwitch(
|
||||
style: switchStyle,
|
||||
semanticLabel: semanticLabel,
|
||||
value: value,
|
||||
enabled: enabled,
|
||||
onChange: onChange,
|
||||
),
|
||||
),
|
||||
].toRow().constrained(width: cellWidth, minHeight: 48.h);
|
||||
].toRow().constrained(width: cellWidth, height: height);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:pj_supermarket_cloud/pages/order_history/view.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/order_history_detail/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/camera_setting_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/offline_user_controller.dart';
|
||||
@@ -330,6 +331,7 @@ final Map<String, _TerminalRouteConfig> _terminalRoutes = {
|
||||
Get.lazyPut(() => SettingController(), fenix: true);
|
||||
Get.lazyPut(() => FaceManageController(), fenix: true);
|
||||
Get.lazyPut(() => FaceSettingController(), fenix: true);
|
||||
Get.lazyPut(() => CameraSettingController(), fenix: true);
|
||||
Get.lazyPut(() => OfflineUserController(), fenix: true);
|
||||
}),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pj_supermarket_cloud
|
||||
description: "智慧后勤-测试"
|
||||
publish_to: "none"
|
||||
version: 2.1.9+219
|
||||
version: 2.2.0+220
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/index.dart';
|
||||
|
||||
/// 通过真实设置页面覆盖设备未绑定、读取中、读取失败及重试,避免白屏回归。
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late _BindingTestController controller;
|
||||
|
||||
setUpAll(() async {
|
||||
// 使用工程实际的 Inter 度量,不能用 Widget 测试默认的等宽 Ahem 替代应用字体。
|
||||
final loader = FontLoader('packages/forui/Inter');
|
||||
for (final weight in ['Regular', 'SemiBold', 'Bold']) {
|
||||
loader.addFont(rootBundle
|
||||
.load('packages/forui/assets/fonts/inter/Inter-$weight.ttf'));
|
||||
}
|
||||
// 可选加载设备上的中文字体用于本地 PNG 目视检查,CI 不依赖外部字体文件。
|
||||
final chineseFont = Platform.environment['PJ_SETTING_TEST_FONT'];
|
||||
if (chineseFont != null) {
|
||||
final chineseLoader = FontLoader('PJSettingCjk')
|
||||
..addFont(File(chineseFont).readAsBytes().then(ByteData.sublistView));
|
||||
await chineseLoader.load();
|
||||
}
|
||||
await loader.load();
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
Get.testMode = true;
|
||||
Get.put<AppControl>(_BindingTestApp());
|
||||
controller = _BindingTestController();
|
||||
controller.cuurentMenuType.value = SettingMenuType.syncSetting;
|
||||
Get.put<SettingController>(controller);
|
||||
SyncCheckTool.instance.configs.clear();
|
||||
SyncCheckTool.instance.records.clear();
|
||||
SyncCheckTool.instance.pendingTasks.clear();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Get.reset();
|
||||
SyncCheckTool.instance.configs.clear();
|
||||
SyncCheckTool.instance.records.clear();
|
||||
SyncCheckTool.instance.pendingTasks.clear();
|
||||
});
|
||||
|
||||
Future<void> mount(
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(1280, 720),
|
||||
double fontScale = 1.4,
|
||||
}) async {
|
||||
// 真机为 1920×1080、240dpi:1280×720 逻辑尺寸。用真实 View 指标初始化 ScreenUtil。
|
||||
tester.view.devicePixelRatio = 1.5;
|
||||
tester.view.physicalSize = Size(size.width * 1.5, size.height * 1.5);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(1920, 1080),
|
||||
minTextAdapt: true,
|
||||
splitScreenMode: true,
|
||||
builder: (_, __) {
|
||||
final base = FThemes.zinc.light;
|
||||
final scaled = base.typography.scale(sizeScalar: fontScale);
|
||||
TextStyle withFallback(TextStyle style) =>
|
||||
style.copyWith(fontFamilyFallback: const ['PJSettingCjk']);
|
||||
final theme = FThemeData.inherit(
|
||||
colorScheme: base.colorScheme,
|
||||
typography: scaled.copyWith(
|
||||
xs: withFallback(scaled.xs),
|
||||
sm: withFallback(scaled.sm),
|
||||
base: withFallback(scaled.base),
|
||||
lg: withFallback(scaled.lg),
|
||||
xl: withFallback(scaled.xl),
|
||||
xl2: withFallback(scaled.xl2),
|
||||
xl3: withFallback(scaled.xl3),
|
||||
xl4: withFallback(scaled.xl4),
|
||||
xl5: withFallback(scaled.xl5),
|
||||
xl6: withFallback(scaled.xl6),
|
||||
xl7: withFallback(scaled.xl7),
|
||||
xl8: withFallback(scaled.xl8),
|
||||
),
|
||||
style: base.style.copyWith(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12.w)),
|
||||
borderWidth: 1,
|
||||
iconStyle: base.style.iconStyle
|
||||
.copyWith(size: base.typography.xl2.fontSize),
|
||||
pagePadding: EdgeInsets.symmetric(vertical: 5.h, horizontal: 1.w),
|
||||
),
|
||||
);
|
||||
return GetMaterialApp(
|
||||
builder: (_, child) => FTheme(data: theme, child: child!),
|
||||
// 与实际终端一致,由设置页提供自己的 Material,不借测试外壳掩盖正文问题。
|
||||
home: const RepaintBoundary(
|
||||
key: ValueKey('setting-layout-capture'),
|
||||
child: SettingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 保存当前真实 Widget 树的渲染图,只有设置本地输出目录时才写入,默认测试无文件副作用。
|
||||
Future<void> capture(WidgetTester tester, String name) async {
|
||||
final directory = Platform.environment['PJ_SETTING_SCREENSHOT_DIR'];
|
||||
if (directory == null) return;
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(const ValueKey('setting-layout-capture')));
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage(pixelRatio: 1.5);
|
||||
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
await File('$directory/$name.png')
|
||||
.writeAsBytes(bytes!.buffer.asUint8List());
|
||||
image.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// 完整默认目录用于检查各列对齐,并包含成功、失败等不同长度的摘要。
|
||||
void prepareTasks() {
|
||||
controller.readBinding = () async => true;
|
||||
SyncCheckTool.instance.configs.assignAll({
|
||||
for (final config in SyncCheckTool.defaultConfigs) config.type: config,
|
||||
});
|
||||
SyncCheckTool.instance.records.assignAll({
|
||||
ScheduledTaskType.mqReconnect:
|
||||
const ScheduledTaskRecord(success: false, message: '消息服务暂未连接,请检查网络'),
|
||||
ScheduledTaskType.printCheck: ScheduledTaskRecord(
|
||||
lastRunAt: DateTime(2026, 10, 28, 11, 12), message: '打印任务检查完成'),
|
||||
});
|
||||
}
|
||||
|
||||
/// 验证规则与开关文案实际没有发生省略,不能只检查 Flutter 没有抛出布局异常。
|
||||
void expectReadable(WidgetTester tester, ScheduledTaskType type) {
|
||||
for (final part in ['cron', 'idle', 'auto']) {
|
||||
final text = find.descendant(
|
||||
of: find.byKey(ValueKey('scheduled-task-$part-${type.name}')),
|
||||
matching: find.byType(Text),
|
||||
);
|
||||
expect(text, findsOneWidget);
|
||||
expect(
|
||||
tester.renderObject<RenderParagraph>(text).didExceedMaxLines, isFalse,
|
||||
reason: '${type.name} 的 $part 文字被截断');
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('未绑定的定时任务页说明原因,并能返回基本信息', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('设备尚未绑定商户,请先在“基本信息”中完成绑定'), findsOneWidget);
|
||||
expect(find.text('自动任务'), findsNothing);
|
||||
await tester.tap(find.text('前往基本信息'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.cuurentMenuType.value, SettingMenuType.baseInfo);
|
||||
expect(find.text('暂无终端信息'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('绑定状态尚未读完时显示加载提示', (tester) async {
|
||||
final pending = Completer<bool>();
|
||||
controller.readBinding = () => pending.future;
|
||||
await mount(tester);
|
||||
expect(find.text('正在读取设备配置…'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
pending.complete(false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('前往基本信息'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('配置读取失败显示错误,重试成功后能进入定时任务正文', (tester) async {
|
||||
controller.readBinding = () => Future<bool>.error(StateError('读取失败'));
|
||||
await mount(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('设备配置读取失败,请重试'), findsOneWidget);
|
||||
controller.readBinding = () async => true;
|
||||
await tester.tap(find.text('重试'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('正在加载同步任务'), findsOneWidget);
|
||||
expect(find.text('设备配置读取失败,请重试'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('绑定完成后重新进入定时任务会重新校验', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('前往基本信息'));
|
||||
await tester.pumpAndSettle();
|
||||
controller.readBinding = () async => true;
|
||||
controller.openMenu(SettingMenuType.syncSetting);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('正在加载同步任务'), findsOneWidget);
|
||||
expect(find.text('前往基本信息'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('已有绑定且任务配置存在时可渲染任务内容', (tester) async {
|
||||
controller.readBinding = () async => true;
|
||||
SyncCheckTool.instance.configs.assignAll({
|
||||
for (final config in SyncCheckTool.defaultConfigs) config.type: config,
|
||||
});
|
||||
await mount(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('自动任务'), findsOneWidget);
|
||||
expect(find.text('立即执行'), findsNWidgets(ScheduledTaskType.values.length));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final scale in [1.2, 1.4, 1.5]) {
|
||||
testWidgets('1280 逻辑宽度与 $scale 倍字号保持单行、固定按钮和完整文字', (tester) async {
|
||||
prepareTasks();
|
||||
await mount(tester, fontScale: scale);
|
||||
await tester.pumpAndSettle();
|
||||
final type = ScheduledTaskType.mqReconnect;
|
||||
final run = tester
|
||||
.getRect(find.byKey(ValueKey('scheduled-task-run-${type.name}')));
|
||||
final cron = tester
|
||||
.getRect(find.byKey(ValueKey('scheduled-task-cron-${type.name}')));
|
||||
expect(run.width, lessThan(220), reason: '执行按钮不应扩展成整行');
|
||||
expect(run.center.dy, closeTo(cron.center.dy, 1),
|
||||
reason: '正常宽度下执行按钮与规则应在同一行');
|
||||
final nextCron = tester.getRect(
|
||||
find.byKey(const ValueKey('scheduled-task-cron-printCheck')));
|
||||
expect(cron.left, closeTo(nextCron.left, 1), reason: '各任务列应对齐');
|
||||
for (final type in ScheduledTaskType.values) {
|
||||
expectReadable(tester, type);
|
||||
}
|
||||
if (scale == 1.4) await capture(tester, 'scheduled-tasks-1280-font-1.4');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('1024 窄屏使用明确的双行布局,执行按钮保持原宽且规则不截断', (tester) async {
|
||||
prepareTasks();
|
||||
SyncCheckTool.instance.pendingTasks.add(ScheduledTaskType.mqReconnect);
|
||||
await mount(tester, size: const Size(1024, 768), fontScale: 1.5);
|
||||
await tester.pumpAndSettle();
|
||||
final run = tester
|
||||
.getRect(find.byKey(const ValueKey('scheduled-task-run-mqReconnect')));
|
||||
final cron = tester
|
||||
.getRect(find.byKey(const ValueKey('scheduled-task-cron-mqReconnect')));
|
||||
expect(run.width, lessThan(220));
|
||||
expect(cron.top, greaterThan(run.top), reason: '窄屏规则移至明确的第二行');
|
||||
expect(find.text('再执行一次'), findsOneWidget);
|
||||
expectReadable(tester, ScheduledTaskType.mqReconnect);
|
||||
await capture(tester, 'scheduled-tasks-1024-font-1.5');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// 避免测试启动 AppControl 的硬件、数据库和后台调度。
|
||||
class _BindingTestApp extends AppControl {
|
||||
/// 不启动真实设备资源。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onInit() {}
|
||||
|
||||
/// 不执行真实应用就绪动作。
|
||||
@override
|
||||
void onReady() {}
|
||||
|
||||
/// 当前实例没有硬件句柄,不走生产释放流程。
|
||||
@override
|
||||
void onClose() {}
|
||||
}
|
||||
|
||||
/// 只替换绑定信息的读取,其余菜单切换和实际页面构建均使用正式代码。
|
||||
class _BindingTestController extends SettingController {
|
||||
/// 默认模拟本机尚未关联商户,测试可替换为未完成或失败的读取。
|
||||
Future<bool> Function() readBinding = () async => false;
|
||||
|
||||
/// 不从测试环境读取 SQLite 或初始化 Cron。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onInit() {}
|
||||
|
||||
/// 页面由正式 GetBuilder 驱动,不额外触发应用启动动作。
|
||||
@override
|
||||
void onReady() {}
|
||||
|
||||
/// 只释放已创建的字号控制器,未创建的 MQTT 订阅不应访问。
|
||||
@override
|
||||
void onClose() => globalFontSizeC.dispose();
|
||||
|
||||
/// 返回受控的本机绑定状态,不改变正式判断规则。
|
||||
@override
|
||||
Future<bool> checkIsBind() => readBinding();
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/camera_setting_controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/widgets/setting_camera.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/setting/widgets/setting_components.dart';
|
||||
|
||||
/// 通过真实点击路径核对候选草稿、保存/取消、权限及异步清理,不连接相机或虹软。
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
const eventChannel = MethodChannel('local/face_discern_session_event');
|
||||
late List<MethodCall> calls;
|
||||
late List<Map<String, dynamic>> cameras;
|
||||
int? savedCameraId;
|
||||
var failRefresh = false;
|
||||
var failSave = false;
|
||||
Completer<Map<String, dynamic>>? pendingPreview;
|
||||
|
||||
Map<String, dynamic> snapshot() => {
|
||||
'cameras': cameras,
|
||||
'primaryCameraId': savedCameraId,
|
||||
'refreshedAt': 1792454400000,
|
||||
};
|
||||
|
||||
Map<String, dynamic> previewMetadata(MethodCall call) => {
|
||||
'previewSessionId': call.arguments['previewSessionId'],
|
||||
'textureId': calls.length,
|
||||
'cameraId': call.arguments['cameraId'] ?? 0,
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'rotation': 0,
|
||||
'mirror': false,
|
||||
};
|
||||
|
||||
setUp(() {
|
||||
Get.testMode = true;
|
||||
calls = [];
|
||||
cameras = [
|
||||
{'cameraId': 0, 'facing': 0, 'orientation': 0},
|
||||
{'cameraId': 1, 'facing': 1, 'orientation': 0},
|
||||
];
|
||||
savedCameraId = 0;
|
||||
failRefresh = false;
|
||||
failSave = false;
|
||||
pendingPreview = null;
|
||||
messenger.setMockMethodCallHandler(eventChannel, (_) async => null);
|
||||
messenger.setMockMethodCallHandler(FaceDiscernPlugs.getChannel,
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'getCameraConfiguration':
|
||||
return snapshot();
|
||||
case 'refreshCameraList':
|
||||
if (failRefresh) {
|
||||
throw PlatformException(
|
||||
code: 'CAMERA_LIST_FAILED', message: '刷新失败,保留原目录');
|
||||
}
|
||||
return snapshot();
|
||||
case 'startCameraSelectionPreview':
|
||||
return pendingPreview == null
|
||||
? previewMetadata(call)
|
||||
: await pendingPreview!.future;
|
||||
case 'stopCameraSelectionPreview':
|
||||
return null;
|
||||
case 'setPrimaryCamera':
|
||||
if (failSave) {
|
||||
throw PlatformException(
|
||||
code: 'SAVE_PRIMARY_CAMERA_FAILED', message: '主摄保存失败,请重试');
|
||||
}
|
||||
savedCameraId = call.arguments['cameraId'] as int?;
|
||||
return snapshot();
|
||||
default:
|
||||
throw PlatformException(
|
||||
code: 'UNEXPECTED_CALL', message: call.method);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
FToast().removeCustomToast();
|
||||
FToast().removeQueuedCustomToasts();
|
||||
Get.reset();
|
||||
messenger.setMockMethodCallHandler(FaceDiscernPlugs.getChannel, null);
|
||||
messenger.setMockMethodCallHandler(eventChannel, null);
|
||||
});
|
||||
|
||||
Future<_GrantedCameraController> mount(
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(1920, 1080),
|
||||
double textScale = 1,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(size);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final controller = _GrantedCameraController();
|
||||
Get.put<CameraSettingController>(controller);
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(1920, 1080),
|
||||
builder: (_, __) => GetMaterialApp(
|
||||
builder: (context, child) => FTheme(
|
||||
data: FThemes.zinc.light,
|
||||
child: FToastBuilder()(
|
||||
context,
|
||||
MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(textScale),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
home: const Scaffold(
|
||||
body: SingleChildScrollView(child: CameraSettingGroup()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<void> openSelection(WidgetTester tester) async {
|
||||
await tester.tap(find.text('选择摄像头'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('切换更新选中态及画面编号,取消保留旧主摄且不启动识别', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
await tester.tap(find.text('摄像头 #1'));
|
||||
await tester.pumpAndSettle();
|
||||
final tile = tester.widget<RadioListTile<int?>>(
|
||||
find.ancestor(
|
||||
of: find.text('摄像头 #1'), matching: find.byType(RadioListTile<int?>)),
|
||||
);
|
||||
expect(tile.groupValue, 1);
|
||||
expect(controller.preview.value?.cameraId, 1);
|
||||
expect(savedCameraId, 0);
|
||||
expect(calls.where((call) => call.method == 'startRecognition'), isEmpty);
|
||||
expect(calls.where((call) => call.method == 'setPrimaryCamera'), isEmpty);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.selectionOpen.value, isFalse);
|
||||
expect(controller.preview.value, isNull);
|
||||
expect(savedCameraId, 0);
|
||||
expect(
|
||||
calls
|
||||
.where((call) => call.method == 'stopCameraSelectionPreview')
|
||||
.length,
|
||||
greaterThanOrEqualTo(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('保存失败保留画面和原选择,重试保存后重新读取能恢复', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
await tester.tap(find.text('摄像头 #1'));
|
||||
await tester.pumpAndSettle();
|
||||
failSave = true;
|
||||
await tester.tap(find.text('保存选择'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('主摄保存失败,请重试'), findsOneWidget);
|
||||
expect(controller.preview.value?.cameraId, 1);
|
||||
expect(savedCameraId, 0);
|
||||
failSave = false;
|
||||
await tester.tap(find.text('保存选择'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(savedCameraId, 1);
|
||||
expect(find.byType(CameraSelectionDialog), findsNothing);
|
||||
controller.configuration.value = null;
|
||||
await controller.loadConfiguration();
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byWidgetPredicate((widget) =>
|
||||
widget is SettingInfoRow &&
|
||||
widget.label == '识别摄像头' &&
|
||||
widget.value == '摄像头 #1'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('刷新失败保留缓存,成功的空列表禁用识别摄像头并保留失联偏好', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
failRefresh = true;
|
||||
await tester.tap(find.text('刷新列表'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('刷新失败,保留原目录'), findsOneWidget);
|
||||
expect(controller.configuration.value?.cameras.length, 2);
|
||||
failRefresh = false;
|
||||
cameras = [];
|
||||
await tester.tap(find.text('刷新列表'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('未发现摄像头,请检查设备后刷新列表'), findsOneWidget);
|
||||
final select = tester
|
||||
.widgetList<SettingActionButton>(find.byType(SettingActionButton))
|
||||
.singleWhere((button) => button.label == '选择摄像头');
|
||||
expect(select.enabled, isFalse);
|
||||
expect(controller.configuration.value?.primaryMissing, isTrue);
|
||||
expect(savedCameraId, 0);
|
||||
});
|
||||
|
||||
testWidgets('候选创建期间取消会释放迟到纹理且不改变主摄', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
pendingPreview = Completer<Map<String, dynamic>>();
|
||||
await tester.tap(find.text('摄像头 #1'));
|
||||
await tester.pump();
|
||||
final pendingCall =
|
||||
calls.lastWhere((call) => call.method == 'startCameraSelectionPreview');
|
||||
expect(controller.isPreviewing.value, isTrue);
|
||||
expect(controller.canSave, isFalse);
|
||||
expect(
|
||||
tester.widget<FButton>(find.widgetWithText(FButton, '保存选择')).onPress,
|
||||
isNull,
|
||||
);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
pendingPreview!.complete(previewMetadata(pendingCall));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.preview.value, isNull);
|
||||
expect(controller.selectionOpen.value, isFalse);
|
||||
expect(savedCameraId, 0);
|
||||
expect(
|
||||
calls.where((call) =>
|
||||
call.method == 'stopCameraSelectionPreview' &&
|
||||
call.arguments['previewSessionId'] ==
|
||||
pendingCall.arguments['previewSessionId']),
|
||||
isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('后台释放候选,前台重试后重新获得可保存的画面', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
controller.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
await tester.pump();
|
||||
expect(controller.preview.value, isNull);
|
||||
expect(controller.canSave, isFalse);
|
||||
controller.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
await tester.ensureVisible(find.text('重新预览'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('重新预览'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.preview.value, isNotNull);
|
||||
expect(controller.canSave, isTrue);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('权限永久拒绝展示设置入口,授权重试之前不能保存', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
controller.granted = false;
|
||||
await openSelection(tester);
|
||||
expect(find.text('打开系统设置'), findsOneWidget);
|
||||
expect(controller.canSave, isFalse);
|
||||
expect(calls.where((call) => call.method == 'startCameraSelectionPreview'),
|
||||
isEmpty);
|
||||
controller.granted = true;
|
||||
await tester.ensureVisible(find.text('重新预览'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('重新预览'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.canSave, isTrue);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('自动模式先预览默认设备,保存后重新创建控制器仍回显自动', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
await tester.tap(find.text('自动(默认规则)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.preview.value?.cameraId, 0);
|
||||
expect(savedCameraId, 0);
|
||||
await tester.tap(find.text('保存选择'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(savedCameraId, isNull);
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
Get.delete<CameraSettingController>();
|
||||
final restored = await mount(tester);
|
||||
expect(restored.configuration.value?.primaryCameraId, isNull);
|
||||
expect(find.text('自动(默认规则)'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('迟到的旧设备错误不关闭新候选,当前设备断开则禁止保存', (tester) async {
|
||||
final controller = await mount(tester);
|
||||
await openSelection(tester);
|
||||
final previous = controller.preview.value!.previewSessionId;
|
||||
await tester.tap(find.text('摄像头 #1'));
|
||||
await tester.pumpAndSettle();
|
||||
final current = controller.preview.value!.previewSessionId;
|
||||
|
||||
void emitFailure(String sessionId) => controller.events.add(
|
||||
FaceRecognitionSessionEvent.fromMap({
|
||||
'cameraPreviewSessionId': sessionId,
|
||||
'message': '摄像头已断开,请重新预览',
|
||||
}),
|
||||
);
|
||||
|
||||
emitFailure(previous);
|
||||
await tester.pump();
|
||||
expect(controller.preview.value?.previewSessionId, current);
|
||||
expect(controller.canSave, isTrue);
|
||||
emitFailure(current);
|
||||
await tester.pump();
|
||||
expect(controller.preview.value, isNull);
|
||||
expect(controller.canSave, isFalse);
|
||||
expect(savedCameraId, 0);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
for (final size in [const Size(800, 600), const Size(1280, 720)]) {
|
||||
testWidgets('较小窗口 $size 及放大字体仍能切换、取消', (tester) async {
|
||||
final controller = await mount(tester, size: size, textScale: 1.5);
|
||||
await openSelection(tester);
|
||||
await tester.ensureVisible(find.text('摄像头 #1'));
|
||||
await tester.tap(find.text('摄像头 #1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.preview.value?.cameraId, 1);
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(savedCameraId, 0);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 用受控权限代替真实 Android 授权,其余通道和页面逻辑均使用正式实现。
|
||||
class _GrantedCameraController extends CameraSettingController {
|
||||
/// 为每个测试实例创建独立的事件源,避免全局原生流跨测试异步区域存活。
|
||||
_GrantedCameraController()
|
||||
: this._(StreamController<FaceRecognitionSessionEvent>.broadcast());
|
||||
|
||||
/// 仍使用正式模型和错误处理,只替换不依赖实际 Engine 的事件传输。
|
||||
_GrantedCameraController._(this.events) : super(previewEvents: events.stream);
|
||||
|
||||
/// 当前测试拥有的设备错误源,生命周期随控制器结束。
|
||||
final StreamController<FaceRecognitionSessionEvent> events;
|
||||
|
||||
/// 测试当前是否允许打开相机,false 时模拟永久拒绝。
|
||||
bool granted = true;
|
||||
|
||||
/// 将受控权限同步到正式的系统设置入口状态。
|
||||
@override
|
||||
Future<bool> requestCameraPermission() async {
|
||||
permissionCanOpenSettings.value = !granted;
|
||||
return granted;
|
||||
}
|
||||
|
||||
/// 先取消正式订阅,再结束测试事件源,避免向已销毁的 Obx 回填。
|
||||
@override
|
||||
void onClose() {
|
||||
super.onClose();
|
||||
unawaited(events.close());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user