This commit is contained in:
2026-09-03 17:50:23 +08:00
parent 7bfafc7be3
commit 77d3aad6fc
45 changed files with 1398 additions and 1088 deletions
+268 -136
View File
@@ -47,6 +47,79 @@ class _CameraScreenState extends State<CameraScreen> {
/// 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;
@@ -56,53 +129,66 @@ class _CameraScreenState extends State<CameraScreen> {
isScrollControlled: true,
backgroundColor: Colors.black87,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('识别设置',
// 弹层高度 ≤ 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)}%',
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),
],
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),
],
),
),
),
),
@@ -115,15 +201,20 @@ class _CameraScreenState extends State<CameraScreen> {
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')}');
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());
_statsTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _pollStats(),
);
// 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker
ModelManager.instance.addListener(_onModelsChanged);
}
@@ -142,29 +233,37 @@ class _CameraScreenState extends State<CameraScreen> {
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型
// 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 30));
} catch (_) {}
await _reloadWorker();
// 新识别会话(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(无激活模型或加载失败)时仅预览并提示。
/// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待
/// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。
/// 用当前激活模型重建推理 worker(启用/停用模型、模型文件更新时调用);
/// worker 为 null(无启用模型或加载失败)时仅预览并提示。
/// 并发调用共享同一进行中的重建:进行中时先等完成再复查,期间变化不丢失。
Future<void> _reloadWorker() {
final inFlight = _reloadInFlight;
if (inFlight != null) {
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
}
_reloadInFlight =
_doReloadWorker().whenComplete(() => _reloadInFlight = null);
if (mounted) setState(() => _workerPending = true);
_reloadInFlight = _doReloadWorker().whenComplete(() {
_reloadInFlight = null;
if (mounted) setState(() => _workerPending = false);
});
return _reloadInFlight!;
}
@@ -182,24 +281,31 @@ class _CameraScreenState extends State<CameraScreen> {
_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);
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
final old = _analyzer;
if (!mounted) {
analyzer.dispose();
if (vm != _viewModel) vm.dispose();
// 重建后把设置页调过的阈值落到新 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;
}
setState(() {
_viewModel = vm;
_analyzer = analyzer;
});
old?.worker?.dispose();
// 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅)
if (mounted && _cameraController != null) {
await _cameraController!.start(analyzer);
}
// 相机已绑定(预览中):worker 原地挂到既有 analyzer——帧流回调闭包捕获的
// 是 analyzer 对象本身,替换其 worker 双平台即时生效;重启相机会重新
// initializeiOS ~1s+)且预览闪断,一律避免(2026-09-03)
existing.attachWorker(worker);
}
void _onModelsChanged() {
@@ -310,7 +416,10 @@ class _CameraScreenState extends State<CameraScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('相机初始化失败', style: TextStyle(color: Colors.white)),
const Text(
'相机初始化失败',
style: TextStyle(color: Colors.white),
),
if (_initError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
@@ -320,7 +429,9 @@ class _CameraScreenState extends State<CameraScreen> {
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent, fontSize: 11),
color: Colors.redAccent,
fontSize: 11,
),
),
),
TextButton(
@@ -361,8 +472,7 @@ class _CameraScreenState extends State<CameraScreen> {
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
@@ -375,8 +485,9 @@ class _CameraScreenState extends State<CameraScreen> {
),
),
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
if (!vm.state.modelReady)
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
// worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬)
if (!vm.state.modelReady && !_workerPending)
Positioned(
left: 16,
right: 16,
@@ -405,56 +516,87 @@ class _CameraScreenState extends State<CameraScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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.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),
// 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 (_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 (_devInfo) ...[
const SizedBox(height: 4),
const Text(
'开发诊断(连点上方状态胶囊 5 次收起)',
style: TextStyle(color: Colors.white24, fontSize: 10),
),
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),
'阈值:${(_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 内同尺寸)
@@ -464,10 +606,7 @@ class _ZoomablePreview extends StatefulWidget {
/// 检测框 overlay(随帧更新,与纹理同区域)
final Widget? overlay;
const _ZoomablePreview({
required this.controller,
this.overlay,
});
const _ZoomablePreview({required this.controller, this.overlay});
@override
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
@@ -497,8 +636,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
return GestureDetector(
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
onScaleUpdate: (d) {
final target =
(_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
final target = (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
if ((target - _currentZoom).abs() < 0.01) return;
_currentZoom = target;
widget.controller.setZoomLevel(target);
@@ -518,10 +656,7 @@ class _CameraTopBar extends StatelessWidget {
final VoidCallback onClose;
final VoidCallback onOpenSettings;
const _CameraTopBar({
required this.onClose,
required this.onOpenSettings,
});
const _CameraTopBar({required this.onClose, required this.onOpenSettings});
@override
Widget build(BuildContext context) {
@@ -558,10 +693,7 @@ class _PermissionGuide extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'需要相机权限才能进行实时识别',
style: TextStyle(color: Colors.white),
),
const Text('需要相机权限才能进行实时识别', style: TextStyle(color: Colors.white)),
const SizedBox(height: 16),
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
],
+66 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
import '../detection/nms.dart';
import '../reminder/reminder.dart';
@immutable
@@ -21,6 +22,10 @@ class CameraUiState {
final int debugLastMs;
final String debugYuv;
/// 近 [CameraViewModel.latencyWindow] 帧推理耗时滚动平均(毫秒;无样本为 null)。
/// 给 C 端用户看的设备识别延迟。
final double? latencyAvgMs;
const CameraUiState({
this.modelReady = false,
this.results = const [],
@@ -34,6 +39,7 @@ class CameraUiState {
this.framesReceived = 0,
this.debugLastMs = 0,
this.debugYuv = '',
this.latencyAvgMs,
});
}
@@ -43,6 +49,9 @@ class CameraUiState {
/// 高于 0.35 视为强证据。
/// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// (运动区域/背景新出现区域重叠)才确认显示。
/// - 同位置去重(2026-09-03):多模型对同一目标交替检出时各轨迹都会在
/// 忘记窗内持续显示 → 轨迹层跨标签同目标补挂 + 显示层
/// [dedupeVisibleOverlaps] 同类别强重叠只保留高置信度框。
class CameraViewModel extends ChangeNotifier {
static const int maxTracks = 30;
static const double motionBoost = 0.15;
@@ -52,6 +61,10 @@ class CameraViewModel extends ChangeNotifier {
static const int displayAgeMs = 500;
static const int forgetMs = 2000;
/// 延迟滚动平均窗口(帧数):单帧抖动大,取近期均值给用户展示
static const int latencyWindow = 30;
final List<int> _procWindow = [];
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
bool modelReady = false;
@@ -68,6 +81,8 @@ class CameraViewModel extends ChangeNotifier {
void setModelReady(bool ready) {
if (modelReady == ready) return;
modelReady = ready;
// 换 worker / 停识别:旧窗口样本作废,从零起算
_procWindow.clear();
_state = CameraUiState(modelReady: ready);
notifyListeners();
}
@@ -122,9 +137,18 @@ class CameraViewModel extends ChangeNotifier {
for (final r in results) {
if (r.score > highest) highest = r.score;
}
if (lastProcessMs > 0) {
_procWindow.add(lastProcessMs);
if (_procWindow.length > latencyWindow) {
_procWindow.removeAt(0);
}
}
final latencyAvg = _procWindow.isEmpty
? null
: _procWindow.reduce((a, b) => a + b) / _procWindow.length;
_state = CameraUiState(
modelReady: modelReady,
results: visible,
results: dedupeVisibleOverlaps(visible),
rotation: rotation,
imageWidthPx: imageWidthPx,
imageHeightPx: imageHeightPx,
@@ -135,6 +159,7 @@ class CameraViewModel extends ChangeNotifier {
framesReceived: framesReceived,
debugLastMs: lastProcessMs,
debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv,
latencyAvgMs: latencyAvg,
);
notifyListeners();
}
@@ -161,6 +186,24 @@ class CameraViewModel extends ChangeNotifier {
best = t;
}
}
// 跨标签同目标补挂(2026-09-03 用户实测修订:重叠位置只保留高置信度框):
// 中心距超过跨标签收紧半径、但几何强重叠指向同一位置(不同模型对同一
// 目标的框偏移/紧致度不同)的检测并入既有轨迹,防止同目标两条轨迹并存
// → 同位置双名常驻/重复提醒。疑似↔目标(不同类别预警)不并入。
if (best == null) {
_Track? adopt;
var adoptD = double.infinity;
for (final t in _tracks.values) {
if (matched.contains(t.id)) continue;
if (t.isSuspect != r.isSuspect || !sameTarget(t.result, r)) continue;
final d = _centerDist(t.result, r);
if (d < adoptD) {
adoptD = d;
adopt = t;
}
}
best = adopt;
}
if (best != null) {
matched.add(best.id);
best.update(r, now);
@@ -234,8 +277,30 @@ class _Track {
: lastSeenMs = firstSeenMs,
label = result.label;
/// 当前框是否疑似类别(随关联的最新检测更新——轨迹框在跨标签补挂后
/// 会换成其他标签的框;轨迹 label 仅创建时记账)
bool get isSuspect => result.isSuspect;
void update(DetectionResult r, int now) {
lastSeenMs = now;
result = r;
}
}
/// 同屏抑制(2026-09-03 用户实测修订:同一位置/重叠位置出现同/异模型检出的
/// 物种只保留高置信度者)。根因:多个模型对同一目标**交替**检出时,每帧合并
/// 层只压掉当帧低分框,但各自轨迹都落在 2s 忘记窗内持续显示 → 同位置双名
/// 常驻。显示层兜底:可见框内同类别(都目标/都疑似)、且 [sameTarget] 强
/// 重叠指向同一位置的框每帧只保留最高分者。异类别(目标×疑似生境预警)是
/// 两种语义不同的框,同位置也各自保留。
List<DetectionResult> dedupeVisibleOverlaps(List<DetectionResult> visible) {
if (visible.length <= 1) return visible;
final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final r in sorted) {
if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) {
kept.add(r);
}
}
return kept;
}
+22 -6
View File
@@ -12,8 +12,12 @@ class FrameAnalyzer {
/// 连续检测:100ms 一帧
int intervalMs = 100;
/// null = 模型加载失败,仅预览不分析
final DetectorWorker? worker;
DetectorWorker? _worker;
/// 当前推理 worker(null = 仅预览不分析);相机启动后可用 [attachWorker]
/// 原地替换(相机帧流回调闭包捕获的是本 analyzer 对象,无需重启相机)
DetectorWorker? get worker => _worker;
final CameraViewModel viewModel;
int _lastDetectMs = 0;
@@ -29,9 +33,21 @@ class FrameAnalyzer {
/// 图像流回调是否到达(诊断用)
int framesReceived = 0;
FrameAnalyzer({required this.worker, required this.viewModel}) {
worker?.onResult = _onResult;
worker?.onError = _onError;
FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) {
attachWorker(worker);
}
/// 替换推理 workernull = 停识别仅预览)。旧 worker 在此释放;若相机已
/// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize
/// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。
void attachWorker(DetectorWorker? w) {
if (identical(_worker, w)) return;
final old = _worker;
_worker = w;
w?.onResult = _onResult;
w?.onError = _onError;
old?.dispose();
_lastDetectMs = 0;
}
void recordStreamError(String msg) {
@@ -127,5 +143,5 @@ class FrameAnalyzer {
void reset() => _lastDetectMs = 0;
void dispose() => worker?.dispose();
void dispose() => _worker?.dispose();
}
+315 -204
View File
@@ -2,15 +2,24 @@ import 'package:flutter/material.dart';
import '../models/model_manager.dart';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(s 高识别 / n 高性能,
/// 默认 s,持久化本地;切换即热加载该档位已激活模型),下方 2 列封面缩略图网格。
/// 双档位:每数据集至多两张卡片(s/n 各一),卡片带档位角标;
/// 卡片状态(下载进度/激活)按 (数据集, 档位) 独立记账。
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度,
/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**——
/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方
/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账,
/// 各自下载独立进度)。
///
/// 卡片状态机(档位 == 当前识别档位时与原单档行为一致):
/// - 未下载 →「使用/下载」:下载完成自动激活(当前档立即使用,非当前档备好待切);
/// - 已下载未激活 →「使用」直接激活;已激活且当前档 →「已使用」点击取消;
/// - 已激活但属非当前档 →「已备好」(已下载就绪,点击切换到该档立即生效)。
/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用
/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案——
/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示):
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
/// - 目标档已激活 →「使用中」点击取消使用;
/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用);
/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好;
/// 另一档在使用时补下目标档后自动切换过去)。
class ModelCatalogSection extends StatelessWidget {
final ModelManager manager;
@@ -21,44 +30,57 @@ class ModelCatalogSection extends StatelessWidget {
return ListenableBuilder(
listenable: manager,
builder: (context, _) {
// 目录行按 (数据集, 档位) 稳定排序:同数据集 s 前 n 后相邻展示
final items = [...manager.catalog]..sort((a, b) {
if (a.datasetId != b.datasetId) {
return a.datasetId.compareTo(b.datasetId);
}
return a.variant.compareTo(b.variant);
});
// 目录按数据集分组:同一物种 s/n 合成一张卡;组内 s(高精度)前 n 后
final byDataset = <int, List<ModelCatalogItem>>{};
for (final c in manager.catalog) {
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
}
final groups = byDataset.values.toList()
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
final order = {kVariantS: 0, kVariantN: 1};
for (final g in groups) {
g.sort((a, b) =>
(order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('识别模式',
style: TextStyle(color: Colors.white70, fontSize: 14)),
const Text(
'识别模式',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
_ModeToggle(manager: manager),
],
),
const SizedBox(height: 2),
const Text('切换即加载该档位已激活模型(激活状态跨档保留)',
style: TextStyle(color: Colors.white38, fontSize: 11)),
const Text(
'卡片操作面向所选档位;同一动物一次只运行一档,切换会自动停用另一档',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(height: 12),
Row(
children: [
const Text('模型清单',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold)),
const Text(
'模型清单',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: () => manager.refresh(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact),
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -66,27 +88,29 @@ class ModelCatalogSection extends StatelessWidget {
if (manager.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(manager.error!,
style: const TextStyle(
color: Colors.orange, fontSize: 12)),
child: Text(
manager.error!,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
if (items.isEmpty)
const Text('暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13))
if (groups.isEmpty)
const Text(
'暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: items.length,
itemCount: groups.length,
itemBuilder: (context, i) =>
_ModelCard(item: items[i], manager: manager),
_SpeciesCard(items: groups[i], manager: manager),
),
],
);
@@ -95,12 +119,16 @@ class ModelCatalogSection extends StatelessWidget {
}
}
/// 档位分段控件:s 高识别 / n 高性能
/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置,
/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示)
class _ModeToggle extends StatelessWidget {
final ModelManager manager;
const _ModeToggle({required this.manager});
/// 展示顺序(与内部档位常量解耦)
static const List<String> _displayOrder = [kVariantN, kVariantS];
@override
Widget build(BuildContext context) {
return Container(
@@ -112,9 +140,9 @@ class _ModeToggle extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in const [kVariantS, kVariantN]) ...[
if (v != kVariantS) const SizedBox(width: 2),
_seg(v),
for (var i = 0; i < _displayOrder.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
_seg(_displayOrder[i]),
],
],
),
@@ -123,7 +151,6 @@ class _ModeToggle extends StatelessWidget {
Widget _seg(String v) {
final selected = manager.mode == v;
final label = v == kVariantS ? 's 高识别' : 'n 高性能';
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => manager.setMode(v),
@@ -134,7 +161,7 @@ class _ModeToggle extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
_variantLabel(v),
style: TextStyle(
color: selected ? Colors.black : Colors.white70,
fontSize: 12,
@@ -146,22 +173,35 @@ class _ModeToggle extends StatelessWidget {
}
}
class _ModelCard extends StatelessWidget {
final ModelCatalogItem item;
/// 单物种卡片:同数据集 s/n 两档合并;下载/激活状态按 (数据集, 档位) 独立记账,
/// 同物种同时至多一个档位被激活(激活以目标档为准)
class _SpeciesCard extends StatelessWidget {
final List<ModelCatalogItem> items;
final ModelManager manager;
const _ModelCard({required this.item, required this.manager});
const _SpeciesCard({required this.items, required this.manager});
@override
Widget build(BuildContext context) {
final variant = item.variant;
final sameMode = manager.mode == variant;
final active = manager.isActive(item.datasetId, variant);
final downloaded = manager.isDownloaded(item.datasetId, variant);
final progress = manager.progressOf(item.datasetId, variant);
final error = manager.errorOf(item.datasetId, variant);
final accent =
variant == kVariantS ? Colors.greenAccent : Colors.orangeAccent;
// 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档
final target = items.firstWhere(
(i) => i.variant == manager.mode,
orElse: () => items.first,
);
final tActive = manager.isActive(target.datasetId, target.variant);
final tDownloaded = manager.isDownloaded(target.datasetId, target.variant);
// 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束)
final activeOther = items
.where((i) =>
i.variant != target.variant &&
manager.isActive(i.datasetId, i.variant))
.toList();
// 任一档进行中/失败标志
final downloading =
items.any((i) => manager.progressOf(i.datasetId, i.variant) != null);
final hasError =
items.any((i) => manager.errorOf(i.datasetId, i.variant) != null);
final thumb = ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -171,41 +211,25 @@ class _ModelCard extends StatelessWidget {
fit: StackFit.expand,
children: [
Image.network(
'${manager.baseUrl}${item.coverUrl}',
'${manager.baseUrl}${target.coverUrl}',
fit: BoxFit.cover,
loadingBuilder: (context, child, chunk) => chunk == null
? child
: Container(
color: Colors.white12,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2)))),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorBuilder: (context, error, stack) => Container(
color: Colors.white12,
child: const Icon(Icons.image_not_supported_outlined,
color: Colors.white38),
),
),
// 档位角标:s 高识别(绿)/ n 高性能(橙)
Positioned(
top: 4,
left: 4,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? 's' : 'n',
style: const TextStyle(
color: Colors.black87,
fontSize: 10,
fontWeight: FontWeight.bold),
child: const Icon(
Icons.image_not_supported_outlined,
color: Colors.white38,
),
),
),
@@ -214,110 +238,6 @@ class _ModelCard extends StatelessWidget {
),
);
Widget action;
if (progress != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white12,
color: Colors.greenAccent),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: Text(
'${(progress * 100).toStringAsFixed(0)}%',
textAlign: TextAlign.center,
style:
const TextStyle(color: Colors.white70, fontSize: 11),
),
),
TextButton(
onPressed: () =>
manager.cancelDownload(item.datasetId, variant),
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('取消', style: TextStyle(fontSize: 11)),
),
],
),
],
);
} else if (error != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(error,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.redAccent, fontSize: 10)),
TextButton(
onPressed: () => manager.downloadModel(item),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
],
);
} else if (active && downloaded) {
if (sameMode) {
// 当前档已激活:点击取消
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(item.datasetId, variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent)),
child: const Text('已使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 非当前档已备好(激活保留):点击切换到该档立即生效
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () => manager.setMode(variant),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.amberAccent,
side: const BorderSide(color: Colors.amberAccent)),
child: const Text('已备好', style: TextStyle(fontSize: 12)),
),
);
}
} else if (downloaded) {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.setActive(item.datasetId, variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.downloadModel(item),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: Text(sameMode ? '使用' : '下载', style: const TextStyle(fontSize: 12)),
),
);
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -332,35 +252,226 @@ class _ModelCard extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(item.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? '高识别' : '高性能',
style: TextStyle(color: accent, fontSize: 9),
target.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text('v${item.version}',
style: const TextStyle(color: Colors.white38, fontSize: 10)),
// 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰
Text.rich(
TextSpan(
children: [
for (final i in items) ...[
if (i != items.first) const TextSpan(text: ' · '),
TextSpan(
text: '${_variantLabel(i.variant)} ${i.version}',
style: TextStyle(
fontSize: 9,
color: manager.isActive(i.datasetId, i.variant)
? Colors.greenAccent
: manager.isDownloaded(i.datasetId, i.variant)
? Colors.white70
: Colors.white38,
fontWeight: manager.isActive(i.datasetId, i.variant)
? FontWeight.bold
: FontWeight.normal,
),
),
],
],
),
),
const SizedBox(height: 6),
action,
_actionArea(
items: items,
target: target,
downloading: downloading,
hasError: hasError,
tActive: tActive,
tDownloaded: tDownloaded,
activeOther: activeOther,
),
],
),
);
}
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给主按钮
Widget _actionArea({
required List<ModelCatalogItem> items,
required ModelCatalogItem target,
required bool downloading,
required bool hasError,
required bool tActive,
required bool tDownloaded,
required List<ModelCatalogItem> activeOther,
}) {
if (downloading) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.progressOf(i.datasetId, i.variant) != null)
_progressRow(item: i),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.progressOf(i.datasetId, i.variant) != null) {
manager.cancelDownload(i.datasetId, i.variant);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('取消', style: TextStyle(fontSize: 11)),
),
),
],
);
}
if (hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.errorOf(i.datasetId, i.variant) != null)
Text(
'${_variantLabel(i.variant)}下载失败',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.redAccent, fontSize: 10),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.errorOf(i.datasetId, i.variant) != null) {
manager.downloadModel(i);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('重试', style: TextStyle(fontSize: 11)),
),
),
],
);
}
final Widget mainBtn;
if (tActive) {
// 目标档使用中:点击取消使用
mainBtn = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent),
),
child: const Text('使用中', style: TextStyle(fontSize: 12)),
),
);
} else if (tDownloaded) {
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被
// 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 目标档未下载:下载全部缺失档(目标档落地自动启用、伴档备好待切);
// 另一档在使用时按钮同样为「下载」:补下目标档后自动切换(停用另一档)
final use = activeOther.isNotEmpty;
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () async {
if (!use) {
for (final i in items) {
if (!manager.isDownloaded(i.datasetId, i.variant)) {
manager.downloadModel(i);
}
}
return;
}
if (!manager.isDownloaded(target.datasetId, target.variant)) {
final ok = await manager.downloadModel(target);
if (!ok) return; // 下载失败/取消:错误分支展示,保持现状
}
await manager.setActive(target.datasetId, target.variant, true);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('下载', style: TextStyle(fontSize: 12)),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [mainBtn],
);
}
Widget _progressRow({required ModelCatalogItem item}) {
final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0;
final label = _variantLabel(item.variant);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'$label ${(p * 100).toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
LinearProgressIndicator(
value: p,
backgroundColor: Colors.white12,
color: Colors.greenAccent,
),
],
);
}
}