Files
2026-09-08 22:29:00 +08:00

837 lines
31 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert' show jsonEncode;
import 'dart:ui' as ui show PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../auth/session_store.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import '../feedback/feedback_api.dart';
import '../feedback/false_target_capture.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;
/// 假目标上报进行中(防重复触发)
bool _reporting = false;
static const _falseTargetConsent = FlutterSecureStorage();
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),
],
),
),
),
),
),
);
}
/// 假目标上报:一键上报当前画面——按一次快照帧(与喂给 YOLO 的帧同源同尺寸)
/// + 当前全部检测框快照,单独同意(首次)→ isolate 内整帧 jpg 重编码
/// (剥离全部元数据)→ 上传。无任何额外选择步骤,取消/失败不影响识别。
Future<void> _startFalseTargetReport() async {
final analyzer = _analyzer;
final vm = _viewModel;
if (_reporting || analyzer == null || vm == null || !vm.state.modelReady) {
return;
}
_reporting = true;
try {
final snap = await analyzer
.takeSnapshot()
.timeout(const Duration(seconds: 2), onTimeout: () => null);
if (!mounted || snap == null) {
_toast('未获取到画面,请稍后重试');
return;
}
final ok = await _ensureFalseTargetConsent();
if (!mounted || !ok) return;
_toast('正在上报…');
final boxes = List<DetectionResult>.of(vm.state.results)
.where((b) => b.width > 0 && b.height > 0)
.toList();
final detections = jsonEncode([
for (final b in boxes)
{
'label': b.label,
'class': b.classId,
'score': double.parse(b.score.toStringAsFixed(4)),
'model': b.modelName,
'cx': double.parse(b.centerX.toStringAsFixed(6)),
'cy': double.parse(b.centerY.toStringAsFixed(6)),
'w': double.parse(b.width.toStringAsFixed(6)),
'h': double.parse(b.height.toStringAsFixed(6)),
},
]);
final jpeg = await compute(encodeFrameJpeg, snap);
await FeedbackApi(sessionStore: SessionStore()).reportFalseTarget(
jpeg: jpeg,
detectionsJson: detections,
sourceW: snap.width,
sourceH: snap.height,
);
_toast('感谢反馈!我们会用它改进识别');
} catch (e) {
_toast('上报失败: $e');
} finally {
_reporting = false;
}
}
/// 单独同意(PIPL):首次上报前弹说明,可拒绝且不影响识别;同意后记录不再弹
Future<bool> _ensureFalseTargetConsent() async {
if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') {
return true;
}
if (!mounted) return false;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('上报说明'),
content: const Text(
'将上传当前画面一帧(与识别使用的画面一致,上传前已在本地重新编码、'
'移除位置等全部照片信息)及当时的识别框,用于改进识别模型。\n\n'
'该功能完全自愿,拒绝不影响任何其他功能。已上传的图片可联系客服删除。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('暂不上报'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('同意并继续'),
),
],
),
);
if (ok == true) {
await _falseTargetConsent.write(key: falseTargetConsentKey, value: '1');
}
return ok == true;
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(msg), duration: const Duration(seconds: 2)));
}
@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 双平台即时生效;重启相机会重新
// initializeiOS ~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 恒 0CoordinateMapper 走纯 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),
),
),
),
// 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略)
if (vm.state.modelReady && vm.state.results.isNotEmpty && !_workerPending)
Positioned(
left: 12,
bottom: MediaQuery.of(context).padding.bottom + 72,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: _startFalseTargetReport,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.flag_outlined,
color: Colors.orangeAccent, size: 16),
SizedBox(width: 5),
Text('误报上报',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
),
),
),
),
),
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('授权相机')),
],
),
);
}
}