diff --git a/README.md b/README.md index ae1a8fc..449c11d 100644 --- a/README.md +++ b/README.md @@ -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为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 3058811..c2acc07 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -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(); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/CameraSelectionPreview.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/CameraSelectionPreview.java new file mode 100644 index 0000000..fa630db --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/CameraSelectionPreview.java @@ -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 metadata() { + Map 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 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; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java index 9139c3b..38b4460 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -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 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 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 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 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 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 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 sizes) { if (sizes == null || sizes.isEmpty()) return camera.getParameters().getPreviewSize(); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java index 12cc662..cff9729 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java @@ -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 数据。 *

- * 由于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 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 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 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); } } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java index e51eba8..23615e8 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java @@ -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 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 refresh(Context context) { + List> 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> enumerateCameras() { + int count = Camera.getNumberOfCameras(); + List> result = new ArrayList<>(); + for (int id = 0; id < count; id++) { + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(id, info); + Map 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 savePrimaryCamera(Context context, Integer cameraId) { + if (cameraId != null && cameraId < 0) throw new IllegalArgumentException("摄像头编号必须为非负整数"); + Map 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> cameras, Integer requested, boolean allowFallback) { + Integer first = null; + Integer back = null; + for (Map 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> readCameras(String encoded) throws JSONException { + JSONArray array = new JSONArray(encoded); + List> result = new ArrayList<>(); + Set 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 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 snapshot(Context context, List> cameras, long refreshedAt) { + Map 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); } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java index ad1ba1b..cc814c9 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java @@ -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); } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index 27beb4e..df45c62 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -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) diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStoreTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStoreTest.java new file mode 100644 index 0000000..046a8da --- /dev/null +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStoreTest.java @@ -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> 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 camera(int id, int facing) { + Map result = new HashMap<>(); + result.put("cameraId", id); + result.put("facing", facing); + return result; + } + + /** 模拟 Android 的内存缓存与磁盘分离;重新 open 等价于进程重建后读取磁盘。 */ + private static final class MemoryPreferences { + /** 已成功提交的磁盘数据,失败提交不会写入。 */ + final Map disk = new HashMap<>(); + /** 下次提交在更新进程内缓存后报告失败,贴近 Android 的实际语义。 */ + boolean failNextCommit; + + /** 创建新的进程内偏好视图,不执行 Android 桩方法。 */ + SharedPreferences open() { + Map 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 memory) { + Map 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 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()); + } + }); + } + } +} diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index b8ed390..315b82c 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -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'; diff --git a/lib/common/models/plugs/face_camera_models.dart b/lib/common/models/plugs/face_camera_models.dart new file mode 100644 index 0000000..9e0ae4e --- /dev/null +++ b/lib/common/models/plugs/face_camera_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 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 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 map) { + final cameras = map['cameras']; + return FaceCameraConfiguration( + cameras: cameras is List + ? cameras + .whereType() + .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 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, + ); +} diff --git a/lib/common/models/plugs/face_recognition_session_models.dart b/lib/common/models/plugs/face_recognition_session_models.dart index 4f5a143..6aec365 100644 --- a/lib/common/models/plugs/face_recognition_session_models.dart +++ b/lib/common/models/plugs/face_recognition_session_models.dart @@ -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 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, diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index 2f9b14d..47b74f1 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -23,6 +23,7 @@ class RoutePages { binding: BindingsBuilder(() { Get.lazyPut(() => PanelController(), fenix: true); Get.lazyPut(() => SettingController(), fenix: true); + Get.lazyPut(() => CameraSettingController(), fenix: true); }), ), // 终端工作台一级路由:顶部状态栏常驻外壳,业务页面在 TerminalPage diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart index 9df534c..5bffd67 100644 --- a/lib/common/services/face_recognition_session_service.dart +++ b/lib/common/services/face_recognition_session_service.dart @@ -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; diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index bde82ad..dcfa371 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -152,6 +152,53 @@ abstract class FaceDiscernPlugs { return _invokeRecognizeConfiguration('resetRecognizeConfiguration'); } + /// 读取本机已保存的识别摄像头及目录,首次使用时枚举但不打开硬件。 + static Future getCameraConfiguration() => + _invokeCameraConfiguration('getCameraConfiguration'); + + /// 完整刷新设备目录;失败时保留原目录与人工偏好。 + static Future refreshCameraList() => + _invokeCameraConfiguration('refreshCameraList'); + + /// 打开设置专用候选;null 表示预览默认规则,不运行人脸识别。 + static Future startCameraSelectionPreview({ + required String previewSessionId, + required int? cameraId, + }) async { + final value = await _channel.invokeMethod( + 'startCameraSelectionPreview', + {'previewSessionId': previewSessionId, 'cameraId': cameraId}, + ); + if (value is! Map) throw StateError('原生未返回有效的摄像头预览'); + return FaceCameraSelectionPreview.fromMap(value); + } + + /// 仅释放编号及所属 Engine 都匹配的候选,迟到关闭不影响新画面。 + static Future stopCameraSelectionPreview(String previewSessionId) => + _channel.invokeMethod('stopCameraSelectionPreview', { + 'previewSessionId': previewSessionId, + }); + + /// 保存已经成功预览的候选,原生确认落盘成功后才关闭画面。 + static Future setPrimaryCamera({ + required String previewSessionId, + required int? cameraId, + }) => + _invokeCameraConfiguration('setPrimaryCamera', { + 'previewSessionId': previewSessionId, + 'cameraId': cameraId, + }); + + /// 解析原生配置,异常由设置页展示,避免将读取失败误认为自动模式。 + static Future _invokeCameraConfiguration( + String method, [ + Map? arguments, + ]) async { + final value = await _channel.invokeMethod(method, arguments); + if (value is! Map) throw StateError('原生未返回有效的摄像头配置'); + return FaceCameraConfiguration.fromMap(value); + } + /// 枚举终端摄像头并逐一做预览帧色度探测;探测需短暂打开摄像头,耗时数秒。 static Future getCameraChoices() async { final map = await _channel.invokeMethod('getCameraChoices'); diff --git a/lib/common/widgets/face/face_camera_preview.dart b/lib/common/widgets/face/face_camera_preview.dart index 1bb00cc..aa959cf 100644 --- a/lib/common/widgets/face/face_camera_preview.dart +++ b/lib/common/widgets/face/face_camera_preview.dart @@ -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), diff --git a/lib/pages/setting/camera_setting_controller.dart b/lib/pages/setting/camera_setting_controller.dart new file mode 100644 index 0000000..42c3263 --- /dev/null +++ b/lib/pages/setting/camera_setting_controller.dart @@ -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? previewEvents}) + : _previewEvents = previewEvents ?? FaceDiscernPlugs.sessionStream; + + /// 事件源由调用方或原生通道持有,本控制器只负责取消自己的订阅。 + final Stream _previewEvents; + + /// 最近成功读取的本机持久化配置,刷新失败时仍保留旧值。 + final configuration = Rxn(); + + /// 是否正在读取或刷新目录,防止重复提交和并发覆盖。 + final isLoading = false.obs; + + /// 目录读取、刷新或持久化失败的可恢复提示。 + final errorMessage = ''.obs; + + /// 当前是否存在本控制器拥有的主摄选择弹窗。 + final selectionOpen = false.obs; + + /// 用户当前候选编号,null 表示自动;保存之前不修改本机偏好。 + final draftCameraId = Rxn(); + + /// 原生已打开的候选纹理,切换、暂停或关闭时立即清空。 + final preview = Rxn(); + + /// 是否正在申请权限或切换设备,期间禁用重复选择。 + final isPreviewing = false.obs; + + /// 是否正在提交主摄配置,提交期间不允许取消或重复保存。 + final isSaving = false.obs; + + /// 预览、权限或保存失败说明,与列表刷新错误分别显示。 + final previewError = ''.obs; + + /// 权限永久拒绝时展示系统设置入口,不自动跳出业务页面。 + final permissionCanOpenSettings = false.obs; + + /// 设置候选的原生错误订阅,基本设置路由销毁时取消。 + StreamSubscription? _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 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 requestCameraPermission() async { + final status = await Permission.camera.request(); + if (!isClosed) permissionCanOpenSettings.value = status.isPermanentlyDenied; + return status.isGranted; + } + + /// 立即更新选中态后串行关闭旧画面、申请权限、打开候选;迟到结果只释放、不回填。 + Future 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 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 endSelection() async { + if (!isClosed) selectionOpen.value = false; + await _clearPreview(); + } + + /// 作废所有在途候选并清理已知原生会话编号,暂停、关闭共用这条清理路径。 + Future _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 _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; +} diff --git a/lib/pages/setting/index.dart b/lib/pages/setting/index.dart index 645b0dd..b6a9462 100644 --- a/lib/pages/setting/index.dart +++ b/lib/pages/setting/index.dart @@ -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'; diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index b2d9d74..e1d4e3b 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -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 { 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 { 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 { ? 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 { ); } + /// 绑定校验的等待、失败与未绑定均显示明确状态,避免用空组件掩盖访问原因。 Widget _buildDetail(BuildContext context, SettingMenuType type) { - return FutureBuilder( + return FutureBuilder( 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( 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, + ), ); }, ); diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart index adc4477..57550c7 100644 --- a/lib/pages/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -12,6 +12,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import '../index.dart'; import 'setting_components.dart'; +import 'setting_camera.dart'; /// 绑定接口成功即结束主操作,消息服务连接失败由后台定时任务继续恢复,不能反向判定设备绑定失败。 Future _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 _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 _openSelectionDialog() async { - await showAppDialog( - 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 _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 _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: [ - [ - 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, - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/setting/widgets/setting_camera.dart b/lib/pages/setting/widgets/setting_camera.dart new file mode 100644 index 0000000..fd9efd8 --- /dev/null +++ b/lib/pages/setting/widgets/setting_camera.dart @@ -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 { + /// 创建由基本设置路由 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), + ), + ), + [ + 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 showCameraSelectionDialog( + BuildContext context, + CameraSettingController controller, +) async { + if (!controller.beginSelection()) return; + unawaited(controller.previewCamera(controller.draftCameraId.value)); + try { + await showAppDialog( + 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( + title: const Text('自动(默认规则)'), + subtitle: const Text('默认后置,否则使用首个设备'), + value: null, + groupValue: selected, + onChanged: enabled ? controller.previewCamera : null, + activeColor: XfjDesignTokens.primary, + ), + for (final camera in cameras) + RadioListTile( + 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 [ + 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); + }); + } +} diff --git a/lib/pages/setting/widgets/setting_sync.dart b/lib/pages/setting/widgets/setting_sync.dart index 265ea62..3975b12 100644 --- a/lib/pages/setting/widgets/setting_sync.dart +++ b/lib/pages/setting/widgets/setting_sync.dart @@ -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(); 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 [ - SettingGroup( - title: '自动任务', - child: [ - 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 [ + SettingGroup( + title: '自动任务', + child: [ + 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 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 = [ - SizedBox(width: 8.w), - _TaskLogButton( - taskTitle: metadata.title, - onTap: () => unawaited(SettingNavigation.openTaskLogs(config.type)), + final options = [ + 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 [ - 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 _spaced(List children) => [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(width: _ScheduledTaskLayout.gap), + children[index], + ], + ]; + /// 打开规则弹窗,并在用户确认了不同表达式时交由 Controller 二次校验和保存。 Future _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 [ @@ -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); } } diff --git a/lib/pages/terminal/view.dart b/lib/pages/terminal/view.dart index daeeaa3..0b1bca2 100644 --- a/lib/pages/terminal/view.dart +++ b/lib/pages/terminal/view.dart @@ -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 _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); }), ), diff --git a/pubspec.yaml b/pubspec.yaml index 0d040f6..c4f3c53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/setting_binding_guard_test.dart b/test/setting_binding_guard_test.dart new file mode 100644 index 0000000..ae55a5c --- /dev/null +++ b/test/setting_binding_guard_test.dart @@ -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(_BindingTestApp()); + controller = _BindingTestController(); + controller.cuurentMenuType.value = SettingMenuType.syncSetting; + Get.put(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 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 capture(WidgetTester tester, String name) async { + final directory = Platform.environment['PJ_SETTING_SCREENSHOT_DIR']; + if (directory == null) return; + final boundary = tester.renderObject( + 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(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(); + 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.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 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 checkIsBind() => readBinding(); +} diff --git a/test/setting_camera_test.dart b/test/setting_camera_test.dart new file mode 100644 index 0000000..9460c8d --- /dev/null +++ b/test/setting_camera_test.dart @@ -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 calls; + late List> cameras; + int? savedCameraId; + var failRefresh = false; + var failSave = false; + Completer>? pendingPreview; + + Map snapshot() => { + 'cameras': cameras, + 'primaryCameraId': savedCameraId, + 'refreshedAt': 1792454400000, + }; + + Map 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(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 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>( + find.ancestor( + of: find.text('摄像头 #1'), matching: find.byType(RadioListTile)), + ); + 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(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>(); + 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(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(); + 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.broadcast()); + + /// 仍使用正式模型和错误处理,只替换不依赖实际 Engine 的事件传输。 + _GrantedCameraController._(this.events) : super(previewEvents: events.stream); + + /// 当前测试拥有的设备错误源,生命周期随控制器结束。 + final StreamController events; + + /// 测试当前是否允许打开相机,false 时模拟永久拒绝。 + bool granted = true; + + /// 将受控权限同步到正式的系统设置入口状态。 + @override + Future requestCameraPermission() async { + permissionCanOpenSettings.value = !granted; + return granted; + } + + /// 先取消正式订阅,再结束测试事件源,避免向已销毁的 Obx 回填。 + @override + void onClose() { + super.onClose(); + unawaited(events.close()); + } +}