feat:摄像头选择优化

This commit is contained in:
dzc committed 2026-10-28 19:11:38 +08:00
1 parent c6f9ffb998
commit ef75bb918f
26 files changed
+2661 -495

No files matched your search

+1 -1
View File
@@ -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为版本号(内部比较)
```
@@ -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();
@@ -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;
}
}
@@ -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();
@@ -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);
}
}
}
@@ -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)
@@ -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());
}
});
}
}
}
+1
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
);
},
);
+3 -234
View File
@@ -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);
});
}
}
+277 -97
View File
@@ -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);
}
}
+2
View File
@@ -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
View File
@@ -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
+298
View File
@@ -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();
}
+372
View File
@@ -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());
}
}