bug修改
This commit is contained in:
1 parent
adbd915b45
commit
2ee78a1561
38 files changed
+2069
-240
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
+298
-68
@@ -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);
|
||||
|
||||
+6
-4
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user