diff --git a/README.md b/README.md index 33ec1f2..2fc8687 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 1.3.2 --build-number 132 +flutter build apk --build-name 1.3.5 --build-number 135 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java index a6cdda5..af787a7 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java @@ -20,6 +20,7 @@ import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import io.flutter.view.TextureRegistry; @@ -65,6 +66,13 @@ final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListen private Handler renderHandler; private final Object lock = new Object(); private final Map outputs = new HashMap<>(); + + /** + * 当前主、副 Flutter Engine 实际持有的预览输出数量。 + * Camera 回调线程只读取该原子值,不跨线程直接访问渲染线程拥有的 outputs。 + */ + private final AtomicInteger activeOutputCount = new AtomicInteger(); + private final float[] textureMatrix = new float[16]; private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY; @@ -127,6 +135,7 @@ final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListen throw new IllegalStateException("无法创建 Flutter 人脸预览 EGLSurface"); } outputs.put(outputId, new OutputTarget(entry, surface, eglSurface)); + activeOutputCount.set(outputs.size()); Log.i(TAG, "预览输出已添加 outputId=" + outputId + ";flutterTextureId=" + entry.id() + ";outputCount=" + outputs.size()); }); return outputId; @@ -138,6 +147,15 @@ final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListen handler.post(() -> releaseOutput(outputId)); } + /** + * 判断是否仍有界面需要实时人脸框。 + * + * @return 主屏或副屏至少持有一个有效 Texture 输出时返回 true + */ + boolean hasActiveOutputs() { + return activeOutputCount.get() > 0; + } + /** * 成功后继续保留 Camera 热资源,只暂停向主副屏 Texture 提交新帧。 * 这样界面会停在通过阈值的最后一帧,下一位顾客开始时再恢复,不需要重新开 Camera。 @@ -311,6 +329,7 @@ final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListen private void releaseOutput(long outputId) { OutputTarget output = outputs.remove(outputId); if (output == null) return; + activeOutputCount.set(outputs.size()); if (eglDisplay != EGL14.EGL_NO_DISPLAY && output.eglSurface != EGL14.EGL_NO_SURFACE) { EGL14.eglDestroySurface(eglDisplay, output.eglSurface); } @@ -331,6 +350,7 @@ final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListen if (released) return; released = true; for (Long textureId : outputs.keySet().toArray(new Long[0])) releaseOutput(textureId); + activeOutputCount.set(0); if (cameraTexture != null) { cameraTexture.setOnFrameAvailableListener(null); cameraTexture.release(); 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 42c2658..92ad0f1 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 @@ -9,6 +9,7 @@ import android.graphics.SurfaceTexture; import android.hardware.Camera; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.view.Surface; import android.view.WindowManager; @@ -45,8 +46,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba private static final long WARM_TIMEOUT_MS = 20L * 60L * 1000L; private static final int TARGET_WIDTH = 1280; private static final int TARGET_HEIGHT = 720; + + /** 人脸框事件的最小发送间隔,避免主副屏因高频 Map 消息持续重建。 */ private static final long FACE_BOX_THROTTLE_MS = 100L; + /** + * 虹软在运动模糊或角度变化时允许的短暂漏检时间。 + * 每张脸分别保留最近坐标,连续漏检超时后才确认该人员已经离开。 + */ + private static final long FACE_BOX_MISS_GRACE_MS = 600L; + + /** 结果展示期仅做人脸检测的最小帧间隔,限制热预览的 CPU 消耗。 */ + private static final long PREVIEW_ONLY_DETECTION_INTERVAL_MS = 100L; + public enum State { COLD("cold"), INITIALIZING("initializing"), @@ -87,6 +99,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba thread.setPriority(Thread.NORM_PRIORITY + 1); return thread; }); + + /** Camera 回调是否已有唯一帧任务在执行或排队,由成功取得 CAS 的任务负责释放。 */ private final AtomicBoolean frameInFlight = new AtomicBoolean(false); private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); private final Object sessionLock = new Object(); @@ -105,8 +119,20 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba private String activeScene; private Listener activeOwner; private long revision; + + /** 进程生命周期内单调递增的事件序号,用于主副屏丢弃跨业务会话迟到的状态。 */ + private long eventSequence; + private long sessionGeneration; private long lastFaceBoxEmitAt; + + /** 结果展示期最近一次提交“仅检测”任务的时间戳,单位为毫秒。 */ + private long lastPreviewOnlyDetectionAt; + + /** 每个虹软跟踪编号最近一次真实检测到的时间戳,使用单调时钟且单位为毫秒。 */ + private Map lastFaceBoxSeenAt = new HashMap<>(); + + /** 当前向 Flutter 展示的人脸框,包含真实检测框和仍在漏检宽限内的单人缓存。 */ private List> lastFaceBoxes = new ArrayList<>(); private boolean recognitionConfigurationDirty; private Handler warmReleaseHandler = new Handler(Looper.getMainLooper()); @@ -189,7 +215,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba revision = 0; state = State.INITIALIZING; reloadConfiguration = recognitionConfigurationDirty; - emitState("正在启动摄像头", null, null); + emitState("正在启动摄像头", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); } try { // 保存配置时不打断上一位顾客;下一次 Session 启动前再重建引擎并读取新值。 @@ -213,7 +239,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba throw new IllegalStateException("当前人脸会话已结束"); } state = State.DETECTING; - emitState("请正对摄像头", null, null); + emitState("请正对摄像头", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); Log.i(TAG, "人脸会话进入识别状态 sessionId=" + sessionId); return snapshot(); } @@ -223,7 +249,10 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba synchronized (sessionLock) { ensureOwner(sessionId); state = State.INITIALIZING; - emitState(warmResourcesReady ? "正在重置识别状态" : "正在重新启动摄像头", null, null); + emitState( + warmResourcesReady ? "正在重置识别状态" : "正在重新启动摄像头", + recentFaceBoxesLocked(SystemClock.elapsedRealtime()), + null); } try { ensureWarmResources(); @@ -241,7 +270,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba synchronized (sessionLock) { ensureOwner(sessionId); state = State.DETECTING; - emitState("请调整位置后重新识别", null, null); + emitState("请调整位置后重新识别", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); return snapshot(); } } @@ -252,6 +281,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba if (activeSessionId == null) return snapshot(); if (sessionId != null && !sessionId.isEmpty() && !Objects.equals(activeSessionId, sessionId)) return snapshot(); state = State.CANCELLED; + clearFaceBoxCacheLocked(); emitState(message == null ? "已取消人脸识别" : message, null, null); processorToStop = detachActiveSession(); } @@ -266,14 +296,23 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba } } + /** + * 结束当前业务识别但保留热 Camera,并恢复 Texture 的实时帧提交。 + * + *

成功回调会冻结最后一帧供独立弹框确认;调用方结束 Session 后必须解冻, + * 持续查询页面才能在十秒菜单展示期继续显示实时画面,同时又不处理下一位人脸。

+ */ public Map finishRecognition(String sessionId) { FaceDiscernViewModel processorToStop; + FacePreviewRenderer renderer; synchronized (sessionLock) { if (activeSessionId == null) return snapshot(); if (sessionId != null && !sessionId.isEmpty() && !Objects.equals(activeSessionId, sessionId)) return snapshot(); processorToStop = detachActiveSession(); + renderer = previewRenderer; } stopProcessor(processorToStop); + if (renderer != null) renderer.setFrozen(false); if (isRecognitionConfigurationDirty()) { releaseResources(true, true); } else { @@ -297,9 +336,16 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba } } + /** + * 返回当前原生资源和业务会话快照;检测、成功及热预览状态会附带可继续展示的人脸框。 + * + * @return 可供 MethodChannel 或新 EventChannel 订阅者立即渲染的完整状态 + */ public Map getState() { synchronized (sessionLock) { - return snapshot(); + Map result = snapshot(); + result.put("faceBoxes", currentStateFaceBoxesLocked(SystemClock.elapsedRealtime())); + return result; } } @@ -429,15 +475,43 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba displayOrientation, info.facing, mirror, true, true)); } + /** + * 接收 Camera1 的 NV21 回调,并按当前会话状态提交识别或仅检测任务。 + * 未被选中的帧会立即归还 Camera 缓冲区,避免高频回调积压。 + * + * @param data Camera1 复用的 NV21 帧缓冲区 + * @param callbackCamera 产生该帧的 Camera 实例 + */ @Override @SuppressWarnings("deprecation") public void onPreviewFrame(byte[] data, Camera callbackCamera) { if (data == null || callbackCamera == null) return; - boolean recognize; + boolean recognize = false; + boolean previewOnly = false; + FaceDiscernViewModel frameProcessor = null; + Camera.Size framePreviewSize = null; + long frameGeneration = 0L; + long now = SystemClock.elapsedRealtime(); synchronized (sessionLock) { - recognize = state == State.DETECTING && activeSessionId != null && processor != null; + // Camera、处理器和业务代次必须在入队时固定,禁止上一轮已经回调的 NV21 帧跨轮次进入新会话。 + if (callbackCamera == camera && processor != null && previewSize != null) { + recognize = state == State.DETECTING && activeSessionId != null; + previewOnly = !recognize + && state == State.WARM_IDLE + && activeSessionId == null + && warmResourcesReady + && previewRenderer != null + && previewRenderer.hasActiveOutputs() + && now - lastPreviewOnlyDetectionAt >= PREVIEW_ONLY_DETECTION_INTERVAL_MS; + if (recognize || previewOnly) { + frameProcessor = processor; + framePreviewSize = previewSize; + frameGeneration = sessionGeneration; + } + if (previewOnly) lastPreviewOnlyDetectionAt = now; + } } - if (!recognize || !frameInFlight.compareAndSet(false, true)) { + if ((!recognize && !previewOnly) || !frameInFlight.compareAndSet(false, true)) { try { callbackCamera.addCallbackBuffer(data); } catch (RuntimeException ignored) { @@ -445,10 +519,22 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba return; } try { + final boolean doRecognize = recognize; + final FaceDiscernViewModel expectedProcessor = frameProcessor; + final Camera.Size expectedPreviewSize = framePreviewSize; + final long expectedGeneration = frameGeneration; recognitionExecutor.execute(() -> { try { - processFrame(data); + processFrame( + data, + doRecognize, + callbackCamera, + expectedProcessor, + expectedPreviewSize, + expectedGeneration); } finally { + // 只有成功取得 CAS 的任务可以释放标记,状态切换不得提前清零并让第二帧并发入队。 + frameInFlight.set(false); try { callbackCamera.addCallbackBuffer(data); } catch (RuntimeException ignored) { @@ -464,77 +550,213 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba } } - private void processFrame(byte[] data) { - FaceDiscernViewModel currentProcessor; - long frameGeneration; - synchronized (sessionLock) { - if (state != State.DETECTING || activeSessionId == null || processor == null) { - frameInFlight.set(false); - return; - } - currentProcessor = processor; - frameGeneration = sessionGeneration; - } + /** + * 处理识别帧或热预览检测帧;两种模式复用同一执行器和处理锁,禁止并发调用虹软引擎。 + * + * @param data Camera1 回传的 NV21 原始帧 + * @param doRecognize true 时允许特征比对;false 时只获取人脸坐标用于画框 + * @param expectedCamera 帧入队时所属的 Camera,冷重启后必须与当前实例一致 + * @param expectedProcessor 帧入队时所属的虹软处理器 + * @param expectedPreviewSize 帧入队时使用的 NV21 尺寸 + * @param expectedGeneration 帧入队时所属的业务会话代次 + */ + private void processFrame( + byte[] data, + boolean doRecognize, + Camera expectedCamera, + FaceDiscernViewModel expectedProcessor, + Camera.Size expectedPreviewSize, + long expectedGeneration) { try { - List faces; + List> boxes; synchronized (processorLock) { - if (!isCurrentDetectingProcessor(currentProcessor, frameGeneration)) return; - faces = currentProcessor.onPreviewFrame(data, true); + if (!isCurrentFrameProcessor( + expectedCamera, + expectedProcessor, + expectedGeneration, + doRecognize)) return; + List faces = expectedProcessor.onPreviewFrame(data, doRecognize); + // FaceHelper 返回内部可变列表;必须在处理锁内复制坐标和状态,避免 finish/start 清理列表时产生空框或并发遍历。 + boxes = buildFaceBoxes(faces, expectedPreviewSize, expectedProcessor); } - emitFaceBoxes(faces, frameGeneration); + emitFaceBoxes(boxes, expectedGeneration, doRecognize); } catch (Exception error) { Log.e(TAG, "处理人脸预览帧失败", error); - handleFatalFailure("人脸识别引擎异常,请重试", "RECOGNITION_ENGINE_ERROR", frameGeneration); - } finally { - frameInFlight.set(false); + handleFatalFailure("人脸识别引擎异常,请重试", "RECOGNITION_ENGINE_ERROR", expectedGeneration); } } - private boolean isCurrentDetectingProcessor(FaceDiscernViewModel expectedProcessor, long expectedGeneration) { + /** + * 判断当前状态是否允许处理指定模式的 Camera 帧;调用方必须持有 sessionLock。 + * + * @param doRecognize true 表示业务识别帧,false 表示结果期热预览检测帧 + * @return 当前处理器、会话与预览输出均仍有效时返回 true + */ + private boolean canProcessFrameLocked(boolean doRecognize) { + if (doRecognize) { + return state == State.DETECTING && activeSessionId != null; + } + return state == State.WARM_IDLE + && activeSessionId == null + && warmResourcesReady + && previewRenderer != null + && previewRenderer.hasActiveOutputs(); + } + + /** + * 在耗时检测前二次校验处理器与会话代次,防止 finish/start 过渡帧串入新状态。 + * + * @param expectedCamera 任务创建时持有的 Camera + * @param expectedProcessor 任务创建时持有的虹软处理器 + * @param expectedGeneration 任务创建时的业务会话代次 + * @param doRecognize true 表示业务识别,false 表示仅检测 + * @return 任务仍属于当前状态时返回 true + */ + private boolean isCurrentFrameProcessor( + Camera expectedCamera, + FaceDiscernViewModel expectedProcessor, + long expectedGeneration, + boolean doRecognize) { synchronized (sessionLock) { - return processor == expectedProcessor + return camera == expectedCamera + && processor == expectedProcessor && sessionGeneration == expectedGeneration - && state == State.DETECTING - && activeSessionId != null; + && canProcessFrameLocked(doRecognize); } } - private void emitFaceBoxes(List faces, long frameGeneration) { - long now = System.currentTimeMillis(); - if (now - lastFaceBoxEmitAt < FACE_BOX_THROTTLE_MS) return; - lastFaceBoxEmitAt = now; + /** + * 将虹软当前帧结果复制为 Flutter 通道使用的归一化框。 + * 相似度和活体状态只决定颜色与标签,不参与框的过滤。 + * + * @param faces 虹软当前帧检测到的全部人脸 + * @param currentPreviewSize NV21 原始预览尺寸 + * @param currentProcessor 当前虹软处理器,用于读取最近识别状态 + * @return 与内部 FaceHelper 列表生命周期无关的人脸框快照 + */ + private List> buildFaceBoxes( + List faces, + Camera.Size currentPreviewSize, + FaceDiscernViewModel currentProcessor) { List> boxes = new ArrayList<>(); - Camera.Size currentPreviewSize; - FaceDiscernViewModel currentProcessor; - synchronized (sessionLock) { - currentPreviewSize = previewSize; - currentProcessor = processor; + if (faces == null) return boxes; + for (FacePreviewInfo face : faces) { + // 传原始 NV21 坐标,Flutter 再与 Texture 使用同一套旋转/镜像变换,避免框和画面各自补偿后错位。 + Rect rect = face.getFaceInfoRgb() == null ? null : face.getFaceInfoRgb().getRect(); + if (rect == null) continue; + Map box = new HashMap<>(); + box.put("trackId", face.getTrackId()); + box.put("left", clamp(rect.left / (double) currentPreviewSize.width)); + box.put("top", clamp(rect.top / (double) currentPreviewSize.height)); + box.put("right", clamp(rect.right / (double) currentPreviewSize.width)); + box.put("bottom", clamp(rect.bottom / (double) currentPreviewSize.height)); + Float similarity = currentProcessor.getFaceSimilarity(face.getTrackId()); + if (similarity != null) box.put("similarity", similarity.doubleValue()); + box.put("recognizeStatus", recognizeStatusName(currentProcessor.getFaceRecognizeStatus(face.getTrackId()))); + box.put("livenessStatus", livenessStatusName(currentProcessor.getFaceLiveness(face.getTrackId()))); + boxes.add(box); } - if (faces != null && currentPreviewSize != null) { - for (FacePreviewInfo face : faces) { - // 传原始 NV21 坐标,Flutter 再与 Texture 使用同一套旋转/镜像变换,避免框和画面各自补偿后错位。 - Rect rect = face.getFaceInfoRgb() == null ? null : face.getFaceInfoRgb().getRect(); - if (rect == null) continue; - Map box = new HashMap<>(); - box.put("left", clamp(rect.left / (double) currentPreviewSize.width)); - box.put("top", clamp(rect.top / (double) currentPreviewSize.height)); - box.put("right", clamp(rect.right / (double) currentPreviewSize.width)); - box.put("bottom", clamp(rect.bottom / (double) currentPreviewSize.height)); - if (currentProcessor != null) { - Float similarity = currentProcessor.getFaceSimilarity(face.getTrackId()); - if (similarity != null) box.put("similarity", similarity.doubleValue()); - box.put("recognizeStatus", recognizeStatusName(currentProcessor.getFaceRecognizeStatus(face.getTrackId()))); - box.put("livenessStatus", livenessStatusName(currentProcessor.getFaceLiveness(face.getTrackId()))); - } - boxes.add(box); + return boxes; + } + + /** + * 发送稳定后的人脸框。短暂空帧或人数下降会继续沿用上一组框,持续超过宽限才清除。 + * + * @param detectedBoxes 当前检测帧复制出的全部人脸框 + * @param frameGeneration 当前处理任务的会话代次 + * @param doRecognize true 表示活动识别会话,false 表示 WARM_IDLE 仅检测预览 + */ + private void emitFaceBoxes( + List> detectedBoxes, + long frameGeneration, + boolean doRecognize) { + long now = SystemClock.elapsedRealtime(); + synchronized (sessionLock) { + if (sessionGeneration != frameGeneration || !canProcessFrameLocked(doRecognize)) return; + if (now - lastFaceBoxEmitAt < FACE_BOX_THROTTLE_MS) return; + lastFaceBoxEmitAt = now; + List> visibleBoxes = stabilizeFaceBoxesLocked(detectedBoxes, now); + emitState(doRecognize ? "请正对摄像头" : "摄像头预览中", visibleBoxes, null); + } + } + + /** + * 按虹软跟踪编号分别合并当前检测框与短时缓存,避免一个人漏检时冻结其他人的新坐标。 + * 调用方必须持有 sessionLock。 + * + * @param detectedBoxes 当前帧检测框,不包含任何相似度过滤 + * @param now 当前单调业务时间戳,单位为毫秒 + * @return 本帧应向主副屏展示的稳定框列表 + */ + private List> stabilizeFaceBoxesLocked( + List> detectedBoxes, + long now) { + List> visibleBoxes = new ArrayList<>(detectedBoxes); + Map> detectedByTrackId = new HashMap<>(); + Map nextSeenAt = new HashMap<>(); + for (Map box : detectedBoxes) { + Integer trackId = faceTrackId(box); + if (trackId == null) continue; + detectedByTrackId.put(trackId, box); + nextSeenAt.put(trackId, now); + } + for (Map previousBox : lastFaceBoxes) { + Integer trackId = faceTrackId(previousBox); + if (trackId == null || detectedByTrackId.containsKey(trackId)) continue; + Long lastSeenAt = lastFaceBoxSeenAt.get(trackId); + if (lastSeenAt != null && now - lastSeenAt < FACE_BOX_MISS_GRACE_MS) { + visibleBoxes.add(previousBox); + nextSeenAt.put(trackId, lastSeenAt); } } - synchronized (sessionLock) { - if (state == State.DETECTING && activeSessionId != null && sessionGeneration == frameGeneration) { - lastFaceBoxes = boxes; - emitState("请正对摄像头", boxes, null); - } + lastFaceBoxes = visibleBoxes; + lastFaceBoxSeenAt = nextSeenAt; + return lastFaceBoxes; + } + + /** + * 读取原生框快照中的虹软跟踪编号,仅用于进程内稳定框,不属于 Flutter 必填协议字段。 + * + * @param box 单张人脸框快照 + * @return 可用于逐人缓存的跟踪编号;历史或异常数据缺失时返回 null + */ + private Integer faceTrackId(Map box) { + Object value = box.get("trackId"); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + /** + * 返回仍在漏检宽限内的最近人脸框,超时则同步清理缓存。 + * 调用方必须持有 sessionLock。 + * + * @param now 当前时间戳,单位为毫秒 + * @return 可安全用于初始化或资源状态事件的人脸框 + */ + private List> recentFaceBoxesLocked(long now) { + return stabilizeFaceBoxesLocked(new ArrayList<>(), now); + } + + /** + * 返回适合新订阅者的当前框快照;成功态保留冻结结果,实时态只保留仍在漏检宽限内的框。 + * 调用方必须持有 sessionLock。 + * + * @param now 当前单调业务时间戳,单位为毫秒 + * @return 与内部列表解耦、可直接放入通道 Map 的人脸框列表 + */ + private List> currentStateFaceBoxesLocked(long now) { + if (state == State.SUCCESS) { + return new ArrayList<>(lastFaceBoxes); } + if (state == State.INITIALIZING || state == State.DETECTING || state == State.WARM_IDLE) { + return new ArrayList<>(recentFaceBoxesLocked(now)); + } + return new ArrayList<>(); + } + + /** 清空人脸框和漏检计时;取消、冷释放及显式失败不得继续展示旧人员。 */ + private void clearFaceBoxCacheLocked() { + lastFaceBoxes = new ArrayList<>(); + lastFaceBoxSeenAt = new HashMap<>(); } private double clamp(double value) { @@ -567,6 +789,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba successBox.put("recognizeStatus", "success"); successBoxes.add(successBox); } + lastFaceBoxes = successBoxes; // 只冻结预览输出,不释放 Camera 热资源;成功框和相似度保留至少 3 秒供顾客确认。 if (previewRenderer != null) previewRenderer.setFrozen(true); emitState("识别成功,正在读取信息", successBoxes, null); @@ -583,6 +806,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba preserveSession = activeSessionId != null; if (preserveSession) { state = State.FAILED; + clearFaceBoxCacheLocked(); emitState(message, null, errorCode); } } @@ -605,11 +829,10 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba FacePreviewRenderer renderer; synchronized (sessionLock) { renderer = previewRenderer; - lastFaceBoxes = new ArrayList<>(); + lastFaceBoxEmitAt = 0; + lastPreviewOnlyDetectionAt = 0; } if (renderer != null) renderer.setFrozen(false); - frameInFlight.set(false); - lastFaceBoxEmitAt = 0; } /** @@ -621,7 +844,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba activeSessionId = null; activeScene = null; activeOwner = null; - frameInFlight.set(false); + // 若旧帧仍在执行,必须由该任务的 finally 自行释放标记;提前清零会让 Camera 重复排队并占满回调缓冲。 state = warmResourcesReady ? State.WARM_IDLE : State.COLD; scheduleWarmRelease(); return processorToStop; @@ -656,7 +879,6 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba synchronized (sessionLock) { cancelWarmRelease(); sessionGeneration++; - frameInFlight.set(false); cameraToRelease = camera; processorToRelease = processor; rendererToRelease = previewRenderer; @@ -666,6 +888,9 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba previewSize = null; warmResourcesReady = false; recognitionConfigurationDirty = false; + clearFaceBoxCacheLocked(); + lastFaceBoxEmitAt = 0L; + lastPreviewOnlyDetectionAt = 0L; if (clearActiveSession) { activeSessionId = null; activeScene = null; @@ -712,6 +937,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba Map event = snapshot(); event.put("message", message); event.put("revision", ++revision); + event.put("eventSequence", ++eventSequence); event.put("faceBoxes", boxes == null ? new ArrayList<>() : boxes); if (errorCode != null) event.put("errorCode", errorCode); for (Listener listener : listeners) listener.onSessionEvent(event); @@ -724,8 +950,11 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba Map event; synchronized (sessionLock) { event = snapshot(); + event.put("eventSequence", ++eventSequence); event.put("message", message); - event.put("faceBoxes", new ArrayList<>()); + event.put("faceBoxes", state == State.WARM_IDLE + ? recentFaceBoxesLocked(SystemClock.elapsedRealtime()) + : new ArrayList<>()); } for (Listener listener : listeners) listener.onSessionEvent(event); } @@ -742,6 +971,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba result.put("scene", activeScene == null ? "" : activeScene); result.put("state", state.code); result.put("revision", revision); + result.put("eventSequence", eventSequence); result.put("width", previewSize == null ? TARGET_WIDTH : previewSize.width); result.put("height", previewSize == null ? TARGET_HEIGHT : previewSize.height); result.put("rotation", displayOrientation); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java index 349f8d1..dd422d4 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -334,7 +334,7 @@ public class FaceHelper implements FaceListener { * * @param rgbNv21 可见光相机预览回传的NV21数据 * @param irNv21 红外相机预览回传的NV21数据 - * @param doRecognize 是否进行识别 + * @param doRecognize 是否进入口罩、活体、特征提取和比对链;false 时只做人脸检测与跟踪 * @return 实时人脸处理结果,封装添加了一个trackId,trackId的获取依赖于faceId,用于记录人脸序号并保存 */ public List onPreviewFrame(@NonNull byte[] rgbNv21, @Nullable byte[] irNv21, boolean doRecognize) { @@ -353,7 +353,9 @@ public class FaceHelper implements FaceListener { clearLeftFace(facePreviewInfoList); return facePreviewInfoList; } - if (!onlyDetectLiveness) { + // 结果展示期只需要实时框,跳过口罩与后续识别链可以降低常驻 Camera 的 CPU 占用, + // 也避免口罩引擎的单帧异常把已经成功检测到的人脸误报为空列表。 + if (doRecognize && !onlyDetectLiveness) { code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList, FaceEngine.ASF_MASK_DETECT); if (code == ErrorInfo.MOK) { @@ -917,7 +919,7 @@ public class FaceHelper implements FaceListener { /** * 异步活体任务的构造函数 * - * @param nv21Data 可见光或红外图像数据 + * @param nv21Data 可见光或红外图像数据;构造时复制,避免 Camera 复用缓冲区覆盖异步任务 * @param faceInfo 可见光人脸检测得到的人脸信息 * @param width 图像宽度 * @param height 图像高度 @@ -930,7 +932,7 @@ public class FaceHelper implements FaceListener { if (nv21Data == null) { return; } - this.nv21Data = nv21Data; + this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length); this.faceInfo = new FaceInfo(faceInfo.getFaceInfoRgb()); this.width = width; this.height = height; diff --git a/lib/common/models/plugs/face_recognition_session_models.dart b/lib/common/models/plugs/face_recognition_session_models.dart index 98df86e..4f5a143 100644 --- a/lib/common/models/plugs/face_recognition_session_models.dart +++ b/lib/common/models/plugs/face_recognition_session_models.dart @@ -111,6 +111,7 @@ class FaceRecognitionSessionEvent { required this.scene, required this.status, required this.revision, + this.eventSequence = 0, required this.width, required this.height, required this.rotation, @@ -136,6 +137,11 @@ class FaceRecognitionSessionEvent { /// 单会话内递增的事件版本,用于忽略乱序或重复事件。 final int revision; + /// 原生进程内单调递增的事件序号,用于过滤跨业务会话和跨线程迟到的状态。 + /// + /// 旧版原生插件未提供该字段时为 0,并回退到会话编号与 [revision] 兼容判断。 + final int eventSequence; + /// 原始摄像头预览宽度,单位为像素。 final int width; @@ -188,6 +194,7 @@ class FaceRecognitionSessionEvent { orElse: () => FaceRecognitionSessionStatus.cold, ), revision: (map['revision'] as num?)?.toInt() ?? 0, + eventSequence: (map['eventSequence'] as num?)?.toInt() ?? 0, 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/models/plugs/sub_screen_models.dart b/lib/common/models/plugs/sub_screen_models.dart index e50fd7a..a48b5bf 100644 --- a/lib/common/models/plugs/sub_screen_models.dart +++ b/lib/common/models/plugs/sub_screen_models.dart @@ -114,6 +114,9 @@ class SubScreenReservationQueryEvent { /// 当前预约结果的隐私过期时间。 final String? expiresAt; + /// 是否启用查询菜单的持续人脸组合页面;旧副屏消息缺省为关闭。 + final bool continuousFaceRecognition; + const SubScreenReservationQueryEvent({ required this.sessionId, required this.revision, @@ -127,6 +130,7 @@ class SubScreenReservationQueryEvent { required this.totalPages, required this.days, this.expiresAt, + this.continuousFaceRecognition = false, }); factory SubScreenReservationQueryEvent.fromJson( diff --git a/lib/common/models/plugs/sub_screen_models.g.dart b/lib/common/models/plugs/sub_screen_models.g.dart index 15bca14..d0433bc 100644 --- a/lib/common/models/plugs/sub_screen_models.g.dart +++ b/lib/common/models/plugs/sub_screen_models.g.dart @@ -75,6 +75,8 @@ SubScreenReservationQueryEvent _$SubScreenReservationQueryEventFromJson( (e) => ReservationMenuDayData.fromJson(e as Map)) .toList(), expiresAt: json['expiresAt'] as String?, + continuousFaceRecognition: + json['continuousFaceRecognition'] as bool? ?? false, ); Map _$SubScreenReservationQueryEventToJson( @@ -92,4 +94,5 @@ Map _$SubScreenReservationQueryEventToJson( 'totalPages': instance.totalPages, 'days': instance.days.map((e) => e.toJson()).toList(), 'expiresAt': instance.expiresAt, + 'continuousFaceRecognition': instance.continuousFaceRecognition, }; diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart index 7a9b74e..0eeaa7c 100644 --- a/lib/common/services/face_recognition_session_service.dart +++ b/lib/common/services/face_recognition_session_service.dart @@ -222,13 +222,16 @@ class FaceRecognitionSessionService extends GetxService { ); } + /// 取消当前业务人脸会话,并在释放原生资源前先让副屏退出人脸页。 Future cancel({String message = '已取消人脸识别'}) async { final sessionId = _activeSessionId; if (sessionId == null) return; - unawaited(_sendViceState( + // 主屏弹框会在本方法返回后立即关闭,必须先串行投递取消状态; + // 否则后续的预约查询消息可能先到,副屏仍会停留在人脸识别页。 + await _sendViceState( SubScreenAction.faceRecognitionCancel, message: message, - )); + ); logger.i('|人脸识别|开始原生调用cancelRecognition|sessionId:$sessionId|'); await FaceDiscernPlugs.cancelRecognition(sessionId, message: message); if (_resultCompleter?.isCompleted == false) { diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 775d2ce..d6b8f11 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -13,8 +13,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - // static const apiUrl = 'https://www.zianxin.com/api'; + // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/common/widgets/app/app_dialog.dart b/lib/common/widgets/app/app_dialog.dart index c4d3433..77da24f 100644 --- a/lib/common/widgets/app/app_dialog.dart +++ b/lib/common/widgets/app/app_dialog.dart @@ -15,10 +15,17 @@ const Duration appDialogDefaultTransitionDuration = Duration(milliseconds: 180); /// 禁止关闭反馈的最短触发间隔,避免连续点击导致设备持续震动。 const Duration appDialogBlockedFeedbackThrottle = Duration(milliseconds: 700); +/// 默认弹框与系统顶部安全区之间的间隔,避免表单弹框紧贴状态栏。 +const double appDialogDefaultTopSpacing = 24; + +/// 弹框与左右、底部可用区域边缘的最小间隔,用于触控和拖动边界保护。 +const double appDialogDefaultEdgeSpacing = 8; + /// 展示使用统一遮罩、返回拦截、关闭反馈和动效的业务弹框。 /// /// [builder] 应返回实际弹框表面,推荐使用 [AppDialog]。路由始终自行管理 -/// ModalBarrier,因此禁止关闭时也能给出视觉和触觉反馈。 +/// ModalBarrier,因此禁止关闭时也能给出视觉和触觉反馈。默认位置位于顶部 +/// 安全区下方,输入法出现后会自动缩入键盘上方的可用区域。 Future showAppDialog({ required BuildContext context, required WidgetBuilder builder, @@ -28,7 +35,7 @@ Future showAppDialog({ bool useRootNavigator = true, String barrierLabel = '关闭弹框', Color? barrierColor, - Alignment alignment = Alignment.center, + Alignment alignment = Alignment.topCenter, Duration transitionDuration = appDialogDefaultTransitionDuration, RouteSettings? routeSettings, }) { @@ -156,6 +163,9 @@ class _AppDialogState extends State { /// 相对初始对齐位置的拖动偏移。 Offset _dragOffset = Offset.zero; + /// 上一次构建使用的键盘遮挡高度,用于在输入法变化时清理失效拖动位置。 + double _lastKeyboardInset = 0; + /// 当前拖动开始时指针的全局位置;未拖动时为空。 Offset? _dragStartGlobalPosition; @@ -178,6 +188,13 @@ class _AppDialogState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + if (keyboardInset != _lastKeyboardInset) { + _lastKeyboardInset = keyboardInset; + // 键盘会改变可用区域,旧拖动偏移不再具有合法的屏幕坐标语义。 + _dragOffset = Offset.zero; + _handleDragEnd(); + } final signal = _AppDialogBlockedFeedbackScope.maybeOf(context); if (identical(signal, _blockedFeedbackSignal)) return; _blockedFeedbackSignal?.removeListener(_handleBlockedFeedback); @@ -229,11 +246,14 @@ class _AppDialogState extends State { final viewport = MediaQuery.sizeOf(context); // SafeArea 会把后代的 padding 清零,viewPadding 才能稳定保留系统栏原始占用。 final padding = MediaQuery.viewPaddingOf(context); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final bottomObstruction = + keyboardInset > padding.bottom ? keyboardInset : padding.bottom; final safeRect = Rect.fromLTRB( - padding.left + 8, - padding.top + 8, - viewport.width - padding.right - 8, - viewport.height - padding.bottom - 8, + padding.left + appDialogDefaultEdgeSpacing, + padding.top + appDialogDefaultEdgeSpacing, + viewport.width - padding.right - appDialogDefaultEdgeSpacing, + viewport.height - bottomObstruction - appDialogDefaultEdgeSpacing, ); final requestedDelta = details.globalPosition - dragStartGlobalPosition; final clampedDelta = Offset( @@ -535,7 +555,20 @@ class _AppDialogRouteHostState extends State<_AppDialogRouteHost> /// 构建可命中遮罩、但不抢占弹框内部手势的安全区域布局。 @override Widget build(BuildContext context) { - final safePadding = MediaQuery.paddingOf(context); + final safePadding = MediaQuery.viewPaddingOf(context); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final bottomObstruction = + keyboardInset > safePadding.bottom ? keyboardInset : safePadding.bottom; + final topSpacing = widget.alignment.y <= Alignment.topCenter.y + ? appDialogDefaultTopSpacing + : appDialogDefaultEdgeSpacing; + final dialogInsets = EdgeInsets.fromLTRB( + safePadding.left + appDialogDefaultEdgeSpacing, + safePadding.top + topSpacing, + safePadding.right + appDialogDefaultEdgeSpacing, + bottomObstruction + appDialogDefaultEdgeSpacing, + ); + final reduceMotion = MediaQuery.disableAnimationsOf(context); return PopScope( canPop: widget.canPop, onPopInvokedWithResult: _handlePopInvoked, @@ -555,9 +588,14 @@ class _AppDialogRouteHostState extends State<_AppDialogRouteHost> child: const SizedBox.expand(), ), ), - Padding( - // 不使用 SafeArea 改写后代 MediaQuery,使 AppDialog 拖动时仍能读取原始系统安全区。 - padding: safePadding, + AnimatedPadding( + // 不使用 SafeArea 改写后代 MediaQuery;键盘出现时直接缩小弹框路由的 + // 可用区域,使输入框和底部操作区始终位于键盘上方。 + padding: dialogInsets, + duration: reduceMotion + ? Duration.zero + : appDialogDefaultTransitionDuration, + curve: Curves.easeOutCubic, child: Align( alignment: widget.alignment, child: AnimatedBuilder( diff --git a/lib/common/widgets/face/face_camera_preview.dart b/lib/common/widgets/face/face_camera_preview.dart index a17b3ad..1bb00cc 100644 --- a/lib/common/widgets/face/face_camera_preview.dart +++ b/lib/common/widgets/face/face_camera_preview.dart @@ -13,6 +13,9 @@ class FaceCameraPreview extends StatelessWidget { required this.texture, required this.event, required this.status, + this.statusText, + this.statusColor, + this.scale = 1, }); /// 原生创建的预览纹理;为空时展示摄像头准备状态。 @@ -24,6 +27,15 @@ class FaceCameraPreview extends StatelessWidget { /// 当前识别状态,用于展示文字和非颜色状态标识。 final FaceRecognitionSessionStatus status; + /// 业务页面覆盖的摄像头状态文案;为空时沿用原生会话状态文案。 + final String? statusText; + + /// 业务页面覆盖的摄像头状态颜色;为空时沿用原生会话状态颜色。 + final Color? statusColor; + + /// 宿主 Engine 提供的尺寸缩放系数,副屏不得依赖主屏 ScreenUtil 全局状态。 + final double scale; + /// 构建固定比例的摄像头画面和状态浮层。 @override Widget build(BuildContext context) { @@ -31,16 +43,28 @@ class FaceCameraPreview extends StatelessWidget { color: const Color(0xFF102635), child: [ if (texture != null) - FaceRecognitionPreviewLayer(texture: texture!, event: event) + FaceRecognitionPreviewLayer( + texture: texture!, + event: event, + labelScale: scale, + ) else - _FaceCameraEmptyState(status: status), - _FaceCameraStatusPill(status: status).positioned( - left: 18.w, - top: 18.h, + _FaceCameraEmptyState(status: status, scale: scale), + _FaceCameraStatusPill( + status: status, + text: statusText, + color: statusColor, + scale: scale, + ).positioned( + left: 18 * scale, + top: 18 * scale, ), - _FaceCameraResolutionLabel(texture: texture).positioned( - right: 18.w, - bottom: 16.h, + _FaceCameraResolutionLabel( + texture: texture, + scale: scale, + ).positioned( + right: 18 * scale, + bottom: 16 * scale, ), ].toStack(fit: StackFit.expand), ); @@ -50,33 +74,40 @@ class FaceCameraPreview extends StatelessWidget { /// 摄像头 Texture 尚未创建时的明确等待或失败提示。 class _FaceCameraEmptyState extends StatelessWidget { /// 创建与当前识别状态匹配的空画面。 - const _FaceCameraEmptyState({required this.status}); + const _FaceCameraEmptyState({required this.status, required this.scale}); /// 决定展示加载反馈还是设备异常提示的会话状态。 final FaceRecognitionSessionStatus status; + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + /// 构建不会造成画面尺寸跳变的居中状态。 @override Widget build(BuildContext context) { final failed = status == FaceRecognitionSessionStatus.failed; return [ if (failed) - Icon(Icons.videocam_off_rounded, color: Colors.white, size: 42.w) + Icon( + Icons.videocam_off_rounded, + color: Colors.white, + size: 42 * scale, + ) else SizedBox( - width: 36.w, - height: 36.w, + width: 36 * scale, + height: 36 * scale, child: const CircularProgressIndicator( color: Colors.white, strokeWidth: 3, ), ), - SizedBox(height: 12.h), + SizedBox(height: 12 * scale), Text( failed ? '摄像头画面暂不可用' : '正在准备摄像头画面', style: TextStyle( color: Colors.white, - fontSize: 16.sp, + fontSize: 16 * scale, fontWeight: FontWeight.w700, ), ), @@ -87,44 +118,63 @@ class _FaceCameraEmptyState extends StatelessWidget { /// 摄像头左上角的文字状态标识,确保状态不只依赖红绿颜色表达。 class _FaceCameraStatusPill extends StatelessWidget { /// 创建当前识别阶段的状态标识。 - const _FaceCameraStatusPill({required this.status}); + const _FaceCameraStatusPill({ + required this.status, + required this.text, + required this.color, + required this.scale, + }); /// 当前原生识别会话状态。 final FaceRecognitionSessionStatus status; + /// 业务页面提供的可选状态文案。 + final String? text; + + /// 业务页面提供的可选语义颜色。 + final Color? color; + + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + /// 构建包含状态点和文字的高对比标签。 @override Widget build(BuildContext context) { - final text = switch (status) { - FaceRecognitionSessionStatus.initializing => '初始化中', - FaceRecognitionSessionStatus.success => '已识别', - FaceRecognitionSessionStatus.failed => '需要重试', - FaceRecognitionSessionStatus.cancelled => '已取消', - _ => '识别中', - }; - final color = switch (status) { - FaceRecognitionSessionStatus.failed || - FaceRecognitionSessionStatus.cancelled => - XfjDesignTokens.danger, - FaceRecognitionSessionStatus.initializing => XfjDesignTokens.warning, - _ => XfjDesignTokens.success, - }; + final resolvedText = text ?? + switch (status) { + FaceRecognitionSessionStatus.initializing => '初始化中', + FaceRecognitionSessionStatus.success => '已识别', + FaceRecognitionSessionStatus.failed => '需要重试', + FaceRecognitionSessionStatus.cancelled => '已取消', + _ => '识别中', + }; + final resolvedColor = color ?? + switch (status) { + FaceRecognitionSessionStatus.failed || + FaceRecognitionSessionStatus.cancelled => + XfjDesignTokens.danger, + FaceRecognitionSessionStatus.initializing => XfjDesignTokens.warning, + _ => XfjDesignTokens.success, + }; return [ DecoratedBox( - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ).width(10.w).height(10.w), - SizedBox(width: 8.w), + decoration: BoxDecoration( + color: resolvedColor, + shape: BoxShape.circle, + ), + ).width(10 * scale).height(10 * scale), + SizedBox(width: 8 * scale), Text( - text, + resolvedText, style: TextStyle( color: Colors.white, - fontSize: 14.sp, + fontSize: 14 * scale, fontWeight: FontWeight.w700, ), ), ] .toRow(mainAxisSize: MainAxisSize.min) - .padding(horizontal: 14.w, vertical: 9.h) + .padding(horizontal: 14 * scale, vertical: 9 * scale) .decorated( color: Colors.black.withValues(alpha: 0.62), borderRadius: BorderRadius.circular(99), @@ -135,18 +185,24 @@ class _FaceCameraStatusPill extends StatelessWidget { /// 摄像头右下角的实际纹理分辨率标签。 class _FaceCameraResolutionLabel extends StatelessWidget { /// 创建分辨率说明;纹理未就绪时使用插件既有默认分辨率。 - const _FaceCameraResolutionLabel({required this.texture}); + const _FaceCameraResolutionLabel({ + required this.texture, + required this.scale, + }); /// 当前预览纹理元数据。 final FaceRecognitionPreviewTexture? texture; + /// 宿主页面计算的尺寸缩放系数。 + final double scale; + /// 构建不遮挡主要人脸区域的紧凑标签。 @override Widget build(BuildContext context) { return Text( '彩色摄像头 ${texture?.width ?? 1280} x ${texture?.height ?? 720}', - style: TextStyle(color: Colors.white, fontSize: 13.sp), - ).padding(horizontal: 12.w, vertical: 8.h).decorated( + style: TextStyle(color: Colors.white, fontSize: 13 * scale), + ).padding(horizontal: 12 * scale, vertical: 8 * scale).decorated( color: Colors.black.withValues(alpha: 0.62), borderRadius: BorderRadius.circular(10), ); diff --git a/lib/common/widgets/face/face_recognition_panel.dart b/lib/common/widgets/face/face_recognition_panel.dart index e614dbe..1848f56 100644 --- a/lib/common/widgets/face/face_recognition_panel.dart +++ b/lib/common/widgets/face/face_recognition_panel.dart @@ -299,6 +299,7 @@ class _FaceRecognitionPanelState extends State { texture: _texture, event: _event, status: status, + scale: 1.w, ), ), ), diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 2e0fde2..e6226be 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -23,6 +23,7 @@ export './app/terminal_toolbar.dart'; export './cashier/cashier_workspace.dart'; export './face/face_recognition_preview_layer.dart'; export './face/face_camera_preview.dart'; +export './face/face_live_camera_preview.dart'; export './face/face_recognition_panel.dart'; export './face/face_recognition_preview_dialog.dart'; diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 019844f..65af07b 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -77,6 +77,9 @@ mixin OrderMixin { Future? putOffSendSubScreenDelayed; + /// 副屏购物车快照的递增版本;异步查询套餐组成时用于丢弃已经过期的快照。 + int _subScreenSnapshotRevision = 0; + /// **************** start 更新属性 start **************** set sysType(SysType type) { @@ -576,25 +579,44 @@ mixin OrderMixin { }); } + /// 通知副屏退出当前结算页,并使所有仍在构建的旧购物车快照失效。 Future sendSubScreenBack() async { + // 先使正在查询套餐组成的旧快照失效,避免清屏后迟到的更新重新显示上一笔订单。 + _subScreenSnapshotRevision++; await SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.jumpSubBack.name, ); } + /// 构建副屏使用的购物车商品列表;子类可在不改变下单协议的前提下补充展示字段。 + Future>> buildSubScreenShopList( + List cartItems, + ) async { + return cartItems.map((item) => item.toJson()).toList(growable: false); + } + + /// 冻结当前购物车展示数据并发送副屏;较新的调用会淘汰尚未完成的旧快照。 Future sendSubScreen() async { - if (shopCardList.isEmpty) { + final revision = ++_subScreenSnapshotRevision; + final cartItems = shopCardList.toList(growable: false); + if (cartItems.isEmpty) { await SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.jumpSubBack.name, ); return; } + // 先冻结金额和用户信息,再等待套餐明细查询,确保副屏展示的是同一时刻的购物车。 + final totalAmount = totalPrice.value; + final surchargeAmount = amountMealPrice.value; + final customer = payUser.value?.toJson(); + final shopList = await buildSubScreenShopList(cartItems); + if (revision != _subScreenSnapshotRevision) return; final json = SubScreenProductEvent( - shopList: shopCardList.map((e) => e.toJson()).toList(), + shopList: shopList, sysType: _sysType, - payUser: payUser.value?.toJson(), - totalPrice: totalPrice.value, - amountMealPrice: amountMealPrice.value, + payUser: customer, + totalPrice: totalAmount, + amountMealPrice: surchargeAmount, ).toJson(); await SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.updateCard.name, diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 98c7de9..6c3dee4 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -260,6 +260,62 @@ class CanteenProductController extends GetxController with OrderMixin { ); } + /// 为副屏购物车快照补充食堂套餐组成;这些字段只用于顾客展示,不改变下单商品列表。 + @override + Future>> buildSubScreenShopList( + List cartItems, + ) async { + final shopList = + cartItems.map((item) => item.toJson()).toList(growable: true); + final productsByCode = { + for (final product in canteenSpuList) product.serviceUniqueCode: product, + }; + final checkoutDate = DateUtil.formatDate(DateTime.now()); + final productDao = appC.dataBase.getCanteenSpuDao; + + for (var index = 0; index < cartItems.length; index++) { + final cartItem = cartItems[index]; + final payload = shopList[index]; + final productCode = cartItem.productServiceUniqueCode; + CanteenSpuEntity? product; + try { + // 购物车可跨分类保留商品,当前列表没有父商品时回查当天本地快照。 + product = productsByCode[productCode] ?? + await productDao.getByCode(productCode, checkoutDate); + } catch (error, stack) { + logger.w( + '|食堂购买页面|读取副屏套餐类型失败productCode:|$productCode|error:|$error|stack:|$stack|', + ); + } + + final isCombo = product?.isCombo == true; + payload['isCombo'] = isCombo; + if (!isCombo) continue; + + try { + final items = await productDao.getComboItems( + productCode, + DateUtil.formatDate(product!.saleDate), + ); + payload['comboItems'] = items + .map( + (item) => { + 'name': item.name, + 'quantity': item.quantity, + }, + ) + .toList(growable: false); + } catch (error, stack) { + // 明细异常不能影响收银;副屏至少保留套餐主项和套餐标识。 + payload['comboItems'] = const >[]; + logger.w( + '|食堂购买页面|读取副屏套餐组成失败productCode:|$productCode|error:|$error|stack:|$stack|', + ); + } + } + return List.unmodifiable(shopList); + } + // |下单|点击取消下单 Future doCancelPay({bool cancelShop = true}) async { if (cancelShop) await _clearShopCard(); diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index 4b12de1..a986206 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -63,6 +63,7 @@ class ReservationMenuQueryController extends GetxController { void Function(String)? successNotifier, void Function(String)? errorNotifier, this.privacyDuration = const Duration(seconds: 60), + this.continuousFaceResultDuration = const Duration(seconds: 10), this.timerTick = const Duration(seconds: 1), this.recognitionRestartDelay = const Duration(milliseconds: 300), DateTime Function()? now, @@ -97,7 +98,14 @@ class ReservationMenuQueryController extends GetxController { final Future Function(String) _speaker; final void Function(String) _successNotifier; final void Function(String) _errorNotifier; + + /// 普通查询结果的隐私展示时长,当前生产默认六十秒。 final Duration privacyDuration; + + /// 持续人脸模式单个用户菜单的展示时长,到期后自动进入下一轮识别。 + final Duration continuousFaceResultDuration; + + /// 隐私倒计时刷新间隔,测试可注入更短间隔验证状态转换。 final Duration timerTick; /// 正常等待超时后开启下一轮前的缓冲时间,确保旧串口或原生会话已完成资源释放。 @@ -113,7 +121,7 @@ class ReservationMenuQueryController extends GetxController { /// 是否自动启动当前查询方式的硬件识别;页面生命周期内使用进入页面时读取的配置快照。 final autoRecognitionEnabled = false.obs; - /// 自动识别正常等待超时后是否继续下一轮;异常和主动取消不使用该开关重试。 + /// 自动识别是否持续接待下一位;饭卡结果展示期保持读卡,正常等待超时也会继续下一轮。 final continuousRecognitionEnabled = true.obs; /// 当前页面查询请求是否要求服务端脱敏用户姓名。 @@ -140,7 +148,7 @@ class ReservationMenuQueryController extends GetxController { Timer? _privacyTimer; - /// 正常等待超时后的待重启任务;切换方式、清屏或退出页面时必须取消。 + /// 正常等待超时或同卡重复读取后的待重启任务;切换方式、清屏或退出页面时必须取消。 Timer? _recognitionRestartTimer; DateTime? _expiresAt; @@ -165,6 +173,18 @@ class ReservationMenuQueryController extends GetxController { Stream get quickVerificationRequests => _quickVerificationRequestController.stream; + /// 当前是否为“自动识别 + 持续自动识别 + 人脸”的常驻摄像头模式。 + bool get continuousFaceRecognitionActive => + autoRecognitionEnabled.value && + continuousRecognitionEnabled.value && + method.value == ReservationRecognitionMethod.face; + + /// 当前是否为“自动识别 + 持续自动识别 + 饭卡”的结果期持续读卡模式。 + bool get continuousCardRecognitionActive => + autoRecognitionEnabled.value && + continuousRecognitionEnabled.value && + method.value == ReservationRecognitionMethod.card; + List get currentDays => result.value?.pageDays(currentPage.value) ?? const []; @@ -269,32 +289,65 @@ class ReservationMenuQueryController extends GetxController { if (autoRecognitionEnabled.value) await startRecognition(); } - /// 启动当前识别方式并建立一次防重复回调的查询代次;失败后停留在可人工恢复状态。 - Future startRecognition() async { + /// 主动开始一轮识别;会清除上一位身份和菜单,失败后停留在可人工恢复状态。 + Future startRecognition() => _startRecognition(); + + /// 建立一次防重复回调的识别代次;持续饭卡结果期可保留当前菜单并只重新布防读卡器。 + Future _startRecognition({bool preserveCurrentResult = false}) async { if (_closed || _resultDialogPresented) return; + final preserveCardResult = preserveCurrentResult && + continuousCardRecognitionActive && + result.value != null && + status.value.hasIdentity; + if (preserveCurrentResult && !preserveCardResult) return; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _recognitionLocked = false; - _stopPrivacyTimer(); - result.value = null; - _recognizedIdentityValue = null; - currentPage.value = 1; - errorMessage.value = ''; - status.value = ReservationMenuQueryStatus.recognizing; + if (!preserveCardResult) { + _stopPrivacyTimer(); + result.value = null; + _recognizedIdentityValue = null; + currentPage.value = 1; + errorMessage.value = ''; + status.value = ReservationMenuQueryStatus.recognizing; + } // 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、 // 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。 - if (method.value == ReservationRecognitionMethod.card) { + if (method.value == ReservationRecognitionMethod.card && + !preserveCardResult) { unawaited(_syncSubScreen('loading')); + } else if (continuousFaceRecognitionActive) { + // 先让副屏进入组合页面再启动人脸服务,避免人脸 start 消息抢先把副屏切到旧的独立页面。 + await _syncSubScreen('recognizing'); + if (_closed || generation != _requestGeneration) return; } Future recognized(String value) async { if (_closed || generation != _requestGeneration || _recognitionLocked) { return; } + if (preserveCardResult && value == _recognizedIdentityValue) { + // 卡片未移开时硬件可能在重新布防后再次返回同一卡号;不重复查询,短暂延迟后继续等待换卡。 + _recognitionLocked = true; + await _restartRecognitionAfterDelay( + generation, + preserveCurrentResult: true, + ); + return; + } // 同一硬件事件可能被串口或原生通道重复投递,只允许首个结果进入查询。 _recognitionLocked = true; + _cancelRecognitionRestart(); + _stopPrivacyTimer(); + if (preserveCardResult) { + // 第二张卡进入查询后立即移除上一位结果,查询失败也不能重新暴露旧用户菜单。 + result.value = null; + currentPage.value = 1; + errorMessage.value = ''; + } _recognizedIdentityValue = value; await _recognitionGateway.stopAll(); + if (_closed || generation != _requestGeneration) return; await _queryIdentity(value, generation); } @@ -308,10 +361,16 @@ class ReservationMenuQueryController extends GetxController { continuousRecognitionEnabled.value) { // 正常无人等待超时不向用户闪现错误页;先完整释放本轮资源,再平滑进入下一轮。 errorMessage.value = ''; - status.value = ReservationMenuQueryStatus.recognizing; - unawaited(_restartRecognitionAfterTimeout(generation)); + if (!preserveCardResult) { + status.value = ReservationMenuQueryStatus.recognizing; + } + unawaited(_restartRecognitionAfterDelay( + generation, + preserveCurrentResult: preserveCardResult, + )); return; } + if (preserveCardResult) _stopPrivacyTimer(); if (failure.type == ReservationRecognitionFailureType.cancelled) { if (autoRecognitionEnabled.value) { // 用户主动取消后暂停自动识别并提供恢复入口,禁止立即拉起造成无法退出的弹窗循环。 @@ -342,19 +401,26 @@ class ReservationMenuQueryController extends GetxController { await _recognitionGateway.startFace( onRecognized: recognized, onFailure: failed, + embeddedPreview: continuousFaceRecognitionActive, ); } } catch (error, stack) { if (_closed || generation != _requestGeneration) return; logger.e('|预约查询|启动识别失败error:|$error|stack:|$stack|'); + if (preserveCardResult) _stopPrivacyTimer(); errorMessage.value = '${method.value.title}设备不可用,请检查设备后重试'; status.value = ReservationMenuQueryStatus.deviceError; await _syncSubScreen('error'); } } - /// 正常等待超时后释放旧会话并延迟续轮;代次变化时旧任务立即失效,不能复活已切换的页面。 - Future _restartRecognitionAfterTimeout(int generation) async { + /// 释放当前识别会话并延迟续轮;代次变化时旧任务立即失效。 + /// + /// [preserveCurrentResult] 仅用于持续饭卡结果期,保留当前菜单等待不同卡号。 + Future _restartRecognitionAfterDelay( + int generation, { + bool preserveCurrentResult = false, + }) async { await _recognitionGateway.stopAll(); if (_closed || _resultDialogPresented || @@ -372,7 +438,9 @@ class ReservationMenuQueryController extends GetxController { !continuousRecognitionEnabled.value) { return; } - unawaited(startRecognition()); + unawaited(_startRecognition( + preserveCurrentResult: preserveCurrentResult, + )); }); } @@ -422,7 +490,15 @@ class ReservationMenuQueryController extends GetxController { response.days.isEmpty ? 'empty' : 'success', includeResult: true, ); - _publishQuickVerificationRequest(); + if (_closed || generation != _requestGeneration) return; + final quickVerificationPending = _publishQuickVerificationRequest(); + if (_closed || generation != _requestGeneration) return; + if (continuousCardRecognitionActive && + !quickVerificationPending && + !_resultDialogPresented) { + // 菜单继续展示并维持隐私倒计时,读卡器在后台等待下一张不同饭卡直接覆盖当前结果。 + await _startRecognition(preserveCurrentResult: true); + } } on ReservationMenuIdentityException catch (error) { if (_closed || generation != _requestGeneration) return; errorMessage.value = error.message; @@ -448,16 +524,16 @@ class ReservationMenuQueryController extends GetxController { } } - /// 在快速核销开启且页面正在监听时发布今天预选餐段;缺少可核销预约时保留结果页并提示操作员。 - void _publishQuickVerificationRequest() { + /// 在快速核销开启且页面正在监听时发布今天预选餐段,并返回是否需要等待弹框接管识别。 + bool _publishQuickVerificationRequest() { if (_closed || !verificationEnabled.value || !quickVerificationEnabled.value || !_quickVerificationRequestController.hasListener) { - return; + return false; } final currentResult = result.value; - if (currentResult == null || currentResult.days.isEmpty) return; + if (currentResult == null || currentResult.days.isEmpty) return false; ReservationMenuDayData? today; for (final day in currentResult.days) { @@ -482,7 +558,7 @@ class ReservationMenuQueryController extends GetxController { _errorNotifier( '当前用户今天没有可核销的${selectedMealSlot.value.title}预约', ); - return; + return false; } _quickVerificationRequestController.add( @@ -492,6 +568,7 @@ class ReservationMenuQueryController extends GetxController { meal: targetMeal, ), ); + return true; } String _maskedIdentity(String value) { @@ -503,7 +580,8 @@ class ReservationMenuQueryController extends GetxController { if (result.value == null || page < 1 || page > totalPages) return; if (page == currentPage.value) return; currentPage.value = page; - _startPrivacyTimer(); + // 持续人脸按“识别成功后的固定十秒”接待下一位,操作员翻页不能延长身份展示。 + if (!continuousFaceRecognitionActive) _startPrivacyTimer(); await _syncSubScreen('success', includeResult: true); } @@ -542,6 +620,7 @@ class ReservationMenuQueryController extends GetxController { return; } if (autoRecognitionEnabled.value) { + final keepContinuousFacePreview = continuousFaceRecognitionActive; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); @@ -549,8 +628,14 @@ class ReservationMenuQueryController extends GetxController { _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; - status.value = ReservationMenuQueryStatus.idle; - unawaited(_clearSubScreen()); + status.value = keepContinuousFacePreview + ? ReservationMenuQueryStatus.recognizing + : ReservationMenuQueryStatus.idle; + if (keepContinuousFacePreview) { + unawaited(_syncSubScreen('recognizing')); + } else { + unawaited(_clearSubScreen()); + } var shouldResumeRecognition = false; try { @@ -726,7 +811,12 @@ class ReservationMenuQueryController extends GetxController { Future _resumeAfterStaleResultClear(int generation) async { await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; - unawaited(_clearSubScreen()); + if (continuousFaceRecognitionActive) { + status.value = ReservationMenuQueryStatus.recognizing; + unawaited(_syncSubScreen('recognizing')); + } else { + unawaited(_clearSubScreen()); + } if (!_closed && !_resultDialogPresented && autoRecognitionEnabled.value) { await startRecognition(); } @@ -748,8 +838,11 @@ class ReservationMenuQueryController extends GetxController { void _startPrivacyTimer() { if (_closed || _resultDialogPresented) return; _stopPrivacyTimer(); - _expiresAt = _now().add(privacyDuration); - remainingSeconds.value = privacyDuration.inSeconds; + final displayDuration = continuousFaceRecognitionActive + ? continuousFaceResultDuration + : privacyDuration; + _expiresAt = _now().add(displayDuration); + remainingSeconds.value = displayDuration.inSeconds; _privacyTimer = Timer.periodic(timerTick, (_) { if (_closed || _expiresAt == null) return; final milliseconds = _expiresAt!.difference(_now()).inMilliseconds; @@ -771,6 +864,7 @@ class ReservationMenuQueryController extends GetxController { /// 清除主屏身份与预约结果;自动模式会在清理完成后重新等待下一位用户。 Future clearForPrivacy({bool expired = false}) async { if (_closed || _resultDialogPresented) return; + final keepContinuousFacePreview = continuousFaceRecognitionActive; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); @@ -780,12 +874,19 @@ class ReservationMenuQueryController extends GetxController { _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; - status.value = expired - ? ReservationMenuQueryStatus.expired - : ReservationMenuQueryStatus.idle; - unawaited(_clearSubScreen()); + status.value = keepContinuousFacePreview + ? ReservationMenuQueryStatus.recognizing + : expired + ? ReservationMenuQueryStatus.expired + : ReservationMenuQueryStatus.idle; + if (keepContinuousFacePreview) { + // 只清身份和预约内容,副屏仍保留同一摄像头 Texture,避免每十秒闪回欢迎页。 + await _syncSubScreen('recognizing'); + } else { + unawaited(_clearSubScreen()); + } if (_closed || generation != _requestGeneration) return; - // 自动模式只在隐私展示周期结束后重新布防,结果展示期间不会重复读取同一硬件回调。 + // 清屏先让结果期读卡回调代次失效,再统一以空白等待态启动下一轮。 if (autoRecognitionEnabled.value) await startRecognition(); } @@ -808,6 +909,7 @@ class ReservationMenuQueryController extends GetxController { totalPages: includeResult ? totalPages : 1, days: includeResult ? currentDays : const [], expiresAt: _expiresAt?.toIso8601String(), + continuousFaceRecognition: continuousFaceRecognitionActive, ); try { await _subScreenGateway.update(event); diff --git a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart index 064fb7f..3c742a6 100644 --- a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart @@ -43,6 +43,7 @@ abstract interface class ReservationRecognitionGateway { Future startFace({ required Future Function(String userId) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, + bool embeddedPreview = false, }); /// 停止饭卡识别并释放当前串口回调。 @@ -149,6 +150,7 @@ class TerminalReservationRecognitionGateway Future startFace({ required Future Function(String userId) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, + bool embeddedPreview = false, }) async { await stopCard(); await stopFace(); @@ -212,10 +214,14 @@ class TerminalReservationRecognitionGateway try { handle = await service.start(scene: FaceRecognitionScene.reservation); _faceSessionId = handle.sessionId; - final result = await showFaceRecognitionPreviewDialog( - service: service, - handle: handle, - ); + // 持续模式由查询页面持有常驻 Texture,网关只等待识别结果;普通手动或单轮 + // 人脸仍使用原弹框,避免扩大其他业务入口的交互变化。 + final result = embeddedPreview + ? await handle.result + : await showFaceRecognitionPreviewDialog( + service: service, + handle: handle, + ); if (_faceSessionId != handle.sessionId) return; if (result == null) { final sessionStillActive = service.owns(handle.sessionId); @@ -227,8 +233,8 @@ class TerminalReservationRecognitionGateway )); return; } - // 成功画面已冻结并停留 3 秒,此时先结束原生业务 Session,再进入查询流程。 - // Controller 的 recognized() 会统一 stopAll;提前清空可避免 success 被误发成 cancel。 + // 独立弹框已自行保持成功画面三秒;嵌入模式立即结束识别处理并由原生恢复 + // Texture 实时帧,结果展示期只看画面而不会提前识别下一位。 if (service.owns(handle.sessionId)) await service.finish(); if (_faceSessionId == handle.sessionId) _faceSessionId = null; recognitionCompleted = true; diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index 7a24ca7..8378571 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -49,7 +49,10 @@ class ReservationMenuQueryPage extends GetView { Widget build(BuildContext context) { // 左右工作区各自监听实际使用的 Rx,避免核销倒计时或页码变化重建整张页面。 final workspace = [ - Obx(() => _querySidebar()).width(300.w), + Obx(() { + final continuousFace = controller.continuousFaceRecognitionActive; + return _querySidebar().width(continuousFace ? 430.w : 300.w); + }), Obx( () => ReservationResultPanel( status: controller.status.value, @@ -98,9 +101,14 @@ class ReservationMenuQueryPage extends GetView { autoRecognitionEnabled: controller.autoRecognitionEnabled.value, continuousRecognitionEnabled: controller.continuousRecognitionEnabled.value, + quickVerificationEnabled: controller.quickVerificationEnabled.value, onSelect: controller.selectMethod, onStart: controller.startRecognition, ), + if (controller.continuousFaceRecognitionActive) ...[ + SizedBox(height: 12.h), + _continuousFacePreview(), + ], SizedBox(height: 12.h), const Divider(height: 1, color: XfjDesignTokens.line), SizedBox(height: 12.h), @@ -116,6 +124,94 @@ class ReservationMenuQueryPage extends GetView { ); } + /// 构建持续人脸模式的页面级常驻摄像头;组件只持有 Texture,不接管识别会话。 + Widget _continuousFacePreview() { + final presentation = _continuousFacePresentation(); + final remaining = controller.remainingSeconds.value; + return [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FaceLiveCameraPreview( + key: const ValueKey('reservation-live-face-preview'), + statusText: presentation.text, + statusColor: presentation.color, + scale: 1.w, + ), + ), + ), + SizedBox(height: 8.h), + [ + Icon( + presentation.icon, + color: presentation.color, + size: 20.w, + ), + Text( + remaining > 0 + ? '${presentation.detail},$remaining 秒后识别下一位' + : presentation.detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).expanded(), + ] + .toRowSpace(space: 8.w) + .padding(horizontal: 12.w, vertical: 10.h) + .decorated( + color: presentation.color.withValues(alpha: 0.08), + border: Border.all( + color: presentation.color.withValues(alpha: 0.22), + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ].toColumn(); + } + + /// 把查询状态转换为摄像头区域的稳定文字、颜色和图标,结果期不误导为正在识别下一位。 + ({String text, String detail, Color color, IconData icon}) + _continuousFacePresentation() { + return switch (controller.status.value) { + ReservationMenuQueryStatus.querying => ( + text: '正在查询菜单', + detail: '身份识别成功,正在读取本人预约', + color: XfjDesignTokens.primary, + icon: Icons.search_rounded, + ), + ReservationMenuQueryStatus.success || + ReservationMenuQueryStatus.empty => + ( + text: '当前结果展示中', + detail: '摄像头保持开启,暂不识别其他人员', + color: XfjDesignTokens.success, + icon: Icons.check_circle_outline_rounded, + ), + ReservationMenuQueryStatus.identifyError || + ReservationMenuQueryStatus.networkError || + ReservationMenuQueryStatus.deviceError => + ( + text: '持续识别已暂停', + detail: '请按页面提示处理后重新识别', + color: XfjDesignTokens.warning, + icon: Icons.error_outline_rounded, + ), + _ => ( + text: '持续识别中', + detail: '请下一位用户正对摄像头', + color: XfjDesignTokens.primary, + icon: Icons.face_retouching_natural_rounded, + ), + }; + } + /// 构建本人识别隐私提示,固定在侧栏底部且不承载业务副作用。 Widget _privacyNotice() { return [ diff --git a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart index a5b60d6..57dbede 100644 --- a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart +++ b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart @@ -17,6 +17,7 @@ class QueryMethodPanel extends StatelessWidget { required this.mealSlotController, required this.autoRecognitionEnabled, required this.continuousRecognitionEnabled, + required this.quickVerificationEnabled, required this.onSelect, required this.onStart, }); @@ -36,9 +37,12 @@ class QueryMethodPanel extends StatelessWidget { /// 是否以自动识别状态区替换手动启动按钮。 final bool autoRecognitionEnabled; - /// 自动模式是否在正常等待超时后继续下一轮;同时控制连续接待使用的餐段预选入口显隐。 + /// 自动模式是否在正常等待超时后继续下一轮,用于区分单轮与持续识别状态文案。 final bool continuousRecognitionEnabled; + /// 是否开启查询后的快速核销;仅开启时展示核销目标餐段的预选入口。 + final bool quickVerificationEnabled; + /// 用户切换识别方式时调用的 Controller 回调。 final ValueChanged onSelect; @@ -59,8 +63,8 @@ class QueryMethodPanel extends StatelessWidget { onTap: () => onSelect(method), ), ), - // 预选餐段服务于连续接待流程;关闭持续自动识别时隐藏入口,但保留最近餐段供再次开启时恢复。 - if (continuousRecognitionEnabled) + // 预选餐段只决定快速核销目标;关闭快速核销后隐藏无效入口,但保留最近选择供再次开启时恢复。 + if (quickVerificationEnabled) appRadioHourTypeDialog( context, mealSlotController, diff --git a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart index 3029a6b..c24acad 100644 --- a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart +++ b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart @@ -336,6 +336,7 @@ Color _customerScenarioColor(CustomerDisplayScenario scenario) { SvgAsset _reservationScenarioIcon(ReservationQueryDisplayScenario scenario) { return switch (scenario) { ReservationQueryDisplayScenario.idle => FAssets.icons.badge, + ReservationQueryDisplayScenario.recognizing => FAssets.icons.scanFace, ReservationQueryDisplayScenario.loading => FAssets.icons.loaderCircle, ReservationQueryDisplayScenario.success => FAssets.icons.circleCheck, ReservationQueryDisplayScenario.empty => FAssets.icons.calendarX, @@ -347,6 +348,7 @@ SvgAsset _reservationScenarioIcon(ReservationQueryDisplayScenario scenario) { Color _reservationScenarioColor(ReservationQueryDisplayScenario scenario) { return switch (scenario) { ReservationQueryDisplayScenario.idle || + ReservationQueryDisplayScenario.recognizing || ReservationQueryDisplayScenario.loading || ReservationQueryDisplayScenario.success => XfjDesignTokens.primary, diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart index 5b3d771..e23fbce 100644 --- a/lib/sub_screen/common/models/customer_display_models.dart +++ b/lib/sub_screen/common/models/customer_display_models.dart @@ -37,31 +37,87 @@ extension CustomerDisplayScenarioX on CustomerDisplayScenario { }; } +/// 副屏套餐中的单个组成菜品,只承载名称和每套份数,不参与结算金额。 +@immutable +class CustomerDisplayComboItem { + /// 创建一条已经过副屏消息校验的套餐组成菜品。 + const CustomerDisplayComboItem({ + required this.name, + required this.quantity, + }); + + /// 套餐组成菜品名称,来源于主屏本地每日菜谱快照。 + final String name; + + /// 每套包含的菜品份数,始终为正整数。 + final int quantity; + + /// 按全部展示字段判断两条套餐组成是否一致。 + @override + bool operator ==(Object other) { + return other is CustomerDisplayComboItem && + other.name == name && + other.quantity == quantity; + } + + /// 为副屏状态去重提供与相等判断一致的哈希值。 + @override + int get hashCode => Object.hash(name, quantity); +} + +/// 副屏购物车中的一个主商品,包含可选的食堂套餐组成。 @immutable class CustomerDisplayProduct { + /// 创建一条副屏商品展示记录;旧协议商品默认按单品处理。 const CustomerDisplayProduct({ required this.name, required this.quantityLabel, required this.unitPrice, required this.totalPrice, + this.isCombo = false, + this.comboItems = const [], }); + /// 主商品名称。 final String name; + + /// 按件数量或称重重量的本地化展示文本。 final String quantityLabel; + + /// 主商品单价,单位为分。 final int unitPrice; + + /// 当前购物车条目的结算小计,单位为分。 final int totalPrice; + /// 是否为食堂套餐;旧版副屏消息缺失该字段时为 false。 + final bool isCombo; + + /// 每套包含的组成菜品;单品或本地明细缺失时为空列表。 + final List comboItems; + + /// 按主商品及套餐组成的全部展示字段判断两条记录是否一致。 @override bool operator ==(Object other) { return other is CustomerDisplayProduct && other.name == name && other.quantityLabel == quantityLabel && other.unitPrice == unitPrice && - other.totalPrice == totalPrice; + other.totalPrice == totalPrice && + other.isCombo == isCombo && + listEquals(other.comboItems, comboItems); } + /// 为副屏状态去重提供与相等判断一致的哈希值。 @override - int get hashCode => Object.hash(name, quantityLabel, unitPrice, totalPrice); + int get hashCode => Object.hash( + name, + quantityLabel, + unitPrice, + totalPrice, + isCombo, + Object.hashAll(comboItems), + ); } @immutable diff --git a/lib/sub_screen/common/models/reservation_query_display_models.dart b/lib/sub_screen/common/models/reservation_query_display_models.dart index 5b825f1..5595aba 100644 --- a/lib/sub_screen/common/models/reservation_query_display_models.dart +++ b/lib/sub_screen/common/models/reservation_query_display_models.dart @@ -4,14 +4,29 @@ import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_mo /// 预约查询副屏的稳定展示状态,真实副屏和设置预览共用同一份模型。 enum ReservationQueryDisplayScenario { + /// 普通饭卡或单轮人脸模式尚未开始识别。 idle, + + /// 持续人脸模式正在等待并识别下一位用户。 + recognizing, + + /// 身份已识别,正在请求当前用户的预约菜单。 loading, + + /// 已获取并展示当前用户的预约菜单。 success, + + /// 身份有效,但当前用户没有可展示的预约。 empty, + + /// 识别设备或预约查询出现需要人工恢复的异常。 error, + + /// 普通模式的身份结果已按隐私时限清除。 expired, } +/// 将跨 Engine 场景名称转换为副屏枚举,未知预览参数兼容为成功态。 ReservationQueryDisplayScenario reservationQueryDisplayScenarioFromName( String? name, ) { @@ -22,8 +37,10 @@ ReservationQueryDisplayScenario reservationQueryDisplayScenarioFromName( } extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario { + /// 当前场景用于状态标签的短标题。 String get title => switch (this) { ReservationQueryDisplayScenario.idle => '等待识别', + ReservationQueryDisplayScenario.recognizing => '持续识别中', ReservationQueryDisplayScenario.loading => '正在查询', ReservationQueryDisplayScenario.success => '查询成功', ReservationQueryDisplayScenario.empty => '暂无预约', @@ -31,8 +48,10 @@ extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario { ReservationQueryDisplayScenario.expired => '隐私清屏', }; + /// 当前场景用于预览和辅助说明的完整描述。 String get description => switch (this) { ReservationQueryDisplayScenario.idle => '等待用户刷饭卡或进行人脸识别', + ReservationQueryDisplayScenario.recognizing => '摄像头已开启,正在识别下一位用户', ReservationQueryDisplayScenario.loading => '正在识别本人身份并查询预约菜单', ReservationQueryDisplayScenario.success => '展示当前页预约日期、餐段和菜品', ReservationQueryDisplayScenario.empty => '身份识别成功,但当前没有有效预约', @@ -43,6 +62,7 @@ extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario { @immutable class ReservationQueryDisplayState { + /// 创建已通过 Coordinator 校验的不可变副屏展示快照。 const ReservationQueryDisplayState({ required this.sessionId, required this.revision, @@ -57,6 +77,7 @@ class ReservationQueryDisplayState { this.merchantName, this.expiresAt, this.remainingSeconds = 0, + this.continuousFaceRecognition = false, }); factory ReservationQueryDisplayState.idle({ @@ -82,6 +103,7 @@ class ReservationQueryDisplayState { }) { final current = now ?? DateTime.now(); if (scenario == ReservationQueryDisplayScenario.idle || + scenario == ReservationQueryDisplayScenario.recognizing || scenario == ReservationQueryDisplayScenario.expired) { return ReservationQueryDisplayState( sessionId: 'preview', @@ -92,6 +114,8 @@ class ReservationQueryDisplayState { currentPage: 1, totalPages: 1, days: const [], + continuousFaceRecognition: + scenario == ReservationQueryDisplayScenario.recognizing, ); } if (scenario == ReservationQueryDisplayScenario.loading || @@ -105,6 +129,7 @@ class ReservationQueryDisplayState { currentPage: 1, totalPages: 1, days: const [], + continuousFaceRecognition: false, ); } @@ -147,26 +172,57 @@ class ReservationQueryDisplayState { days: List.unmodifiable(days), expiresAt: current.add(const Duration(seconds: 60)), remainingSeconds: 60, + continuousFaceRecognition: false, ); } + /// 主屏查询页面会话编号,用于拒绝旧页面迟到消息。 final String sessionId; + + /// 当前会话内单调递增的消息版本。 final int revision; + + /// 副屏当前展示的查询阶段。 final ReservationQueryDisplayScenario scenario; + + /// 服务端返回的脱敏姓名;等待和异常场景为空。 final String? maskedName; + + /// 用餐人所属班级或部门;服务端未提供时为空。 final String? deptName; + + /// 当前终端绑定的食堂名称;未取得身份结果时为空。 final String? merchantName; + + /// 当前身份全部预约日期数量,不等同于当前页卡片数。 final int totalDayCount; + + /// 当前身份全部已预约餐次数量。 final int totalMealCount; + + /// 当前结果页码,从一开始。 final int currentPage; + + /// 当前结果总页数,至少为一。 final int totalPages; + + /// 当前页最多十个预约日期,不包含核销订单标识。 final List days; + + /// 当前身份结果的绝对过期时间;非结果场景为空。 final DateTime? expiresAt; + + /// 距离结果清理的剩余秒数,使用等宽数字展示。 final int remainingSeconds; + /// 是否在副屏使用摄像头与预约内容并列的持续人脸布局。 + final bool continuousFaceRecognition; + + /// 复制计时器需要变化的场景或剩余秒数,其余快照内容保持不变。 ReservationQueryDisplayState copyWith({ ReservationQueryDisplayScenario? scenario, int? remainingSeconds, + bool? continuousFaceRecognition, }) { return ReservationQueryDisplayState( sessionId: sessionId, @@ -182,6 +238,8 @@ class ReservationQueryDisplayState { days: days, expiresAt: expiresAt, remainingSeconds: remainingSeconds ?? this.remainingSeconds, + continuousFaceRecognition: + continuousFaceRecognition ?? this.continuousFaceRecognition, ); } @@ -200,7 +258,8 @@ class ReservationQueryDisplayState { other.totalPages == totalPages && listEquals(other.days, days) && other.expiresAt == expiresAt && - other.remainingSeconds == remainingSeconds; + other.remainingSeconds == remainingSeconds && + other.continuousFaceRecognition == continuousFaceRecognition; } @override @@ -218,5 +277,6 @@ class ReservationQueryDisplayState { Object.hashAll(days), expiresAt, remainingSeconds, + continuousFaceRecognition, ); } diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 434932c..83ef619 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -160,6 +160,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier { ); } + /// 将单个主屏购物车条目解析为副屏商品,并兼容旧协议缺失套餐字段的情况。 CustomerDisplayProduct? _parseProduct(Object? rawProduct) { final data = _normalizeMap(rawProduct); if (data == null) return null; @@ -188,11 +189,45 @@ class CustomerDisplayCoordinator extends ChangeNotifier { return null; } + final rawIsCombo = data['isCombo']; + if (rawIsCombo != null && rawIsCombo is! bool) return null; + final comboItems = []; + final rawComboItems = data['comboItems']; + if (rawComboItems != null) { + if (rawComboItems is! List) return null; + for (final rawComboItem in rawComboItems) { + final comboItem = _parseComboItem(rawComboItem); + if (comboItem == null) return null; + comboItems.add(comboItem); + } + } + return CustomerDisplayProduct( name: name.trim(), quantityLabel: quantityLabel, unitPrice: unitPrice, totalPrice: totalPrice, + // 套餐组成非空时兼容主屏仅透传明细但缺失标识的过渡消息。 + isCombo: rawIsCombo == true || comboItems.isNotEmpty, + comboItems: List.unmodifiable(comboItems), + ); + } + + /// 校验主屏透传的套餐组成;份数必须为正,避免副屏展示无意义的零份菜品。 + CustomerDisplayComboItem? _parseComboItem(Object? rawComboItem) { + final data = _normalizeMap(rawComboItem); + if (data == null) return null; + final name = data['name']; + final quantity = _nonNegativeInt(data['quantity']); + if (name is! String || + name.trim().isEmpty || + quantity == null || + quantity == 0) { + return null; + } + return CustomerDisplayComboItem( + name: name.trim(), + quantity: quantity, ); } diff --git a/lib/sub_screen/common/utils/face_recognition_coordinator.dart b/lib/sub_screen/common/utils/face_recognition_coordinator.dart index d8227b8..0bb496d 100644 --- a/lib/sub_screen/common/utils/face_recognition_coordinator.dart +++ b/lib/sub_screen/common/utils/face_recognition_coordinator.dart @@ -56,6 +56,12 @@ class FaceRecognitionCoordinator extends ChangeNotifier { current.status != FaceRecognitionDisplayStatus.cancelled) { return FaceRecognitionActionResult.ignored; } + // 取消后不允许同一会话的迟到开始消息重新打开副屏人脸页。 + if (current != null && + current.sessionId == sessionId && + current.status == FaceRecognitionDisplayStatus.cancelled) { + return FaceRecognitionActionResult.ignored; + } if (current != null && current.sessionId == sessionId && revision <= current.revision) { @@ -86,6 +92,10 @@ class FaceRecognitionCoordinator extends ChangeNotifier { revision <= current.revision) { return FaceRecognitionActionResult.ignored; } + // 取消是本会话的终态,原生取消过程中迟到的检测事件不得恢复视图。 + if (current.status == FaceRecognitionDisplayStatus.cancelled) { + return FaceRecognitionActionResult.ignored; + } final status = switch (action) { _detecting => FaceRecognitionDisplayStatus.detecting, _success => FaceRecognitionDisplayStatus.success, diff --git a/lib/sub_screen/common/utils/reservation_query_coordinator.dart b/lib/sub_screen/common/utils/reservation_query_coordinator.dart index 4510c1e..64d419b 100644 --- a/lib/sub_screen/common/utils/reservation_query_coordinator.dart +++ b/lib/sub_screen/common/utils/reservation_query_coordinator.dart @@ -49,12 +49,7 @@ class ReservationQueryCoordinator extends ChangeNotifier { _commitEnvelope(next.sessionId, next.revision); if (next.expiresAt != null && !next.expiresAt!.isAfter(_now())) { _stopExpiryTimer(); - return _replace( - ReservationQueryDisplayState.idle( - sessionId: next.sessionId, - revision: next.revision, - ), - ); + return _replace(_privacyClearedState(next)); } final changed = _replace(next); if (next.expiresAt == null) { @@ -94,6 +89,8 @@ class ReservationQueryCoordinator extends ChangeNotifier { final sessionId = _nonEmptyString(data['sessionId']); final revision = _nonNegativeInt(data['revision']); final scenarioName = _nonEmptyString(data['scenario']); + final continuousFaceRecognition = + _optionalBool(data['continuousFaceRecognition']); ReservationQueryDisplayScenario? scenario; for (final value in ReservationQueryDisplayScenario.values) { if (value.name == scenarioName) { @@ -104,6 +101,7 @@ class ReservationQueryCoordinator extends ChangeNotifier { if (sessionId == null || revision == null || scenario == null) return null; if (scenario == ReservationQueryDisplayScenario.idle || + scenario == ReservationQueryDisplayScenario.recognizing || scenario == ReservationQueryDisplayScenario.loading || scenario == ReservationQueryDisplayScenario.error) { return ReservationQueryDisplayState( @@ -115,6 +113,7 @@ class ReservationQueryCoordinator extends ChangeNotifier { currentPage: 1, totalPages: 1, days: const [], + continuousFaceRecognition: continuousFaceRecognition, ); } @@ -175,6 +174,7 @@ class ReservationQueryCoordinator extends ChangeNotifier { days: List.unmodifiable(days), expiresAt: expiresAt, remainingSeconds: remaining, + continuousFaceRecognition: continuousFaceRecognition, ); } @@ -207,12 +207,7 @@ class ReservationQueryCoordinator extends ChangeNotifier { (expiresAt.difference(_now()).inMilliseconds / 1000).ceil(); if (remaining <= 0) { _stopExpiryTimer(); - _replace( - ReservationQueryDisplayState.idle( - sessionId: _state.sessionId, - revision: _state.revision, - ), - ); + _replace(_privacyClearedState(_state)); return; } _replace(_state.copyWith(remainingSeconds: remaining)); @@ -265,6 +260,36 @@ class ReservationQueryCoordinator extends ChangeNotifier { return result == null || result == 0 ? null : result; } + /// 读取可选布尔协议字段;旧主屏未发送时按关闭处理,错误类型直接拒绝整条消息。 + bool _optionalBool(Object? value) { + if (value == null) return false; + if (value is bool) return value; + throw const FormatException('布尔字段格式错误'); + } + + /// 清除身份结果;持续人脸保留组合页并等待下一位,其他模式回到普通空闲态。 + ReservationQueryDisplayState _privacyClearedState( + ReservationQueryDisplayState current, + ) { + if (!current.continuousFaceRecognition) { + return ReservationQueryDisplayState.idle( + sessionId: current.sessionId, + revision: current.revision, + ); + } + return ReservationQueryDisplayState( + sessionId: current.sessionId, + revision: current.revision, + scenario: ReservationQueryDisplayScenario.recognizing, + totalDayCount: 0, + totalMealCount: 0, + currentPage: 1, + totalPages: 1, + days: const [], + continuousFaceRecognition: true, + ); + } + @override void dispose() { if (_disposed) return; diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index e427d05..92b69c9 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -67,6 +67,15 @@ class SubDisplayCoordinator extends ChangeNotifier { final result = faceRecognitionCoordinator.handleAction(action, arguments); if (!result.accepted) return false; final faceState = faceRecognitionCoordinator.state; + if (_isContinuousReservationFace(faceState)) { + // 持续人脸由预约组合页长期持有副屏 Texture,人脸动作只刷新状态和框, + // 禁止再切换到旧的独立人脸页面或等待三秒恢复计时器。 + _faceRestoreTimer?.cancel(); + _resetReservationFaceHandoff(); + _modeBeforeFace = SubDisplayMode.reservationQuery; + _replaceMode(SubDisplayMode.reservationQuery); + return true; + } switch (result.transition) { case FaceRecognitionDisplayTransition.show: _faceRestoreTimer?.cancel(); @@ -117,8 +126,16 @@ class SubDisplayCoordinator extends ChangeNotifier { final accepted = reservationCoordinator.handleUpdate(arguments); if (accepted) { _reservationClearedDuringFace = false; - _showOrRememberMode(SubDisplayMode.reservationQuery); - _handleReservationUpdateDuringFace(); + if (reservationCoordinator.state.continuousFaceRecognition) { + // 该快照必须先于原生 faceStart 落地;即使消息顺序偶发反转,也强制恢复组合页。 + _faceRestoreTimer?.cancel(); + _resetReservationFaceHandoff(); + _modeBeforeFace = SubDisplayMode.reservationQuery; + _replaceMode(SubDisplayMode.reservationQuery); + } else { + _showOrRememberMode(SubDisplayMode.reservationQuery); + _handleReservationUpdateDuringFace(); + } } return accepted; } @@ -169,6 +186,12 @@ class SubDisplayCoordinator extends ChangeNotifier { return true; } + /// 判断当前人脸动作是否属于已启用组合页的预约持续识别会话。 + bool _isContinuousReservationFace(FaceRecognitionDisplayState? faceState) { + return faceState?.scene == FaceRecognitionDisplayScene.reservation && + reservationCoordinator.state.continuousFaceRecognition; + } + void _beginReservationFaceHandoff() { final reservationState = reservationCoordinator.state; _reservationSessionAtFaceStart = reservationState.sessionId; diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index 5c8241f..4291d95 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -419,40 +419,216 @@ class _ProductHeader extends StatelessWidget { } class _ProductRow extends StatelessWidget { + /// 创建一条可同时展示主商品和套餐组成的副屏商品行。 const _ProductRow({required this.product, required this.scale}); + /// 当前主商品及其可选套餐组成。 final CustomerDisplayProduct product; + + /// 基于副屏分辨率计算的统一缩放比例。 final double scale; + /// 构建主商品表格行,并在套餐下方追加不参与计价的组成明细。 @override Widget build(BuildContext context) { - return Container( + return ConstrainedBox( constraints: BoxConstraints(minHeight: 74 * scale), - padding: - EdgeInsets.symmetric(horizontal: 22 * scale, vertical: 10 * scale), - child: Row( + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: 22 * scale, vertical: 10 * scale), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + flex: 5, + child: Row( + children: [ + _ProductTypeBadge( + isCombo: product.isCombo, + scale: scale, + ), + SizedBox(width: 9 * scale), + Expanded( + child: Text( + product.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 18 * scale, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + _valueCell(product.quantityLabel, 2, scale), + _valueCell(_money(product.unitPrice), 2, scale), + _valueCell( + _money(product.totalPrice), + 2, + scale, + emphasized: true, + ), + ], + ), + if (product.isCombo && product.comboItems.isNotEmpty) ...[ + SizedBox(height: 10 * scale), + _ComboContents(product: product, scale: scale), + ], + ], + ), + ), + ); + } +} + +/// 副屏主商品类型标识,帮助顾客快速区分单品和套餐。 +class _ProductTypeBadge extends StatelessWidget { + /// 创建商品类型标识。 + const _ProductTypeBadge({required this.isCombo, required this.scale}); + + /// 是否展示为套餐标识。 + final bool isCombo; + + /// 基于副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建紧凑且不抢占商品名称空间的类型标识。 + @override + Widget build(BuildContext context) { + final foreground = + isCombo ? XfjDesignTokens.primary : XfjDesignTokens.muted; + final background = + isCombo ? XfjDesignTokens.primarySoft : XfjDesignTokens.control; + return DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(4 * scale), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 7 * scale, + vertical: 3 * scale, + ), + child: Text( + isCombo ? '套餐' : '单品', + style: TextStyle( + color: foreground, + fontSize: 13 * scale, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +/// 套餐主商品下方的组成明细带,展示每套菜品名称和份数但不重复展示金额。 +class _ComboContents extends StatelessWidget { + /// 创建指定套餐的组成明细带。 + const _ComboContents({required this.product, required this.scale}); + + /// 当前套餐主商品,组成列表非空时才会创建本组件。 + final CustomerDisplayProduct product; + + /// 基于副屏分辨率计算的统一缩放比例。 + final double scale; + + /// 构建套餐汇总说明及逐项组成,长名称在当前行内截断以保持列宽稳定。 + @override + Widget build(BuildContext context) { + final totalQuantity = product.comboItems.fold( + 0, + (total, item) => total + item.quantity, + ); + return Container( + key: ValueKey('combo-details-${product.name}'), + padding: EdgeInsets.fromLTRB( + 14 * scale, + 10 * scale, + 14 * scale, + 11 * scale, + ), + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border( + left: BorderSide(color: XfjDesignTokens.primary, width: 3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - flex: 5, - child: Text( - product.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: XfjDesignTokens.ink, - fontSize: 18 * scale, - fontWeight: FontWeight.w700, + Row( + children: [ + Icon( + Icons.layers_outlined, + size: 17 * scale, + color: XfjDesignTokens.primary, + ), + SizedBox(width: 7 * scale), + Expanded( + child: Text( + '套餐内含 · 每套 ${product.comboItems.length} 样 / $totalQuantity 份', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: 14 * scale, + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 12 * scale), + Text( + '明细不单独计价', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 12 * scale, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + for (final item in product.comboItems) + Padding( + padding: EdgeInsets.only(top: 8 * scale), + child: Row( + children: [ + Icon( + Icons.subdirectory_arrow_right_rounded, + size: 16 * scale, + color: XfjDesignTokens.muted, + ), + SizedBox(width: 8 * scale), + Expanded( + child: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 15 * scale, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 12 * scale), + Text( + '×${item.quantity}', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 15 * scale, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], ), ), - ), - _valueCell(product.quantityLabel, 2, scale), - _valueCell(_money(product.unitPrice), 2, scale), - _valueCell( - _money(product.totalPrice), - 2, - scale, - emphasized: true, - ), ], ), ); diff --git a/lib/sub_screen/page/reservation_query_display.dart b/lib/sub_screen/page/reservation_query_display.dart index 32453ae..220627b 100644 --- a/lib/sub_screen/page/reservation_query_display.dart +++ b/lib/sub_screen/page/reservation_query_display.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -104,7 +105,12 @@ class _ReservationQueryDisplayView extends StatelessWidget { _ReservationHeader(state: state, scale: scale), SizedBox(height: 14 * scale), Expanded( - child: _ReservationBody(state: state, scale: scale), + child: state.continuousFaceRecognition + ? _ContinuousFaceReservationBody( + state: state, + scale: scale, + ) + : _ReservationBody(state: state, scale: scale), ), SizedBox(height: 12 * scale), _ReservationFooter(state: state, scale: scale), @@ -236,6 +242,152 @@ class _HeaderPill extends StatelessWidget { } } +/// 持续人脸副屏组合区,左侧长期保留实时预览,右侧按状态切换等待或预约菜单。 +class _ContinuousFaceReservationBody extends StatelessWidget { + /// 创建与预约快照同步的持续人脸组合布局。 + const _ContinuousFaceReservationBody({ + required this.state, + required this.scale, + }); + + /// 当前主屏发送并经 Coordinator 校验的预约展示快照。 + final ReservationQueryDisplayState state; + + /// 基于副屏实际尺寸计算的统一缩放系数。 + final double scale; + + /// 构建固定宽度摄像头区域和弹性预约结果区域,状态变化不会销毁 Texture。 + @override + Widget build(BuildContext context) { + final presentation = _continuousFacePresentation(state.scenario); + final countdown = state.remainingSeconds; + final cameraPanel = [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(12 * scale), + child: FaceLiveCameraPreview( + key: const ValueKey('sub-reservation-live-face-preview'), + statusText: presentation.text, + statusColor: presentation.color, + scale: scale, + ), + ), + ), + SizedBox(height: 18 * scale), + [ + Icon( + presentation.icon, + color: presentation.color, + size: 34 * scale, + ), + [ + Text( + presentation.title, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 25 * scale, + fontWeight: FontWeight.w900, + ), + ), + SizedBox(height: 5 * scale), + Text( + countdown > 0 + ? '${presentation.detail},$countdown 秒后识别下一位' + : presentation.detail, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 16 * scale, + height: 1.45, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ].toRowSpace(space: 14 * scale).padding(all: 16 * scale).decorated( + color: presentation.color.withValues(alpha: 0.08), + border: Border.all( + color: presentation.color.withValues(alpha: 0.22), + ), + borderRadius: BorderRadius.circular(12 * scale), + ), + const Spacer(), + [ + Icon( + Icons.privacy_tip_outlined, + color: XfjDesignTokens.primary, + size: 21 * scale, + ), + Text( + '实时画面仅用于本人识别,不保存影像', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 14 * scale, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ].toRowSpace(space: 9 * scale), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(all: 16 * scale) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(16 * scale), + boxShadow: XfjDesignTokens.surfaceShadow, + ); + + return [ + cameraPanel.expanded(flex: 3), + _ReservationBody(state: state, scale: scale).expanded(flex: 8), + ].toRowSpace( + space: 14 * scale, + crossAxisAlignment: CrossAxisAlignment.stretch, + ); + } + + /// 把预约阶段转换为摄像头区域的独立状态,菜单展示期明确暂停下一位识别。 + ({ + String text, + String title, + String detail, + Color color, + IconData icon, + }) _continuousFacePresentation(ReservationQueryDisplayScenario scenario) { + return switch (scenario) { + ReservationQueryDisplayScenario.loading => ( + text: '正在查询菜单', + title: '身份识别成功', + detail: '正在读取本人预约菜单', + color: XfjDesignTokens.primary, + icon: Icons.search_rounded, + ), + ReservationQueryDisplayScenario.success || + ReservationQueryDisplayScenario.empty => + ( + text: '当前结果展示中', + title: '预约结果展示中', + detail: '摄像头保持开启,暂不识别其他人员', + color: XfjDesignTokens.success, + icon: Icons.check_circle_outline_rounded, + ), + ReservationQueryDisplayScenario.error => ( + text: '持续识别已暂停', + title: '识别暂时中断', + detail: '请等待工作人员处理后重新识别', + color: XfjDesignTokens.warning, + icon: Icons.error_outline_rounded, + ), + _ => ( + text: '持续识别中', + title: '请正对摄像头', + detail: '正在等待并识别下一位用户', + color: XfjDesignTokens.primary, + icon: Icons.face_retouching_natural_rounded, + ), + }; + } +} + class _ReservationBody extends StatelessWidget { const _ReservationBody({required this.state, required this.scale}); @@ -394,6 +546,7 @@ class _ReservationStatePanel extends StatelessWidget { final color = _scenarioColor(state.scenario); final title = switch (state.scenario) { ReservationQueryDisplayScenario.idle => '请刷饭卡或进行人脸识别', + ReservationQueryDisplayScenario.recognizing => '正在识别下一位用户', ReservationQueryDisplayScenario.loading => '正在查询本人预约', ReservationQueryDisplayScenario.empty => '当前没有有效预约', ReservationQueryDisplayScenario.error => '暂时无法查询预约', @@ -402,6 +555,7 @@ class _ReservationStatePanel extends StatelessWidget { }; final description = switch (state.scenario) { ReservationQueryDisplayScenario.idle => '识别成功后,这里将展示当前用户本人的预约菜单', + ReservationQueryDisplayScenario.recognizing => '请正对摄像头,识别成功后将展示本人预约菜单', ReservationQueryDisplayScenario.loading => '请稍候,不要重复刷卡或离开识别区域', ReservationQueryDisplayScenario.empty => '当前食堂今天及未来日期暂无可展示的已支付预约', ReservationQueryDisplayScenario.error => '请在主屏检查网络或识别设备后重新尝试', @@ -488,7 +642,9 @@ class _ReservationFooter extends StatelessWidget { SizedBox(width: 8 * scale), Expanded( child: Text( - '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号', + state.continuousFaceRecognition + ? '持续识别已开启,仅展示当前用户本人的有效预约' + : '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号', style: TextStyle( color: XfjDesignTokens.muted, fontSize: 14 * scale, @@ -523,6 +679,8 @@ class _ReservationFooter extends StatelessWidget { IconData _scenarioIcon(ReservationQueryDisplayScenario scenario) { return switch (scenario) { ReservationQueryDisplayScenario.idle => Icons.badge_outlined, + ReservationQueryDisplayScenario.recognizing => + Icons.face_retouching_natural_rounded, ReservationQueryDisplayScenario.loading => Icons.search_rounded, ReservationQueryDisplayScenario.success => Icons.check_circle_outline, ReservationQueryDisplayScenario.empty => Icons.event_busy_outlined, @@ -534,6 +692,7 @@ IconData _scenarioIcon(ReservationQueryDisplayScenario scenario) { Color _scenarioColor(ReservationQueryDisplayScenario scenario) { return switch (scenario) { ReservationQueryDisplayScenario.idle || + ReservationQueryDisplayScenario.recognizing || ReservationQueryDisplayScenario.loading || ReservationQueryDisplayScenario.success => XfjDesignTokens.primary, diff --git a/pubspec.yaml b/pubspec.yaml index 3347f51..7601520 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.3.3+133 +version: 1.3.3+134 environment: sdk: ^3.6.0 diff --git a/test/common/app_dialog_test.dart b/test/common/app_dialog_test.dart index cb583a7..4cfb912 100644 --- a/test/common/app_dialog_test.dart +++ b/test/common/app_dialog_test.dart @@ -1,4 +1,5 @@ import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -62,6 +63,113 @@ void main() { expect(result, isNull); }); + testWidgets('默认弹框位于顶部安全区下方并保留固定间隔', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 600); + addTearDown(tester.view.reset); + const viewPadding = EdgeInsets.fromLTRB(16, 18, 16, 12); + await tester.pumpWidget( + _dialogTestApp( + viewPadding: viewPadding, + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showAppDialog( + context: context, + builder: (dialogContext) => AppDialog( + key: const ValueKey('top-dialog'), + title: const Text('顶部弹框'), + width: 360, + maxWidth: 360, + content: const Text('默认不再相对整个屏幕居中'), + onClose: () => Navigator.of(dialogContext).pop(), + ), + ), + child: const Text('打开顶部弹框'), + ), + ), + ), + ); + + await tester.tap(find.text('打开顶部弹框')); + await tester.pumpAndSettle(); + + final rect = tester.getRect(find.byKey(const ValueKey('top-dialog'))); + expect( + rect.top, + closeTo(viewPadding.top + appDialogDefaultTopSpacing, 0.01), + ); + expect( + rect.left, + greaterThanOrEqualTo(viewPadding.left + appDialogDefaultEdgeSpacing), + ); + expect( + rect.right, + lessThanOrEqualTo( + 800 - viewPadding.right - appDialogDefaultEdgeSpacing, + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('键盘出现后弹框动态缩入键盘上方可用区域', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 600); + addTearDown(tester.view.reset); + final viewInsets = ValueNotifier(EdgeInsets.zero); + addTearDown(viewInsets.dispose); + await tester.pumpWidget( + _dialogTestApp( + viewInsetsListenable: viewInsets, + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showAppDialog( + context: context, + builder: (dialogContext) => AppDialog( + key: const ValueKey('keyboard-dialog'), + title: const Text('输入信息'), + width: 420, + maxWidth: 420, + height: 400, + content: const TextField( + key: ValueKey('keyboard-dialog-field'), + autofocus: true, + ), + onClose: () => Navigator.of(dialogContext).pop(), + ), + ), + child: const Text('打开输入弹框'), + ), + ), + ), + ); + + await tester.tap(find.text('打开输入弹框')); + await tester.pumpAndSettle(); + final initialRect = tester.getRect( + find.byKey(const ValueKey('keyboard-dialog')), + ); + expect(initialRect.top, closeTo(appDialogDefaultTopSpacing, 0.01)); + + viewInsets.value = const EdgeInsets.only(bottom: 260); + await tester.pump(); + await tester.pump(appDialogDefaultTransitionDuration); + + final keyboardRect = tester.getRect( + find.byKey(const ValueKey('keyboard-dialog')), + ); + final fieldRect = tester.getRect( + find.byKey(const ValueKey('keyboard-dialog-field')), + ); + final keyboardTop = 600 - viewInsets.value.bottom; + expect(keyboardRect.top, closeTo(appDialogDefaultTopSpacing, 0.01)); + expect( + keyboardRect.bottom, + lessThanOrEqualTo(keyboardTop - appDialogDefaultEdgeSpacing), + ); + expect(fieldRect.bottom, lessThanOrEqualTo(keyboardRect.bottom)); + expect(tester.takeException(), isNull); + }); + testWidgets('禁止关闭时节流震动且减少动态模式使用边框高亮', (tester) async { await tester.pumpWidget( _dialogTestApp( @@ -295,20 +403,36 @@ Widget _dialogTestApp({ required Widget child, bool disableAnimations = false, EdgeInsets viewPadding = EdgeInsets.zero, + ValueListenable? viewInsetsListenable, }) { return ScreenUtilInit( designSize: const Size(1920, 1080), minTextAdapt: true, splitScreenMode: true, builder: (_, __) => MaterialApp( - builder: (context, appChild) => MediaQuery( - data: MediaQuery.of(context).copyWith( + builder: (context, appChild) { + final mediaData = MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, padding: viewPadding, viewPadding: viewPadding, - ), - child: FTheme(data: FThemes.zinc.light, child: appChild!), - ), + ); + final themedChild = FTheme( + data: FThemes.zinc.light, + child: appChild!, + ); + final viewInsets = viewInsetsListenable; + if (viewInsets == null) { + return MediaQuery(data: mediaData, child: themedChild); + } + return ValueListenableBuilder( + valueListenable: viewInsets, + builder: (_, insets, child) => MediaQuery( + data: mediaData.copyWith(viewInsets: insets), + child: child!, + ), + child: themedChild, + ); + }, home: Scaffold(body: Center(child: child)), ), ); diff --git a/test/common/face_recognition_preview_layer_test.dart b/test/common/face_recognition_preview_layer_test.dart index ab43307..2817c1d 100644 --- a/test/common/face_recognition_preview_layer_test.dart +++ b/test/common/face_recognition_preview_layer_test.dart @@ -61,9 +61,23 @@ void main() { }); expect(event.keepMaxFace, isTrue); + expect(event.eventSequence, 0); expect(event.faceBoxes, hasLength(2)); }); + test('原生事件可解析进程级单调序号', () { + final event = FaceRecognitionSessionEvent.fromMap({ + 'sessionId': 'face-session-2', + 'scene': 'reservation', + 'state': 'detecting', + 'revision': 3, + 'eventSequence': 17, + 'faceBoxes': const [], + }); + + expect(event.eventSequence, 17); + }); + test('归一化面积计算异常坐标时不会产生负占比', () { const invalidFace = FaceRecognitionFaceBox( left: 0.8, @@ -122,6 +136,36 @@ void main() { expect(painter.labelFor(1), isNot(contains('占画面'))); expect(painter.labelFor(1), contains('相似度 86.0%')); }); + + test('低相似度和尚未比对的人脸仍全部进入绘制器', () { + const lowSimilarityFace = FaceRecognitionFaceBox( + left: 0.08, + top: 0.12, + right: 0.34, + bottom: 0.48, + recognizeStatus: 'failed', + livenessStatus: 'unknown', + similarity: 0.12, + ); + const waitingFace = FaceRecognitionFaceBox( + left: 0.55, + top: 0.10, + right: 0.88, + bottom: 0.52, + recognizeStatus: 'waiting', + livenessStatus: 'unknown', + ); + final painter = _painter( + boxes: const [lowSimilarityFace, waitingFace], + keepMaxFace: true, + ); + + expect(painter.boxes, hasLength(2)); + expect(painter.labelFor(0), contains('非识别目标')); + expect(painter.labelFor(0), contains('相似度 12.0%')); + expect(painter.labelFor(1), contains('识别目标')); + expect(painter.labelFor(1), contains('检测中')); + }); } /// 创建只用于判断面积、颜色和标签的绘制器,不实际申请摄像头 Texture。 diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 3638201..0db10f4 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.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/terminal/menu_query/controller.dart'; @@ -8,6 +12,7 @@ import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_menu_ import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_recognition_gateway.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_sub_screen_gateway.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_verification_gateway.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/menu_query/view.dart'; void main() { testWidgets('页面配置决定查询方式顺序和默认项,进入页面不自动识别', (tester) async { @@ -166,18 +171,20 @@ void main() { await controller.clearForPrivacy(); }); - testWidgets('自动饭卡模式进入页面即等待放卡,清屏后继续接收下一位用户', (tester) async { - var now = DateTime(2026, 8, 4, 8); - final repository = _ImmediateRepository(_result('张*明', 1)); + testWidgets('持续自动饭卡在结果期继续读卡并用下一位结果覆盖', (tester) async { + final repository = _SequenceRepository([ + _result('张*明', 1), + _result('李*华', 1), + ]); final recognition = _FakeRecognitionGateway(); + final subScreen = _FakeSubScreenGateway(); final controller = ReservationMenuQueryController( repository: repository, recognitionGateway: recognition, - subScreenGateway: _FakeSubScreenGateway(), + subScreenGateway: subScreen, networkAvailable: () => true, - privacyDuration: const Duration(seconds: 2), - now: () => now, preferencesLoader: _autoCardPreferences, + recognitionRestartDelay: const Duration(milliseconds: 10), ); addTearDown(controller.onClose); @@ -194,14 +201,49 @@ void main() { await duplicate; expect(repository.calls, hasLength(1)); expect(controller.status.value, ReservationMenuQueryStatus.success); - - now = now.add(const Duration(seconds: 2)); - await tester.pump(const Duration(seconds: 1)); - await tester.pump(); - - expect(controller.result.value, isNull); - expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(controller.result.value?.maskedName, '张*明'); expect(recognition.cardCallbacks, hasLength(2)); + + // 第一张卡未移开时可能被新一轮读到,但不应重复请求同一位用户。 + await recognition.emitCardAt(1, 'CARD-001'); + expect(repository.calls, hasLength(1)); + expect(controller.result.value?.maskedName, '张*明'); + await tester.pump(const Duration(milliseconds: 10)); + expect(recognition.cardCallbacks, hasLength(3)); + + await recognition.emitCardAt(2, 'CARD-002'); + + expect(repository.calls, [ + const _QueryCall(identityValue: 'CARD-001', maskName: true), + const _QueryCall(identityValue: 'CARD-002', maskName: true), + ]); + expect(controller.status.value, ReservationMenuQueryStatus.success); + expect(controller.result.value?.maskedName, '李*华'); + expect(subScreen.updates.last.scenario, 'success'); + expect(subScreen.updates.last.maskedName, '李*华'); + expect(recognition.cardCallbacks, hasLength(4)); + await controller.clearForPrivacy(); + }); + + testWidgets('持续自动识别关闭时饭卡结果期不会重新布防读卡器', (tester) async { + final recognition = _FakeRecognitionGateway(); + final controller = ReservationMenuQueryController( + repository: _ImmediateRepository(_result('张*明', 1)), + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + networkAvailable: () => true, + preferencesLoader: _autoCardSingleRoundPreferences, + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + await recognition.emitCardAt(0, 'CARD-001'); + + expect(controller.status.value, ReservationMenuQueryStatus.success); + expect(controller.result.value?.maskedName, '张*明'); + expect(recognition.cardCallbacks, hasLength(1)); + await controller.clearForPrivacy(); }); testWidgets('饭卡和人脸结果弹框展示期间暂停识别,关闭后才自动开始下一轮', (tester) async { @@ -236,7 +278,7 @@ void main() { await tester.pump(const Duration(seconds: 61)); if (recognitionMethod == ReservationRecognitionMethod.card) { - expect(recognition.cardCallbacks, hasLength(1)); + expect(recognition.cardCallbacks, hasLength(2)); } else { expect(recognition.faceCallbacks, hasLength(1)); } @@ -246,7 +288,7 @@ void main() { expect(controller.result.value, isNull); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); if (recognitionMethod == ReservationRecognitionMethod.card) { - expect(recognition.cardCallbacks, hasLength(2)); + expect(recognition.cardCallbacks, hasLength(3)); } else { expect(recognition.faceCallbacks, hasLength(2)); } @@ -299,6 +341,7 @@ void main() { expect(controller.method.value, ReservationRecognitionMethod.face); expect(controller.status.value, ReservationMenuQueryStatus.recognizing); expect(recognition.faceCallbacks, hasLength(1)); + expect(recognition.faceEmbeddedPreviews, [isTrue]); await recognition.emitFaceAt(0, '1001'); expect(repository.calls, [ @@ -312,6 +355,95 @@ void main() { expect(recognition.cardCallbacks, hasLength(1)); }); + testWidgets('持续人脸结果固定展示十秒并保留副屏组合页后识别下一位', (tester) async { + var now = DateTime(2026, 8, 7, 10); + final recognition = _FakeRecognitionGateway(); + final subScreen = _FakeSubScreenGateway(); + final controller = ReservationMenuQueryController( + repository: _ImmediateRepository(_result('李*华', 1)), + recognitionGateway: recognition, + subScreenGateway: subScreen, + networkAvailable: () => true, + preferencesLoader: _autoFacePreferences, + continuousFaceResultDuration: const Duration(seconds: 10), + now: () => now, + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + + expect(controller.continuousFaceRecognitionActive, isTrue); + expect(recognition.faceEmbeddedPreviews, [isTrue]); + expect(subScreen.updates.first.scenario, 'recognizing'); + expect(subScreen.updates.first.continuousFaceRecognition, isTrue); + + await recognition.emitFaceAt(0, '1001'); + expect(controller.status.value, ReservationMenuQueryStatus.success); + expect(controller.remainingSeconds.value, 10); + expect(subScreen.updates.last.scenario, 'success'); + expect(subScreen.updates.last.continuousFaceRecognition, isTrue); + + now = now.add(const Duration(seconds: 10)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(controller.result.value, isNull); + expect(controller.status.value, ReservationMenuQueryStatus.recognizing); + expect(recognition.faceCallbacks, hasLength(2)); + expect(recognition.faceEmbeddedPreviews, [isTrue, isTrue]); + expect(subScreen.clearCalls, 0); + expect(subScreen.updates.last.scenario, 'recognizing'); + expect(subScreen.updates.last.continuousFaceRecognition, isTrue); + }); + + testWidgets('持续人脸主屏在 1920x1080 保留常驻摄像头且无布局异常', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = ReservationMenuQueryController( + repository: _ImmediateRepository(_result('李*华', 1)), + recognitionGateway: _FakeRecognitionGateway(), + subScreenGateway: _FakeSubScreenGateway(), + networkAvailable: () => true, + preferencesLoader: _autoFacePreferences, + ); + Get.testMode = true; + Get.put(controller); + addTearDown(() async { + if (Get.isRegistered()) { + await Get.delete(force: true); + } + Get.reset(); + }); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: const Scaffold(body: ReservationMenuQueryPage()), + ), + ), + ), + ); + await tester.pump(); + + final preview = find.byKey( + const ValueKey('reservation-live-face-preview'), + ); + expect(preview, findsOneWidget); + final previewSize = tester.getSize(preview); + expect(previewSize.width / previewSize.height, closeTo(16 / 9, 0.01)); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await Get.delete(force: true); + Get.reset(); + }); + testWidgets('自动人脸被取消后暂停且不循环弹窗,仍可人工重新识别', (tester) async { final recognition = _FakeRecognitionGateway(); final controller = ReservationMenuQueryController( @@ -1105,6 +1237,9 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway { final cardFailures = []; final faceCallbacks = Function(String)>[]; final faceFailures = []; + + /// 每次人脸启动是否使用页面内嵌预览,用于校验持续模式不会再弹独立识别框。 + final faceEmbeddedPreviews = []; int stopAllCalls = 0; Future emitCardAt(int index, String value) { @@ -1128,9 +1263,11 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway { Future startFace({ required Future Function(String userId) onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, + bool embeddedPreview = false, }) async { faceCallbacks.add(onRecognized); faceFailures.add(onFailure); + faceEmbeddedPreviews.add(embeddedPreview); } @override diff --git a/test/pages/terminal/query_method_panel_test.dart b/test/pages/terminal/query_method_panel_test.dart index bd68311..50f7634 100644 --- a/test/pages/terminal/query_method_panel_test.dart +++ b/test/pages/terminal/query_method_panel_test.dart @@ -25,6 +25,7 @@ void main() { mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: false, + quickVerificationEnabled: false, onSelect: (method) => selectedMethod = method, onStart: () => startCalls++, ), @@ -45,6 +46,7 @@ void main() { mealSlotController: mealSlotController, autoRecognitionEnabled: true, continuousRecognitionEnabled: true, + quickVerificationEnabled: false, onSelect: (method) => selectedMethod = method, onStart: () => startCalls++, ), @@ -60,7 +62,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('持续自动识别关闭时隐藏餐段选择,开启后恢复展示', (tester) async { + testWidgets('餐段只由快速核销开关控制,不受持续自动识别影响', (tester) async { await tester.binding.setSurfaceSize(const Size(1920, 1080)); addTearDown(() => tester.binding.setSurfaceSize(null)); final mealSlotController = FRadioSelectGroupController(value: '2'); @@ -74,7 +76,8 @@ void main() { status: ReservationMenuQueryStatus.recognizing, mealSlotController: mealSlotController, autoRecognitionEnabled: true, - continuousRecognitionEnabled: false, + continuousRecognitionEnabled: true, + quickVerificationEnabled: false, onSelect: (_) {}, onStart: () {}, ), @@ -95,7 +98,8 @@ void main() { status: ReservationMenuQueryStatus.recognizing, mealSlotController: mealSlotController, autoRecognitionEnabled: true, - continuousRecognitionEnabled: true, + continuousRecognitionEnabled: false, + quickVerificationEnabled: true, onSelect: (_) {}, onStart: () {}, ), @@ -134,6 +138,7 @@ void main() { mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: true, + quickVerificationEnabled: true, onSelect: (_) {}, onStart: () {}, ), @@ -174,6 +179,7 @@ void main() { mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: true, + quickVerificationEnabled: true, onSelect: (_) {}, onStart: () {}, ), @@ -198,6 +204,7 @@ void main() { mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: true, + quickVerificationEnabled: true, onSelect: (_) {}, onStart: () {}, ), diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index 2fdfe7a..de089da 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -11,6 +11,8 @@ void main() { expect(coordinator.state.scenario, CustomerDisplayScenario.review); expect(coordinator.state.products, hasLength(2)); expect(coordinator.state.products.last.quantityLabel, '0.36 kg'); + expect(coordinator.state.products.first.isCombo, isFalse); + expect(coordinator.state.products.first.comboItems, isEmpty); expect(coordinator.state.totalPrice, 4670); expect(coordinator.state.amountMealPrice, 300); expect(coordinator.state.payablePrice, 4970); @@ -19,6 +21,46 @@ void main() { coordinator.dispose(); }); + test('食堂套餐消息解析主商品类型和每套组成,且组成不改变结算金额', () { + final coordinator = CustomerDisplayCoordinator(); + + expect( + coordinator.handleAction('updateCard', _comboProductEvent()), isTrue); + expect(coordinator.state.products, hasLength(2)); + final combo = coordinator.state.products.first; + expect(combo.name, '中餐A'); + expect(combo.isCombo, isTrue); + expect(combo.comboItems, hasLength(3)); + expect(combo.comboItems.map((item) => item.name), [ + '蒜香黑虎虾', + '口蘑炒牛肉', + '清炒有机西兰花', + ]); + expect(combo.comboItems.last.quantity, 1); + expect(coordinator.state.products.last.isCombo, isFalse); + expect(coordinator.state.totalPrice, 6400); + expect(coordinator.state.payablePrice, 6400); + + coordinator.dispose(); + }); + + test('套餐组成份数异常时保留最近一次有效商品状态', () { + final coordinator = CustomerDisplayCoordinator(); + expect( + coordinator.handleAction('updateCard', _comboProductEvent()), isTrue); + final validState = coordinator.state; + final invalidEvent = _comboProductEvent(); + final shopList = invalidEvent['shopList']! as List>; + final comboItems = + shopList.first['comboItems']! as List>; + comboItems.first['quantity'] = 0; + + expect(coordinator.handleAction('updateCard', invalidEvent), isFalse); + expect(coordinator.state, validState); + + coordinator.dispose(); + }); + test('重复、空参数、错误参数和未知消息保留最近有效状态', () { final coordinator = CustomerDisplayCoordinator(); var notifications = 0; @@ -247,3 +289,37 @@ Map _productEvent() { ], }; } + +Map _comboProductEvent() { + return { + 'sysType': 'CANTEEN', + 'payUser': null, + 'totalPrice': 6400, + 'amountMealPrice': 0, + 'shopList': >[ + { + 'productName': '中餐A', + 'productPrice': 5200, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 5200, + 'isCombo': true, + 'comboItems': >[ + {'name': '蒜香黑虎虾', 'quantity': 1}, + {'name': '口蘑炒牛肉', 'quantity': 1}, + {'name': '清炒有机西兰花', 'quantity': 1}, + ], + }, + { + 'productName': '例汤', + 'productPrice': 1200, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 1200, + 'isCombo': false, + }, + ], + }; +} diff --git a/test/sub_screen/customer_display_widget_test.dart b/test/sub_screen/customer_display_widget_test.dart index 5c0dd92..f335ac5 100644 --- a/test/sub_screen/customer_display_widget_test.dart +++ b/test/sub_screen/customer_display_widget_test.dart @@ -82,6 +82,35 @@ void main() { await tester.pumpAndSettle(); expect(find.text('商品核对:已选中'), findsOneWidget); }); + + testWidgets('套餐副屏展开组成并区分套餐与单品', (tester) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _comboProductEvent()); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: CustomerDisplayPage(coordinator: coordinator), + ), + ); + await tester.pump(); + + expect(find.text('套餐'), findsOneWidget); + expect(find.text('单品'), findsOneWidget); + expect(find.text('中餐A'), findsOneWidget); + expect(find.text('蒜香黑虎虾'), findsOneWidget); + expect(find.text('口蘑炒牛肉'), findsOneWidget); + expect(find.text('清炒有机西兰花'), findsOneWidget); + expect(find.byKey(const ValueKey('combo-details-中餐A')), findsOneWidget); + expect(find.text('明细不单独计价'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } class _PreviewNavigationHarness extends StatelessWidget { @@ -174,3 +203,37 @@ class _NestedSettingStateState extends State<_NestedSettingState> { ); } } + +Map _comboProductEvent() { + return { + 'sysType': 'CANTEEN', + 'payUser': null, + 'totalPrice': 6400, + 'amountMealPrice': 0, + 'shopList': >[ + { + 'productName': '中餐A', + 'productPrice': 5200, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 5200, + 'isCombo': true, + 'comboItems': >[ + {'name': '蒜香黑虎虾', 'quantity': 1}, + {'name': '口蘑炒牛肉', 'quantity': 1}, + {'name': '清炒有机西兰花', 'quantity': 1}, + ], + }, + { + 'productName': '例汤', + 'productPrice': 1200, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 1200, + 'isCombo': false, + }, + ], + }; +} diff --git a/test/sub_screen/reservation_query_coordinator_test.dart b/test/sub_screen/reservation_query_coordinator_test.dart index 771a35f..d11a5c6 100644 --- a/test/sub_screen/reservation_query_coordinator_test.dart +++ b/test/sub_screen/reservation_query_coordinator_test.dart @@ -82,6 +82,36 @@ void main() { coordinator.dispose(); }); + testWidgets('持续人脸结果过期后保留组合页并回到识别下一位', (tester) async { + var now = DateTime(2026, 8, 7, 10); + final coordinator = ReservationQueryCoordinator(now: () => now); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleUpdate( + _reservationEvent( + sessionId: '220', + revision: 1, + expiresAt: now.add(const Duration(seconds: 10)), + continuousFaceRecognition: true, + ), + ), + isTrue, + ); + expect(coordinator.state.remainingSeconds, 10); + + now = now.add(const Duration(seconds: 10)); + await tester.pump(const Duration(seconds: 1)); + + expect( + coordinator.state.scenario, + ReservationQueryDisplayScenario.recognizing, + ); + expect(coordinator.state.continuousFaceRecognition, isTrue); + expect(coordinator.state.maskedName, isNull); + expect(coordinator.state.days, isEmpty); + }); + testWidgets('预约结果自行过期后顶层副屏返回欢迎页', (tester) async { var now = DateTime(2026, 7, 24, 8); final coordinator = SubDisplayCoordinator( @@ -264,6 +294,68 @@ void main() { coordinator.dispose(); }); + test('持续人脸动作始终保留预约组合页,不切换到独立人脸页面', () { + final now = DateTime(2026, 8, 7, 10); + final coordinator = SubDisplayCoordinator( + reservationCoordinator: ReservationQueryCoordinator(now: () => now), + ); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'reservationQueryUpdate', + const { + 'sessionId': 'continuous-query', + 'revision': 1, + 'scenario': 'recognizing', + 'continuousFaceRecognition': true, + }, + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + expect( + coordinator.handleAction( + 'faceRecognitionStart', + const { + 'sessionId': 'continuous-face', + 'revision': 1, + 'scene': 'reservation', + 'message': '正在启动摄像头', + }, + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + coordinator.handleAction( + 'faceRecognitionSuccess', + const { + 'sessionId': 'continuous-face', + 'revision': 2, + 'scene': 'reservation', + 'message': '识别成功,正在读取信息', + }, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + coordinator.handleAction( + 'reservationQueryUpdate', + _reservationEvent( + sessionId: 'continuous-query', + revision: 2, + expiresAt: now.add(const Duration(seconds: 10)), + continuousFaceRecognition: true, + ), + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + expect( + coordinator.reservationCoordinator.state.scenario, + ReservationQueryDisplayScenario.success, + ); + }); + testWidgets('预约查询先返回时仍保证人脸成功提示展示 3 秒', (tester) async { final now = DateTime(2026, 7, 24, 8); final coordinator = SubDisplayCoordinator( @@ -360,6 +452,7 @@ Map _reservationEvent({ required String sessionId, required int revision, required DateTime expiresAt, + bool continuousFaceRecognition = false, }) { return { 'sessionId': sessionId, @@ -373,6 +466,7 @@ Map _reservationEvent({ 'currentPage': 1, 'totalPages': 1, 'expiresAt': expiresAt.toIso8601String(), + 'continuousFaceRecognition': continuousFaceRecognition, 'days': [ _day('2026-07-24'), _day('2026-07-25'), diff --git a/test/sub_screen/reservation_query_widget_test.dart b/test/sub_screen/reservation_query_widget_test.dart index 08ec19f..4e1d0d3 100644 --- a/test/sub_screen/reservation_query_widget_test.dart +++ b/test/sub_screen/reservation_query_widget_test.dart @@ -184,7 +184,7 @@ void main() { }); for (final size in [const Size(1920, 1080), const Size(1280, 720)]) { - testWidgets('${size.width.toInt()}x${size.height.toInt()} 六种预约状态无布局异常', + testWidgets('${size.width.toInt()}x${size.height.toInt()} 七种预约状态无布局异常', (tester) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -227,4 +227,42 @@ void main() { } }); } + + testWidgets('持续人脸副屏同时保留实时预览、预约菜单和十秒倒计时', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final state = ReservationQueryDisplayState.demo( + ReservationQueryDisplayScenario.success, + now: DateTime(2026, 8, 7, 10), + ).copyWith( + remainingSeconds: 10, + continuousFaceRecognition: true, + ); + final coordinator = ReservationQueryCoordinator(initialState: state); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: FTheme( + data: FThemes.zinc.light, + child: ReservationQueryDisplayPage(coordinator: coordinator), + ), + ), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('sub-reservation-live-face-preview')), + findsOneWidget, + ); + expect(find.byType(ReservationScheduleCard), findsNWidgets(10)); + expect(find.text('预约结果展示中'), findsOneWidget); + expect(find.textContaining('10 秒后识别下一位'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + coordinator.dispose(); + }); } diff --git a/test/sub_screen/sub_display_coordinator_test.dart b/test/sub_screen/sub_display_coordinator_test.dart index 958dd5d..2efd4e8 100644 --- a/test/sub_screen/sub_display_coordinator_test.dart +++ b/test/sub_screen/sub_display_coordinator_test.dart @@ -104,6 +104,55 @@ void main() { await tester.pump(const Duration(seconds: 3)); expect(coordinator.mode, SubDisplayMode.settlement); }); + + test('预约查询人脸取消后立即退出副屏人脸页', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction('reservationQueryUpdate', const { + 'sessionId': 'query-cancel', + 'revision': 1, + 'scenario': 'idle', + }), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + expect( + coordinator.handleAction('faceRecognitionStart', const { + 'sessionId': 'reservation-face-cancel', + 'revision': 1, + 'scene': 'reservation', + 'message': '正在启动摄像头', + }), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.faceRecognition); + + expect( + coordinator.handleAction('faceRecognitionCancel', const { + 'sessionId': 'reservation-face-cancel', + 'revision': 2, + 'scene': 'reservation', + 'message': '已取消人脸识别', + }), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + expect( + coordinator.handleAction('faceRecognitionDetecting', const { + 'sessionId': 'reservation-face-cancel', + 'revision': 3, + 'scene': 'reservation', + 'message': '请正对摄像头', + }), + isFalse, + reason: '取消后迟到的检测消息不得重新打开副屏人脸页', + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + }); } Map _productEvent() {