feat:摄像头选择,副屏页面修改

This commit is contained in:
dzc committed 2026-09-10 15:25:41 +08:00
1 parent 2e929c199b
commit a81a79b69f
9 files changed
+613 -10

No files matched your search

@@ -250,6 +250,32 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
case "hasCamera":
result.success(sessionManager.hasCamera());
break;
case "getCameraChoices":
// 探测会短暂打开每个摄像头,必须串行进入人脸执行器且不占用主线程。
FACE_EXECUTOR.execute(() -> {
try {
Map<String, Object> choices = FlutterTool.getCameraChoices(context);
MAIN_HANDLER.post(() -> result.success(choices));
} catch (Exception e) {
Log.e(TAG, "读取摄像头列表失败:" + e.getMessage(), e);
MAIN_HANDLER.post(() -> result.error("CAMERA_CHOICES_FAILED", "读取摄像头列表失败", null));
}
});
break;
case "setCameraSelection":
Number selectedCameraId = call.argument("cameraId");
if (selectedCameraId == null) {
result.error("INVALID_CAMERA_SELECTION", "缺少摄像头编号", null);
break;
}
FlutterTool.setCameraSelection(context, selectedCameraId.intValue());
// 空闲时立即释放热相机让下次识别按新编号打开;识别中延迟到会话结束。
sessionManager.onCameraSelectionChanged();
result.success(true);
break;
case "getCameraSelection":
result.success(FlutterTool.getCameraSelection(context));
break;
case "cleanupOrderEvidenceFiles":
List<?> rawReferencedPaths = call.argument("referencedPaths");
Number olderThan = call.argument("olderThan");
@@ -354,7 +380,10 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
String activeKey = stringArgument(call, "activeKey");
String sdkKey = stringArgument(call, "sdkKey");
if (FlutterTool.isRegistrationInProgress()) {
result.success(FlutterTool.getFaceStatusWhileRegistering(context, appId, activeKey, sdkKey));
Log.i(TAG, "人脸状态查询遇到互斥占用owner:" + FlutterTool.getRegistrationOwner() + ";");
Map<String, Object> busyStatus = FlutterTool.getFaceStatusWhileRegistering(context, appId, activeKey, sdkKey);
busyStatus.put("registrationOwner", FlutterTool.getRegistrationOwner());
result.success(busyStatus);
return;
}
FACE_EXECUTOR.execute(() -> {
@@ -369,7 +398,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
}
private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) {
if (!FlutterTool.tryStartRegistration()) {
if (!FlutterTool.tryStartRegistration("rebuildFaceLibrary")) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null);
return;
}
@@ -399,7 +428,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
* @param result Flutter MethodChannel 异步结果
*/
private void startFaceFeatureExtraction(String filePath, MethodChannel.Result result) {
if (!FlutterTool.tryStartRegistration()) {
if (!FlutterTool.tryStartRegistration("extractFaceFeature")) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
@@ -427,7 +456,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null);
return;
}
if (!FlutterTool.tryStartRegistration()) {
if (!FlutterTool.tryStartRegistration("auditFaceQuality")) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
@@ -452,7 +481,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
* @param result Flutter MethodChannel 异步结果
*/
private void startFaceDelta(MethodCall call, MethodChannel.Result result) {
if (!FlutterTool.tryStartRegistration()) {
if (!FlutterTool.tryStartRegistration("applyFaceDelta")) {
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
return;
}
@@ -34,6 +34,8 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import io.flutter.plugins.facediscern.flutter.config.Constants;
import io.flutter.plugins.facediscern.tool.camera.CameraColorProbe;
import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer;
import io.flutter.plugins.facediscern.tool.file.FileUtil;
@@ -150,6 +152,50 @@ public class FlutterTool {
return REGISTRATION_IN_PROGRESS.get();
}
// 枚举本机摄像头并逐一探测彩色/黑白输出,供设置页人工选择识别摄像头。
// 只能在 FACE_EXECUTOR 上调用:探测会短暂打开摄像头,不能与人脸会话或主线程并发。
static Map<String, Object> getCameraChoices(Context context) {
Map<String, Object> result = new HashMap<>();
List<Map<String, Object>> choices = new ArrayList<>();
int selectedId = CameraSelectionStore.loadRgbCameraId(context);
result.put("selectedCameraId", selectedId);
try {
int cameraCount = android.hardware.Camera.getNumberOfCameras();
android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo();
for (int id = 0; id < cameraCount; id++) {
android.hardware.Camera.getCameraInfo(id, info);
Map<String, Object> choice = new HashMap<>();
choice.put("cameraId", id);
choice.put("facing", info.facing == android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT
? "front" : "back");
choice.put("colorKind", CameraColorProbe.probeColorKind(id));
choices.add(choice);
}
} catch (Exception e) {
Log.e(TAG, "枚举摄像头失败error:" + e.getMessage(), e);
}
result.put("choices", choices);
return result;
}
// 保存人工选择的识别摄像头;负数归一化为自动。持久化由调用方通知会话管理器失效热相机。
static void setCameraSelection(Context context, int cameraId) {
CameraSelectionStore.saveRgbCameraId(context, cameraId);
Log.i(TAG, "人工选择识别摄像头cameraId:" + cameraId + ";");
}
// 只读当前人工选择,不探测摄像头;供设置页轻量回显。
static int getCameraSelection(Context context) {
return CameraSelectionStore.loadRgbCameraId(context);
}
// 最近一次占用互斥锁的操作名,仅用于诊断日志;锁空闲时可能残留上一次的值。
private static volatile String REGISTRATION_OWNER = "";
static String getRegistrationOwner() {
return REGISTRATION_OWNER;
}
// 注册/特征提取/质量审计占线期间的状态查询:只读授权文件与最新进度缓存,
// 不触碰 FaceServer 锁也不查数据库,供主线程即时返回,避免在单线程执行器里
// 排队等待整库任务完成后才能应答。
@@ -182,8 +228,10 @@ public class FlutterTool {
return status;
}
static boolean tryStartRegistration() {
return REGISTRATION_IN_PROGRESS.compareAndSet(false, true);
static boolean tryStartRegistration(String owner) {
boolean acquired = REGISTRATION_IN_PROGRESS.compareAndSet(false, true);
if (acquired) REGISTRATION_OWNER = owner == null ? "" : owner;
return acquired;
}
static void finishRegistration() {
@@ -35,6 +35,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo;
import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus;
import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus;
import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore;
import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig;
import io.flutter.plugins.facediscern.ui.viewmodel.FaceDiscernViewModel;
import io.flutter.view.TextureRegistry;
@@ -375,6 +376,17 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
if (releaseNow) releaseResources(true, true);
}
/**
* 人工切换识别摄像头后让下次识别按新编号开相机;活动识别延迟到当前顾客结束。
*/
public void onCameraSelectionChanged() {
boolean releaseNow;
synchronized (sessionLock) {
releaseNow = activeSessionId == null;
}
if (releaseNow) releaseResources(true, true);
}
public void releaseForLowMemory() {
synchronized (sessionLock) {
if (activeSessionId != null) {
@@ -1064,8 +1076,15 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
@SuppressWarnings("deprecation")
private int findRgbCameraId() {
int fallback = -1;
int cameraCount = Camera.getNumberOfCameras();
// 双目终端存在厂家把彩色/红外排线装反的情况,facing 枚举角色不可信;
// 设置页人工指定的摄像头编号永远优先。
int manualId = CameraSelectionStore.loadRgbCameraId(context);
if (manualId >= 0 && manualId < cameraCount) {
Log.i(TAG, "使用人工选择的识别摄像头 cameraId=" + manualId + ";cameraCount=" + cameraCount);
return manualId;
}
int fallback = -1;
Camera.CameraInfo info = new Camera.CameraInfo();
for (int i = 0; i < cameraCount; i++) {
Camera.getCameraInfo(i, info);
@@ -0,0 +1,138 @@
package io.flutter.plugins.facediscern.tool.camera;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.Camera;
import android.util.Log;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 通过预览帧色度判断单目摄像头输出彩色还是黑白(红外)画面。
*
* 元数据(facing/朝向)无法识别厂家排线装反的双目设备,唯一可靠的区分信号在
* 像素层:红外/黑白传感器的 NV21 色度平面(U/V 交错字节)恒为中性值 128,
* 彩色传感器对任何真实场景都会出现明显色度偏移。探测结果只作为设置页的
* 人工选择参考标签,不作为自动切换依据。
*/
public final class CameraColorProbe {
private static final String TAG = "CameraHelper";
/** 判定为彩色的每字节平均色度偏移阈值;真实场景彩色画面通常远高于该值。 */
private static final double COLOR_CHROMA_THRESHOLD = 2.0;
/** 单摄像头最多采集的帧数,取各帧中的最大色度偏移作为判定值。 */
private static final int MAX_FRAMES = 6;
/** 整个探测(含开相机)的硬超时,超时按未知处理,不阻塞调用方。 */
private static final long PROBE_TIMEOUT_MS = 2500;
private CameraColorProbe() {
}
public static final String KIND_COLOR = "color";
public static final String KIND_MONO = "mono";
public static final String KIND_UNKNOWN = "unknown";
/**
* 打开指定摄像头采集若干 NV21 帧并统计色度偏移。
*
* @return color=彩色;mono=黑白/红外;unknown=相机被占用或超时无法判定
*/
@SuppressWarnings("deprecation")
public static String probeColorKind(int cameraId) {
Camera camera = null;
try {
camera = Camera.open(cameraId);
Camera.Parameters parameters = camera.getParameters();
parameters.setPreviewFormat(ImageFormat.NV21);
Camera.Size size = chooseSmallestSize(parameters.getSupportedPreviewSizes());
if (size != null) {
parameters.setPreviewSize(size.width, size.height);
}
camera.setParameters(parameters);
// 无渲染目标时使用离屏纹理承载预览,仅消费回调帧。
camera.setPreviewTexture(new SurfaceTexture(0));
final CountDownLatch latch = new CountDownLatch(MAX_FRAMES);
final AtomicReference<Double> maxMetric = new AtomicReference<>(0.0);
final AtomicInteger receivedFrames = new AtomicInteger(0);
camera.setPreviewCallback((nv21, callbackCamera) -> {
if (nv21 == null || nv21.length == 0) {
latch.countDown();
return;
}
double metric = chromaDeviation(nv21);
double previous = maxMetric.get();
if (metric > previous) maxMetric.set(metric);
receivedFrames.incrementAndGet();
latch.countDown();
});
camera.startPreview();
boolean completed = latch.await(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
double metric = maxMetric.get();
int frames = receivedFrames.get();
String kind = frames == 0
? KIND_UNKNOWN
: (metric >= COLOR_CHROMA_THRESHOLD ? KIND_COLOR : KIND_MONO);
Log.i(TAG, "摄像头色度探测cameraId=" + cameraId + ";kind=" + kind
+ ";chroma=" + String.format("%.2f", metric)
+ ";frames=" + frames + ";timeout=" + !completed);
return kind;
} catch (Throwable error) {
Log.w(TAG, "摄像头色度探测失败cameraId=" + cameraId + ";error=" + error.getMessage());
return KIND_UNKNOWN;
} finally {
if (camera != null) {
try {
camera.setPreviewCallback(null);
camera.stopPreview();
} catch (Exception ignore) {
}
try {
camera.release();
} catch (Exception ignore) {
}
}
}
}
/** NV21 色度平面采样:每个采样字节相对中性值 128 的平均偏移,单色输出恒接近 0。 */
static double chromaDeviation(byte[] nv21) {
int lumaEnd = estimateLumaEnd(nv21.length);
int chromaLength = nv21.length - lumaEnd;
if (chromaLength <= 0) return 0;
int step = Math.max(1, chromaLength / 4096);
long total = 0;
int samples = 0;
for (int index = lumaEnd; index < nv21.length; index += step) {
total += Math.abs((nv21[index] & 0xFF) - 128);
samples++;
}
return samples == 0 ? 0 : total / (double) samples;
}
/**
* 常见 Camera1 NV21 布局为 width*height 的 Y 平面后跟 VU 交错的色度平面,
* 总长为亮度区的 3/2;无法拿到精确分辨率时按该比例估算亮度区结束位置。
*/
static int estimateLumaEnd(int bufferLength) {
return bufferLength * 2 / 3;
}
@SuppressWarnings("deprecation")
private static Camera.Size chooseSmallestSize(List<Camera.Size> sizes) {
if (sizes == null || sizes.isEmpty()) return null;
Camera.Size smallest = sizes.get(0);
for (Camera.Size size : sizes) {
if (size.width * size.height < smallest.width * smallest.height) {
smallest = size;
}
}
return smallest;
}
}
@@ -0,0 +1,41 @@
package io.flutter.plugins.facediscern.tool.camera.config;
import android.content.Context;
import android.content.SharedPreferences;
/**
* 保存终端本机的彩色识别摄像头人工选择。
*
* 双目人脸设备的彩色/红外摄像头只靠 facing 枚举角色区分,厂家排线装反时
* 枚举为 BACK 的摄像头实际输出红外黑白画面,元数据层面无法判定,因此提供
* 人工指定入口。-1 表示自动(维持按 facing 枚举选择的既有行为)。
*/
public final class CameraSelectionStore {
private static final String PREFERENCES_NAME = "face_camera_selection";
private static final String KEY_RGB_CAMERA_ID = "rgb_camera_id";
public static final int AUTO_CAMERA_ID = -1;
private CameraSelectionStore() {
}
/** 返回人工选择的彩色摄像头编号;-1 表示未指定,按设备枚举自动选择。 */
public static int loadRgbCameraId(Context context) {
try {
return preferences(context).getInt(KEY_RGB_CAMERA_ID, AUTO_CAMERA_ID);
} catch (Exception error) {
return AUTO_CAMERA_ID;
}
}
/** 写入人工选择;负数一律归一化为自动。 */
public static void saveRgbCameraId(Context context, int cameraId) {
preferences(context)
.edit()
.putInt(KEY_RGB_CAMERA_ID, cameraId < 0 ? AUTO_CAMERA_ID : cameraId)
.apply();
}
private static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
}
}
@@ -766,3 +766,66 @@ String? _asNullableString(dynamic value) {
final result = value?.toString();
return result == null || result.isEmpty ? null : result;
}
/// 单个终端摄像头的枚举信息;[colorKind] 来自预览帧色度探测,仅作人工选择参考。
class TerminalCameraChoice {
const TerminalCameraChoice({
required this.cameraId,
required this.facing,
required this.colorKind,
});
/// Android Camera1 摄像头编号。
final int cameraId;
/// facing 枚举角色:front 或 back;双目设备该值不可靠。
final String facing;
/// color=彩色;mono=黑白/红外;unknown=相机被占用或探测超时。
final String colorKind;
/// 供设置页展示的探测标签。
String get colorKindLabel => switch (colorKind) {
'color' => '彩色',
'mono' => '黑白(红外)',
_ => '探测不可用',
};
/// 供设置页展示的朝向标签。
String get facingLabel => facing == 'front' ? '前置角色' : '后置角色';
factory TerminalCameraChoice.fromMap(Map<dynamic, dynamic> map) {
return TerminalCameraChoice(
cameraId: _asInt(map['cameraId']),
facing: _asString(map['facing'], 'back'),
colorKind: _asString(map['colorKind'], 'unknown'),
);
}
}
/// 设置页读取的摄像头选择快照:当前人工选择(-1 为自动)加全部可选摄像头。
class TerminalCameraSelection {
const TerminalCameraSelection({
required this.selectedCameraId,
required this.choices,
});
/// 人工选择的识别摄像头编号;-1 表示按设备枚举自动选择。
final int selectedCameraId;
/// 全部可枚举摄像头及其色度探测结果。
final List<TerminalCameraChoice> choices;
factory TerminalCameraSelection.fromMap(Map<dynamic, dynamic> map) {
final rawChoices = map['choices'];
return TerminalCameraSelection(
selectedCameraId: _asIntOr(map['selectedCameraId'], -1),
choices: rawChoices is List
? rawChoices
.whereType<Map>()
.map(TerminalCameraChoice.fromMap)
.toList(growable: false)
: const [],
);
}
}
@@ -142,6 +142,25 @@ abstract class FaceDiscernPlugs {
return _invokeRecognizeConfiguration('resetRecognizeConfiguration');
}
/// 枚举终端摄像头并逐一做预览帧色度探测;探测需短暂打开摄像头,耗时数秒。
static Future<TerminalCameraSelection> getCameraChoices() async {
final map = await _channel.invokeMethod<dynamic>('getCameraChoices');
if (map is! Map) throw StateError('原生未返回有效的摄像头列表');
return TerminalCameraSelection.fromMap(map);
}
/// 保存人工选择的识别摄像头;-1 恢复自动选择。空闲时立即生效,识别中延迟到会话结束。
static Future<void> setCameraSelection(int cameraId) async {
await _channel.invokeMethod<void>('setCameraSelection', {
'cameraId': cameraId,
});
}
/// 只读当前人工选择的识别摄像头编号;-1 表示自动,不触发摄像头探测。
static Future<int> getCameraSelection() async {
return await _channel.invokeMethod<int>('getCameraSelection') ?? -1;
}
static Stream<FaceRecognitionSessionEvent> get sessionStream {
_sessionStream ??= _sessionEventChannel
.receiveBroadcastStream()
@@ -665,10 +665,22 @@ class SelectPayTypeDialogController extends GetxController {
String msg = "人脸数据库为空,请前往'设置'->'人脸管理'点击同步人脸";
throw Exception(msg);
}
final status = await AppFaceTool.getStatus();
// 注册占用通常是暂态的:整库重建或识别失效触发的增量同步都会短暂持有
// 人脸互斥锁。直接拒绝会让收银员在连续两单之间撞上“正在注册”,
// 这里等待其结束后自动继续,只有超时才提示稍后再试。
var status = await AppFaceTool.getStatus();
if (!_ownsFaceDispatch(requestId)) return;
var waitRound = 0;
while (status?.registrationInProgress == true && waitRound < 8) {
faceWorkspaceMessage.value = waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)';
await Future<void>.delayed(const Duration(seconds: 2));
if (!_ownsFaceDispatch(requestId)) return;
status = await AppFaceTool.getStatus();
if (!_ownsFaceDispatch(requestId)) return;
waitRound++;
}
if (status?.registrationInProgress == true) {
String msg = "人脸库正在注册,请稍候再发起人脸支付";
String msg = "人脸库正在更新,请稍候再发起人脸支付";
throw Exception(msg);
}
if (status?.activated != true) {
@@ -201,6 +201,7 @@ Widget settingBaseInfo(BuildContext context) {
? 'Android 虚拟机'
: 'Android 真实设备',
),
const _CameraSelectionRow(),
SettingInfoRow(
icon: FAssets.icons.smartphone,
label: 'Android 版本',
@@ -533,3 +534,236 @@ String _maskIdentifier(String? value) {
final suffixLength = text.length >= 16 ? 4 : 3;
return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}';
}
/// 识别摄像头人工选择行:双目终端排线装反时元数据无法判定彩色/红外,
/// 行内只轻量回显当前选择,帧色度探测在选择弹窗打开时才执行。
class _CameraSelectionRow extends StatefulWidget {
const _CameraSelectionRow();
@override
State<_CameraSelectionRow> createState() => _CameraSelectionRowState();
}
class _CameraSelectionRowState extends State<_CameraSelectionRow> {
/// 当前人工选择;null 表示尚未读取完成。
int? _selectedCameraId;
@override
void initState() {
super.initState();
_loadSelection();
}
Future<void> _loadSelection() async {
try {
final id = await FaceDiscernPlugs.getCameraSelection();
if (mounted) setState(() => _selectedCameraId = id);
} catch (error, stack) {
logger.w('|基本设置|读取摄像头选择失败error:|$error|stack:|$stack|');
}
}
String get _valueLabel {
final id = _selectedCameraId;
if (id == null) return '读取中';
return id < 0 ? '自动' : '摄像头 $id';
}
Future<void> _openSelectionDialog() async {
await showAppDialog<void>(
context: context,
builder: (dialogContext) => _CameraSelectionDialog(
onChanged: _loadSelection,
),
);
}
@override
Widget build(BuildContext context) {
return SettingInfoRow(
icon: FAssets.icons.webcam,
label: '识别摄像头',
subtitle: '预览为黑白画面时请改选另一个摄像头',
value: _valueLabel,
actionLabel: '选择',
onTap: _openSelectionDialog,
);
}
}
/// 识别摄像头选择弹窗:打开时先对每个摄像头做预览帧色度探测(数秒),
/// 用"彩色/黑白(红外)"标签辅助人工选择;探测不可用时仍可手动改选。
class _CameraSelectionDialog extends StatefulWidget {
const _CameraSelectionDialog({required this.onChanged});
/// 选择保存成功后通知外层行刷新回显。
final VoidCallback onChanged;
@override
State<_CameraSelectionDialog> createState() => _CameraSelectionDialogState();
}
class _CameraSelectionDialogState extends State<_CameraSelectionDialog> {
TerminalCameraSelection? _selection;
String? _error;
@override
void initState() {
super.initState();
_loadChoices();
}
Future<void> _loadChoices() async {
try {
final selection = await FaceDiscernPlugs.getCameraChoices();
if (!mounted) return;
setState(() {
_selection = selection;
_error = null;
});
} catch (error, stack) {
logger.e('|基本设置|探测摄像头失败error:|$error|stack:|$stack|');
if (!mounted) return;
setState(() => _error = '摄像头探测失败,请稍后重试');
}
}
Future<void> _select(int cameraId) async {
try {
await FaceDiscernPlugs.setCameraSelection(cameraId);
widget.onChanged();
if (!mounted) return;
Navigator.of(context).pop();
Toast.success(msg: cameraId < 0 ? '已恢复自动选择摄像头' : '已选择摄像头 $cameraId');
} catch (error, stack) {
logger.e('|基本设置|保存摄像头选择失败error:|$error|stack:|$stack|');
Toast.error(msg: '保存失败,请稍后重试');
}
}
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final selection = _selection;
return AppDialog(
title: const Text('识别摄像头'),
width: 560.w,
content: selection == null
? ConstrainedBox(
constraints: BoxConstraints(minHeight: 180.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
SizedBox(height: 14.h),
Text(
_error ?? '正在逐个探测摄像头,请稍候…',
style: theme.typography.base.copyWith(
color: _error == null ? XfjDesignTokens.muted : XfjDesignTokens.danger,
),
),
if (_error != null) ...[
SizedBox(height: 14.h),
FButton(
style: FButtonStyle.outline,
label: const Text('重试'),
onPress: () {
setState(() => _error = null);
_loadChoices();
},
),
],
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'双目终端可能存在彩色/红外排线装反,以探测标签为准选择彩色摄像头;'
'预览出现黑白画面时改选另一个即可。',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
height: 1.5,
),
),
SizedBox(height: 14.h),
_buildOption(
theme: theme,
title: '自动选择',
subtitle: '按设备枚举角色选择(默认)',
selected: selection.selectedCameraId < 0,
onTap: () => _select(-1),
),
for (final choice in selection.choices) ...[
SizedBox(height: 8.h),
_buildOption(
theme: theme,
title: '摄像头 ${choice.cameraId}',
subtitle: '${choice.facingLabel} · ${choice.colorKindLabel}',
selected: selection.selectedCameraId == choice.cameraId,
onTap: () => _select(choice.cameraId),
),
],
SizedBox(height: 14.h),
FButton(
style: FButtonStyle.outline,
label: const Text('取消'),
onPress: () => Navigator.of(context).pop(),
),
],
),
);
}
Widget _buildOption({
required FThemeData theme,
required String title,
required String subtitle,
required bool selected,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Container(
constraints: BoxConstraints(minHeight: 64.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: BoxDecoration(
color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface,
border: Border.all(
color: selected
? XfjDesignTokens.primary.withValues(alpha: 0.4)
: XfjDesignTokens.line,
),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
<Widget>[
Text(
title,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 2.h),
Text(
subtitle,
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
),
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(),
if (selected)
FIcon(
FAssets.icons.check,
size: 22.w,
color: XfjDesignTokens.primary,
),
],
),
),
);
}
}