704 lines
26 KiB
Dart
704 lines
26 KiB
Dart
import 'dart:async';
|
||
import 'dart:ui' as ui show PlatformDispatcher;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||
|
||
import '../detection/detector_worker.dart';
|
||
import '../models/model_manager.dart';
|
||
import '../reminder/reminder.dart';
|
||
import 'app_camera_controller.dart';
|
||
import 'camera_view_model.dart';
|
||
import 'detection_overlay.dart';
|
||
import 'frame_analyzer.dart';
|
||
import 'model_catalog_section.dart';
|
||
|
||
/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头)
|
||
class CameraScreen extends StatefulWidget {
|
||
const CameraScreen({super.key});
|
||
|
||
@override
|
||
State<CameraScreen> createState() => _CameraScreenState();
|
||
}
|
||
|
||
class _CameraScreenState extends State<CameraScreen> {
|
||
CameraViewModel? _viewModel;
|
||
FrameAnalyzer? _analyzer;
|
||
AppCameraController? _cameraController;
|
||
bool _initFailed = false;
|
||
bool _permissionGranted = false;
|
||
String? _globalError;
|
||
String? _initError;
|
||
|
||
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
|
||
double _minScore = 0.10;
|
||
|
||
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
|
||
Map<dynamic, dynamic> _nativeStats = const {};
|
||
Timer? _statsTimer;
|
||
|
||
/// 当前 worker 已加载的模型 id 集合(激活集变化对比用)
|
||
Set<int> _loadedModelIds = const {};
|
||
|
||
/// 进行中的 worker 重建(并发调用共享,避免重复建/漏建)
|
||
Future<void>? _reloadInFlight;
|
||
|
||
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
|
||
int _lastRevision = -1;
|
||
|
||
/// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数)
|
||
bool _devInfo = false;
|
||
final List<DateTime> _devTaps = [];
|
||
|
||
/// 推理 worker 后台重建中(启用/停用模型触发):期间 modelReady 仍为 false,
|
||
/// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」
|
||
bool _workerPending = false;
|
||
|
||
void _onDevTaps() {
|
||
final now = DateTime.now();
|
||
_devTaps.add(now);
|
||
_devTaps
|
||
.removeWhere((t) => now.difference(t) > const Duration(seconds: 3));
|
||
if (_devTaps.length < 5) return;
|
||
_devTaps.clear();
|
||
setState(() => _devInfo = !_devInfo);
|
||
}
|
||
|
||
/// 识别延迟分档(C 端直观读法:流畅 / 一般 / 偏慢)
|
||
({String label, Color color}) _latencyBucket(double avgMs) {
|
||
if (avgMs <= 200) return (label: '流畅', color: Colors.greenAccent);
|
||
if (avgMs <= 600) return (label: '一般', color: Colors.amberAccent);
|
||
return (label: '偏慢', color: Colors.redAccent);
|
||
}
|
||
|
||
/// C 端底部状态胶囊内容:设备识别延迟(近帧滚动平均)+ 当前识别模型
|
||
List<Widget> _statusCapsuleLines(CameraUiState s) {
|
||
if (!s.modelReady) {
|
||
if (_workerPending) {
|
||
// worker 后台构建中(用户刚启用模型):给个进行时状态,别误读为未启用
|
||
return const [
|
||
Text(
|
||
'识别准备中…',
|
||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||
),
|
||
];
|
||
}
|
||
return const [
|
||
Text(
|
||
'仅预览 · 未启用识别',
|
||
style: TextStyle(color: Colors.orangeAccent, fontSize: 12),
|
||
),
|
||
];
|
||
}
|
||
final avg = s.latencyAvgMs;
|
||
final lines = <Widget>[];
|
||
if (avg == null) {
|
||
lines.add(const Text(
|
||
'识别延迟 --',
|
||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||
));
|
||
} else {
|
||
final b = _latencyBucket(avg);
|
||
lines.add(Text(
|
||
'识别延迟 ${(avg / 1000).toStringAsFixed(2)} 秒 · ${b.label}',
|
||
style: TextStyle(
|
||
color: b.color,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
));
|
||
}
|
||
final names = ModelManager.instance.modelsLabel;
|
||
if (names.isNotEmpty && names != '未下载') {
|
||
lines.add(Text(
|
||
'识别模型:$names',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.white54, fontSize: 10.5),
|
||
));
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
void _openSettings() {
|
||
final vm = _viewModel;
|
||
if (vm == null) return;
|
||
ModelManager.instance.refresh();
|
||
showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.black87,
|
||
builder: (ctx) => StatefulBuilder(
|
||
// 弹层高度 ≤ 70% 屏高:超过会盖满全屏使遮罩无处点按,弹层无法关闭
|
||
builder: (ctx, setSheetState) => ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'识别设置',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
const Text(
|
||
'置信度阈值',
|
||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${(_minScore * 100).toStringAsFixed(0)}%',
|
||
style: const TextStyle(
|
||
color: Colors.greenAccent,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Slider(
|
||
value: _minScore,
|
||
min: 0.05,
|
||
max: 0.50,
|
||
divisions: 45,
|
||
activeColor: Colors.greenAccent,
|
||
onChanged: (v) {
|
||
setSheetState(() => _minScore = v);
|
||
_analyzer?.worker?.setMinScore(v);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
|
||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Divider(color: Colors.white12),
|
||
const SizedBox(height: 8),
|
||
ModelCatalogSection(manager: ModelManager.instance),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final oldPlatform = ui.PlatformDispatcher.instance.onError;
|
||
ui.PlatformDispatcher.instance.onError = (error, stack) {
|
||
setState(
|
||
() => _globalError =
|
||
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}',
|
||
);
|
||
return oldPlatform?.call(error, stack) ?? false;
|
||
};
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
|
||
WakelockPlus.enable();
|
||
// 每秒轮询原生侧帧状态:无帧时诊断行也能实时刷新(camErr/计数)
|
||
_statsTimer = Timer.periodic(
|
||
const Duration(seconds: 1),
|
||
(_) => _pollStats(),
|
||
);
|
||
// 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker
|
||
ModelManager.instance.addListener(_onModelsChanged);
|
||
}
|
||
|
||
Future<void> _pollStats() async {
|
||
final camera = _cameraController;
|
||
if (camera == null) return;
|
||
final s = await camera.stats();
|
||
if (!mounted) return;
|
||
setState(() => _nativeStats = s);
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
final granted = await Permission.camera.request().isGranted;
|
||
if (!mounted) return;
|
||
setState(() => _permissionGranted = granted);
|
||
if (!granted) return;
|
||
|
||
// 新识别会话(2026-09-03):不恢复上次启用的模型——每次进入都从仅预览开始,
|
||
// 识别需用户在模型清单手动启用;崩溃/坏模型不会自动复现。激活集变化经
|
||
// _onModelsChanged 重建 worker 挂载
|
||
ModelManager.instance.resetForSession();
|
||
// 目录同步先行(缓存优先,2026-09-03):进页面立即触发——refresh 开头先把
|
||
// 上次成功目录从磁盘载入并 notify,设置弹层打开即有内容(不完全依赖网络);
|
||
// 网络拉取/自动更新在后台继续,全程不阻塞预览
|
||
unawaited(ModelManager.instance.refresh());
|
||
// 先出预览:占位 analyzer(无 worker)让相机立即启动,识别加载完再原地挂上
|
||
if (_analyzer == null) {
|
||
final vm = CameraViewModel(reminder: Reminder());
|
||
_viewModel = vm;
|
||
_analyzer = FrameAnalyzer(worker: null, viewModel: vm);
|
||
}
|
||
await _startCamera();
|
||
}
|
||
|
||
/// 用当前激活模型重建推理 worker(启用/停用模型、模型文件更新时调用);
|
||
/// worker 为 null(无启用模型或加载失败)时仅预览并提示。
|
||
/// 并发调用共享同一进行中的重建:进行中时先等完成再复查,期间变化不丢失。
|
||
Future<void> _reloadWorker() {
|
||
final inFlight = _reloadInFlight;
|
||
if (inFlight != null) {
|
||
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
|
||
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
|
||
}
|
||
if (mounted) setState(() => _workerPending = true);
|
||
_reloadInFlight = _doReloadWorker().whenComplete(() {
|
||
_reloadInFlight = null;
|
||
if (mounted) setState(() => _workerPending = false);
|
||
});
|
||
return _reloadInFlight!;
|
||
}
|
||
|
||
Future<void> _doReloadWorker() async {
|
||
final mgr = ModelManager.instance;
|
||
final models = mgr.models;
|
||
final ids = models.map((m) => m.datasetId).toSet();
|
||
final revChanged = mgr.revision != _lastRevision;
|
||
if (_viewModel != null &&
|
||
!revChanged &&
|
||
_loadedModelIds.length == ids.length &&
|
||
_loadedModelIds.containsAll(ids)) {
|
||
return; // 激活集/模型文件未变(进度/目录刷新通知)直接跳过
|
||
}
|
||
_lastRevision = mgr.revision;
|
||
final worker = await DetectorWorker.create(models: models);
|
||
_loadedModelIds = ids;
|
||
if (!mounted) {
|
||
worker?.dispose();
|
||
return; // 页面已关闭:不触碰可能已 dispose 的 vm/analyzer
|
||
}
|
||
final vm = _viewModel ?? CameraViewModel(reminder: Reminder());
|
||
vm.setModelReady(worker != null);
|
||
// 重建后把设置页调过的阈值落到新 worker(避免静默回到默认值)
|
||
worker?.setMinScore(_minScore);
|
||
final existing = _analyzer;
|
||
if (existing == null) {
|
||
// 相机尚未绑定 analyzer(冷启动竞态兜底):直接以新 worker 建 analyzer
|
||
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
|
||
setState(() {
|
||
_viewModel = vm;
|
||
_analyzer = analyzer;
|
||
});
|
||
if (_cameraController != null) {
|
||
await _cameraController!.start(analyzer);
|
||
}
|
||
return;
|
||
}
|
||
// 相机已绑定(预览中):worker 原地挂到既有 analyzer——帧流回调闭包捕获的
|
||
// 是 analyzer 对象本身,替换其 worker 双平台即时生效;重启相机会重新
|
||
// initialize(iOS ~1s+)且预览闪断,一律避免(2026-09-03)
|
||
existing.attachWorker(worker);
|
||
}
|
||
|
||
void _onModelsChanged() {
|
||
final mgr = ModelManager.instance;
|
||
final ids = mgr.models.map((m) => m.datasetId).toSet();
|
||
final revChanged = mgr.revision != _lastRevision;
|
||
if (!revChanged &&
|
||
_loadedModelIds.length == ids.length &&
|
||
_loadedModelIds.containsAll(ids)) {
|
||
return;
|
||
}
|
||
_reloadWorker();
|
||
}
|
||
|
||
Future<void> _startCamera() async {
|
||
final analyzer = _analyzer;
|
||
if (analyzer == null) return;
|
||
try {
|
||
final controller = await AppCameraController.create();
|
||
if (controller == null) {
|
||
setState(() {
|
||
_initFailed = true;
|
||
_initError = '未找到可用摄像头';
|
||
});
|
||
return;
|
||
}
|
||
await controller.start(analyzer);
|
||
if (!mounted) {
|
||
controller.stop();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_cameraController = controller;
|
||
_initFailed = false;
|
||
_initError = null;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_initFailed = true;
|
||
_initError = '$e';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
ModelManager.instance.removeListener(_onModelsChanged);
|
||
_statsTimer?.cancel();
|
||
WakelockPlus.disable();
|
||
_cameraController?.stop();
|
||
_analyzer?.dispose();
|
||
_viewModel?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final vm = _viewModel;
|
||
final camera = _cameraController;
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (!_permissionGranted)
|
||
_PermissionGuide(onRequest: () => _init())
|
||
else if (vm != null && (camera?.isInitialized ?? false))
|
||
// 预览 + 检测框同几何:overlay 作为预览 widget 的 sibling,
|
||
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移。
|
||
// 两平台分析帧都已竖屏(Android 原生侧旋转 / iOS 插件本来就竖屏),
|
||
// rotation 恒 0,CoordinateMapper 走纯 FIT_COVER 缩放路径
|
||
ListenableBuilder(
|
||
listenable: vm,
|
||
builder: (context, _) => _ZoomablePreview(
|
||
controller: camera!,
|
||
overlay: DetectionOverlay(
|
||
results: vm.state.results,
|
||
rotation: 0,
|
||
imageWidthPx: vm.state.imageWidthPx,
|
||
imageHeightPx: vm.state.imageHeightPx,
|
||
),
|
||
),
|
||
)
|
||
else if (camera?.isInitialized ?? false)
|
||
_ZoomablePreview(controller: camera!)
|
||
else
|
||
const Center(
|
||
child: Text('相机启动中…', style: TextStyle(color: Colors.white70)),
|
||
),
|
||
|
||
// 帧级动态层(横幅/诊断行)单独订阅 viewModel,避免整屏重建
|
||
if (vm != null)
|
||
ListenableBuilder(
|
||
listenable: vm,
|
||
builder: (context, _) => _buildDiagnosticsLayer(camera),
|
||
),
|
||
|
||
if (_initFailed)
|
||
Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Text(
|
||
'相机初始化失败',
|
||
style: TextStyle(color: Colors.white),
|
||
),
|
||
if (_initError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
_initError!,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
color: Colors.redAccent,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _initFailed = false);
|
||
_startCamera();
|
||
},
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
Positioned(
|
||
top: MediaQuery.of(context).padding.top + 8,
|
||
left: 0,
|
||
right: 0,
|
||
child: _CameraTopBar(
|
||
onClose: () => Navigator.of(context).pop(),
|
||
onOpenSettings: _openSettings,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDiagnosticsLayer(AppCameraController? camera) {
|
||
final vm = _viewModel!;
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// 模型热更新下载失败提示(已加载模型仍可用,仅提示补更新)
|
||
if (vm.state.modelReady && ModelManager.instance.error != null)
|
||
Positioned(
|
||
left: 16,
|
||
right: 16,
|
||
top: MediaQuery.of(context).padding.top + 56,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
ModelManager.instance.error!,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.orange, fontSize: 12),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠);
|
||
// worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬)
|
||
if (!vm.state.modelReady && !_workerPending)
|
||
Positioned(
|
||
left: 16,
|
||
right: 16,
|
||
top: MediaQuery.of(context).padding.top + 56,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
ModelManager.instance.models.isEmpty
|
||
? '未使用模型:点右上角设置,在「模型清单」中选择要使用的模型'
|
||
: '识别模型加载失败:${DetectorWorker.lastLoadError ?? '未知原因'}\n最后步骤:${DetectorWorker.lastLog ?? '-'}',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.orange, fontSize: 14),
|
||
),
|
||
),
|
||
),
|
||
|
||
Positioned(
|
||
left: 8,
|
||
right: 8,
|
||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// C 端状态胶囊:识别延迟 + 识别模型(连点 5 次展开完整开发诊断)
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _onDevTaps,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 14, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: _statusCapsuleLines(vm.state),
|
||
),
|
||
),
|
||
),
|
||
// —— 开发诊断(默认隐藏,远程排查用)——
|
||
if (_devInfo) ...[
|
||
const SizedBox(height: 4),
|
||
const Text(
|
||
'开发诊断(连点上方状态胶囊 5 次收起)',
|
||
style: TextStyle(color: Colors.white24, fontSize: 10),
|
||
),
|
||
Text(
|
||
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 平均:${vm.state.latencyAvgMs?.toStringAsFixed(0) ?? '-'}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
|
||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||
),
|
||
if (_nativeStats.isNotEmpty)
|
||
Text(
|
||
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? '无'} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
|
||
style: const TextStyle(
|
||
color: Colors.amberAccent,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
if (vm.state.debugYuv.isNotEmpty)
|
||
Text(
|
||
'yuv:${vm.state.debugYuv}',
|
||
style: const TextStyle(
|
||
color: Colors.yellowAccent,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
if (camera != null)
|
||
Text(
|
||
'streaming:${camera.isStreaming} '
|
||
'camErr:${camera.errorDescription ?? '无'}',
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: Colors.cyanAccent,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
if (vm.state.debugLastError != null)
|
||
Text(
|
||
vm.state.debugLastError!,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style:
|
||
const TextStyle(color: Colors.redAccent, fontSize: 11),
|
||
),
|
||
if (_globalError != null)
|
||
Text(
|
||
_globalError!,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style:
|
||
const TextStyle(color: Colors.redAccent, fontSize: 11),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸)
|
||
class _ZoomablePreview extends StatefulWidget {
|
||
final AppCameraController controller;
|
||
|
||
/// 检测框 overlay(随帧更新,与纹理同区域)
|
||
final Widget? overlay;
|
||
|
||
const _ZoomablePreview({required this.controller, this.overlay});
|
||
|
||
@override
|
||
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
||
}
|
||
|
||
class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
||
double _minZoom = 1.0;
|
||
double _maxZoom = 1.0;
|
||
double _currentZoom = 1.0;
|
||
double _gestureStartZoom = 1.0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
widget.controller.getMinZoomLevel().then((v) {
|
||
if (mounted) setState(() => _minZoom = v);
|
||
});
|
||
widget.controller.getMaxZoomLevel().then((v) {
|
||
if (mounted) setState(() => _maxZoom = v);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 预览铺满全屏(cover 裁剪由插件按视图比例完成);
|
||
// overlay 与纹理同几何:作为 Stack sibling 叠在上层,坐标与纹理区域一致
|
||
return GestureDetector(
|
||
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
|
||
onScaleUpdate: (d) {
|
||
final target = (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
|
||
if ((target - _currentZoom).abs() < 0.01) return;
|
||
_currentZoom = target;
|
||
widget.controller.setZoomLevel(target);
|
||
},
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
widget.controller.buildPreview(),
|
||
if (widget.overlay != null) widget.overlay!,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CameraTopBar extends StatelessWidget {
|
||
final VoidCallback onClose;
|
||
final VoidCallback onOpenSettings;
|
||
|
||
const _CameraTopBar({required this.onClose, required this.onOpenSettings});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
color: Colors.black54,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
IconButton(
|
||
tooltip: '返回',
|
||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||
onPressed: onClose,
|
||
),
|
||
IconButton(
|
||
tooltip: '识别设置',
|
||
icon: const Icon(Icons.tune, color: Colors.white70),
|
||
onPressed: onOpenSettings,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PermissionGuide extends StatelessWidget {
|
||
final VoidCallback onRequest;
|
||
|
||
const _PermissionGuide({required this.onRequest});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Text('需要相机权限才能进行实时识别', style: TextStyle(color: Colors.white)),
|
||
const SizedBox(height: 16),
|
||
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|