bug修改

This commit is contained in:
dzc committed 2026-08-09 14:45:18 +08:00
1 parent adbd915b45
commit 2ee78a1561
38 files changed
+2069 -240

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
## flutter打包
```
flutter build apk --build-name 1.3.2 --build-number 132
flutter build apk --build-name 1.3.5 --build-number 135
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
@@ -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<Long, OutputTarget> 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();
@@ -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<Listener> 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<Integer, Long> lastFaceBoxSeenAt = new HashMap<>();
/** 当前向 Flutter 展示的人脸框,包含真实检测框和仍在漏检宽限内的单人缓存。 */
private List<Map<String, Object>> 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 的实时帧提交。
*
* <p>成功回调会冻结最后一帧供独立弹框确认;调用方结束 Session 后必须解冻,
* 持续查询页面才能在十秒菜单展示期继续显示实时画面,同时又不处理下一位人脸。</p>
*/
public Map<String, Object> 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<String, Object> getState() {
synchronized (sessionLock) {
return snapshot();
Map<String, Object> 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<FacePreviewInfo> faces;
List<Map<String, Object>> boxes;
synchronized (processorLock) {
if (!isCurrentDetectingProcessor(currentProcessor, frameGeneration)) return;
faces = currentProcessor.onPreviewFrame(data, true);
if (!isCurrentFrameProcessor(
expectedCamera,
expectedProcessor,
expectedGeneration,
doRecognize)) return;
List<FacePreviewInfo> 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<FacePreviewInfo> 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<Map<String, Object>> buildFaceBoxes(
List<FacePreviewInfo> faces,
Camera.Size currentPreviewSize,
FaceDiscernViewModel currentProcessor) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> visibleBoxes = stabilizeFaceBoxesLocked(detectedBoxes, now);
emitState(doRecognize ? "请正对摄像头" : "摄像头预览中", visibleBoxes, null);
}
}
/**
* 按虹软跟踪编号分别合并当前检测框与短时缓存,避免一个人漏检时冻结其他人的新坐标。
* 调用方必须持有 sessionLock。
*
* @param detectedBoxes 当前帧检测框,不包含任何相似度过滤
* @param now 当前单调业务时间戳,单位为毫秒
* @return 本帧应向主副屏展示的稳定框列表
*/
private List<Map<String, Object>> stabilizeFaceBoxesLocked(
List<Map<String, Object>> detectedBoxes,
long now) {
List<Map<String, Object>> visibleBoxes = new ArrayList<>(detectedBoxes);
Map<Integer, Map<String, Object>> detectedByTrackId = new HashMap<>();
Map<Integer, Long> nextSeenAt = new HashMap<>();
for (Map<String, Object> box : detectedBoxes) {
Integer trackId = faceTrackId(box);
if (trackId == null) continue;
detectedByTrackId.put(trackId, box);
nextSeenAt.put(trackId, now);
}
for (Map<String, Object> 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<String, Object> box) {
Object value = box.get("trackId");
return value instanceof Number ? ((Number) value).intValue() : null;
}
/**
* 返回仍在漏检宽限内的最近人脸框,超时则同步清理缓存。
* 调用方必须持有 sessionLock。
*
* @param now 当前时间戳,单位为毫秒
* @return 可安全用于初始化或资源状态事件的人脸框
*/
private List<Map<String, Object>> recentFaceBoxesLocked(long now) {
return stabilizeFaceBoxesLocked(new ArrayList<>(), now);
}
/**
* 返回适合新订阅者的当前框快照;成功态保留冻结结果,实时态只保留仍在漏检宽限内的框。
* 调用方必须持有 sessionLock。
*
* @param now 当前单调业务时间戳,单位为毫秒
* @return 与内部列表解耦、可直接放入通道 Map 的人脸框列表
*/
private List<Map<String, Object>> 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<String, Object> 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<String, Object> 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);
@@ -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<FacePreviewInfo> 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;
@@ -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,
@@ -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(
@@ -75,6 +75,8 @@ SubScreenReservationQueryEvent _$SubScreenReservationQueryEventFromJson(
(e) => ReservationMenuDayData.fromJson(e as Map<String, dynamic>))
.toList(),
expiresAt: json['expiresAt'] as String?,
continuousFaceRecognition:
json['continuousFaceRecognition'] as bool? ?? false,
);
Map<String, dynamic> _$SubScreenReservationQueryEventToJson(
@@ -92,4 +94,5 @@ Map<String, dynamic> _$SubScreenReservationQueryEventToJson(
'totalPages': instance.totalPages,
'days': instance.days.map((e) => e.toJson()).toList(),
'expiresAt': instance.expiresAt,
'continuousFaceRecognition': instance.continuousFaceRecognition,
};
@@ -222,13 +222,16 @@ class FaceRecognitionSessionService extends GetxService {
);
}
/// 取消当前业务人脸会话,并在释放原生资源前先让副屏退出人脸页。
Future<void> 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) {
+2 -2
View File
@@ -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';
+48 -10
View File
@@ -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<T?> showAppDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
@@ -28,7 +35,7 @@ Future<T?> showAppDialog<T>({
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<AppDialog> {
/// 相对初始对齐位置的拖动偏移。
Offset _dragOffset = Offset.zero;
/// 上一次构建使用的键盘遮挡高度,用于在输入法变化时清理失效拖动位置。
double _lastKeyboardInset = 0;
/// 当前拖动开始时指针的全局位置;未拖动时为空。
Offset? _dragStartGlobalPosition;
@@ -178,6 +188,13 @@ class _AppDialogState extends State<AppDialog> {
@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<AppDialog> {
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<T> extends State<_AppDialogRouteHost<T>>
/// 构建可命中遮罩、但不抢占弹框内部手势的安全区域布局。
@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<T>(
canPop: widget.canPop,
onPopInvokedWithResult: _handlePopInvoked,
@@ -555,9 +588,14 @@ class _AppDialogRouteHostState<T> extends State<_AppDialogRouteHost<T>>
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(
@@ -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: <Widget>[
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 <Widget>[
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 <Widget>[
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),
);
@@ -299,6 +299,7 @@ class _FaceRecognitionPanelState extends State<FaceRecognitionPanel> {
texture: _texture,
event: _event,
status: status,
scale: 1.w,
),
),
),
+1
View File
@@ -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';
@@ -77,6 +77,9 @@ mixin OrderMixin {
Future? putOffSendSubScreenDelayed;
/// 副屏购物车快照的递增版本;异步查询套餐组成时用于丢弃已经过期的快照。
int _subScreenSnapshotRevision = 0;
/// **************** start 更新属性 start ****************
set sysType(SysType type) {
@@ -576,25 +579,44 @@ mixin OrderMixin {
});
}
/// 通知副屏退出当前结算页,并使所有仍在构建的旧购物车快照失效。
Future<void> sendSubScreenBack() async {
// 先使正在查询套餐组成的旧快照失效,避免清屏后迟到的更新重新显示上一笔订单。
_subScreenSnapshotRevision++;
await SubScreenPlugin.sendMsgToViceScreen(
SubScreenAction.jumpSubBack.name,
);
}
/// 构建副屏使用的购物车商品列表;子类可在不改变下单协议的前提下补充展示字段。
Future<List<Map<String, dynamic>>> buildSubScreenShopList(
List<ShopCardEntity> cartItems,
) async {
return cartItems.map((item) => item.toJson()).toList(growable: false);
}
/// 冻结当前购物车展示数据并发送副屏;较新的调用会淘汰尚未完成的旧快照。
Future<void> 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,
@@ -260,6 +260,62 @@ class CanteenProductController extends GetxController with OrderMixin {
);
}
/// 为副屏购物车快照补充食堂套餐组成;这些字段只用于顾客展示,不改变下单商品列表。
@override
Future<List<Map<String, dynamic>>> buildSubScreenShopList(
List<ShopCardEntity> cartItems,
) async {
final shopList =
cartItems.map((item) => item.toJson()).toList(growable: true);
final productsByCode = <String, CanteenSpuEntity>{
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) => <String, dynamic>{
'name': item.name,
'quantity': item.quantity,
},
)
.toList(growable: false);
} catch (error, stack) {
// 明细异常不能影响收银;副屏至少保留套餐主项和套餐标识。
payload['comboItems'] = const <Map<String, dynamic>>[];
logger.w(
'|食堂购买页面|读取副屏套餐组成失败productCode:|$productCode|error:|$error|stack:|$stack|',
);
}
}
return List.unmodifiable(shopList);
}
// |下单|点击取消下单
Future<void> doCancelPay({bool cancelShop = true}) async {
if (cancelShop) await _clearShopCard();
+135 -33
View File
@@ -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<void> 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<ReservationQuickVerificationRequest> 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<ReservationMenuDayData> get currentDays =>
result.value?.pageDays(currentPage.value) ?? const [];
@@ -269,32 +289,65 @@ class ReservationMenuQueryController extends GetxController {
if (autoRecognitionEnabled.value) await startRecognition();
}
/// 启动当前识别方式并建立一次防重复回调的查询代次;失败后停留在可人工恢复状态。
Future<void> startRecognition() async {
/// 主动开始一轮识别;会清除上一位身份和菜单,失败后停留在可人工恢复状态。
Future<void> startRecognition() => _startRecognition();
/// 建立一次防重复回调的识别代次;持续饭卡结果期可保留当前菜单并只重新布防读卡器。
Future<void> _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<void> 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<void> _restartRecognitionAfterTimeout(int generation) async {
/// 释放当前识别会话并延迟续轮;代次变化时旧任务立即失效。
///
/// [preserveCurrentResult] 仅用于持续饭卡结果期,保留当前菜单等待不同卡号。
Future<void> _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<void> _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<void> 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);
@@ -43,6 +43,7 @@ abstract interface class ReservationRecognitionGateway {
Future<void> startFace({
required Future<void> Function(String userId) onRecognized,
required void Function(ReservationRecognitionFailure failure) onFailure,
bool embeddedPreview = false,
});
/// 停止饭卡识别并释放当前串口回调。
@@ -149,6 +150,7 @@ class TerminalReservationRecognitionGateway
Future<void> startFace({
required Future<void> 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;
+97 -1
View File
@@ -49,7 +49,10 @@ class ReservationMenuQueryPage extends GetView<ReservationMenuQueryController> {
Widget build(BuildContext context) {
// 左右工作区各自监听实际使用的 Rx,避免核销倒计时或页码变化重建整张页面。
final workspace = <Widget>[
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<ReservationMenuQueryController> {
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<ReservationMenuQueryController> {
);
}
/// 构建持续人脸模式的页面级常驻摄像头;组件只持有 Texture,不接管识别会话。
Widget _continuousFacePreview() {
final presentation = _continuousFacePresentation();
final remaining = controller.remainingSeconds.value;
return <Widget>[
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),
<Widget>[
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 <Widget>[
@@ -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<ReservationRecognitionMethod> onSelect;
@@ -59,8 +63,8 @@ class QueryMethodPanel extends StatelessWidget {
onTap: () => onSelect(method),
),
),
// 预选餐段服务于连续接待流程;关闭持续自动识别时隐藏入口,但保留最近餐段供再次开启时恢复。
if (continuousRecognitionEnabled)
// 预选餐段只决定快速核销目标;关闭快速核销后隐藏无效入口,但保留最近选择供再次开启时恢复。
if (quickVerificationEnabled)
appRadioHourTypeDialog(
context,
mealSlotController,
@@ -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,
@@ -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<CustomerDisplayComboItem> 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
@@ -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<ReservationMenuDayData> 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,
);
}
@@ -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 = <CustomerDisplayComboItem>[];
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,
);
}
@@ -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,
@@ -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;
@@ -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;
+199 -23
View File
@@ -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<int>(
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,
),
],
),
);
@@ -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 = <Widget>[
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),
<Widget>[
Icon(
presentation.icon,
color: presentation.color,
size: 34 * scale,
),
<Widget>[
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(),
<Widget>[
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 <Widget>[
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,
+1 -1
View File
@@ -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
+129 -5
View File
@@ -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<void>(
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>(EdgeInsets.zero);
addTearDown(viewInsets.dispose);
await tester.pumpWidget(
_dialogTestApp(
viewInsetsListenable: viewInsets,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAppDialog<void>(
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<EdgeInsets>? 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<EdgeInsets>(
valueListenable: viewInsets,
builder: (_, insets, child) => MediaQuery(
data: mediaData.copyWith(viewInsets: insets),
child: child!,
),
child: themedChild,
);
},
home: Scaffold(body: Center(child: child)),
),
);
@@ -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。
@@ -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<ReservationMenuQueryController>()) {
await Get.delete<ReservationMenuQueryController>(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<ReservationMenuQueryController>(force: true);
Get.reset();
});
testWidgets('自动人脸被取消后暂停且不循环弹窗,仍可人工重新识别', (tester) async {
final recognition = _FakeRecognitionGateway();
final controller = ReservationMenuQueryController(
@@ -1105,6 +1237,9 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway {
final cardFailures = <void Function(ReservationRecognitionFailure failure)>[];
final faceCallbacks = <Future<void> Function(String)>[];
final faceFailures = <void Function(ReservationRecognitionFailure failure)>[];
/// 每次人脸启动是否使用页面内嵌预览,用于校验持续模式不会再弹独立识别框。
final faceEmbeddedPreviews = <bool>[];
int stopAllCalls = 0;
Future<void> emitCardAt(int index, String value) {
@@ -1128,9 +1263,11 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway {
Future<void> startFace({
required Future<void> Function(String userId) onRecognized,
required void Function(ReservationRecognitionFailure failure) onFailure,
bool embeddedPreview = false,
}) async {
faceCallbacks.add(onRecognized);
faceFailures.add(onFailure);
faceEmbeddedPreviews.add(embeddedPreview);
}
@override
@@ -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<String>(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: () {},
),
@@ -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<Map<String, dynamic>>;
final comboItems =
shopList.first['comboItems']! as List<Map<String, dynamic>>;
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<String, dynamic> _productEvent() {
],
};
}
Map<String, dynamic> _comboProductEvent() {
return {
'sysType': 'CANTEEN',
'payUser': null,
'totalPrice': 6400,
'amountMealPrice': 0,
'shopList': <Map<String, dynamic>>[
{
'productName': '中餐A',
'productPrice': 5200,
'nature': 'DEFAULT',
'productNumber': 1,
'productWidget': 0,
'totalPrice': 5200,
'isCombo': true,
'comboItems': <Map<String, dynamic>>[
{'name': '蒜香黑虎虾', 'quantity': 1},
{'name': '口蘑炒牛肉', 'quantity': 1},
{'name': '清炒有机西兰花', 'quantity': 1},
],
},
{
'productName': '例汤',
'productPrice': 1200,
'nature': 'DEFAULT',
'productNumber': 1,
'productWidget': 0,
'totalPrice': 1200,
'isCombo': false,
},
],
};
}
@@ -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<String, dynamic> _comboProductEvent() {
return {
'sysType': 'CANTEEN',
'payUser': null,
'totalPrice': 6400,
'amountMealPrice': 0,
'shopList': <Map<String, dynamic>>[
{
'productName': '中餐A',
'productPrice': 5200,
'nature': 'DEFAULT',
'productNumber': 1,
'productWidget': 0,
'totalPrice': 5200,
'isCombo': true,
'comboItems': <Map<String, dynamic>>[
{'name': '蒜香黑虎虾', 'quantity': 1},
{'name': '口蘑炒牛肉', 'quantity': 1},
{'name': '清炒有机西兰花', 'quantity': 1},
],
},
{
'productName': '例汤',
'productPrice': 1200,
'nature': 'DEFAULT',
'productNumber': 1,
'productWidget': 0,
'totalPrice': 1200,
'isCombo': false,
},
],
};
}
@@ -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<String, dynamic> _reservationEvent({
required String sessionId,
required int revision,
required DateTime expiresAt,
bool continuousFaceRecognition = false,
}) {
return {
'sessionId': sessionId,
@@ -373,6 +466,7 @@ Map<String, dynamic> _reservationEvent({
'currentPage': 1,
'totalPages': 1,
'expiresAt': expiresAt.toIso8601String(),
'continuousFaceRecognition': continuousFaceRecognition,
'days': [
_day('2026-07-24'),
_day('2026-07-25'),
@@ -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();
});
}
@@ -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<String, dynamic> _productEvent() {