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 createState() => _CameraScreenState(); } class _CameraScreenState extends State { CameraViewModel? _viewModel; FrameAnalyzer? _analyzer; AppCameraController? _cameraController; bool _initFailed = false; bool _permissionGranted = false; String? _globalError; String? _initError; /// 置信度阈值(设置页滑块调整,worker 内实时生效) double _minScore = 0.10; /// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新) Map _nativeStats = const {}; Timer? _statsTimer; /// 当前 worker 已加载的模型 id 集合(激活集变化对比用) Set _loadedModelIds = const {}; /// 进行中的 worker 重建(并发调用共享,避免重复建/漏建) Future? _reloadInFlight; /// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker int _lastRevision = -1; /// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数) bool _devInfo = false; final List _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 _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 = []; 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( 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 _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.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 _ensureFalseTargetConsent() async { if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') { return true; } if (!mounted) return false; final ok = await showDialog( 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 _pollStats() async { final camera = _cameraController; if (camera == null) return; final s = await camera.stats(); if (!mounted) return; setState(() => _nativeStats = s); } Future _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 _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 _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 _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), ), ), ), // 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略) 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('授权相机')), ], ), ); } }