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
+4 -9
View File
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装
// 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装
// 2026-09-03App 内安装在小米等 ROM 失败,改浏览器下载)
final info = await UpdateChecker().fetch();
final current = await PackageInfo.fromPlatform();
if (!mounted) return;
final session = context.read<SessionStore>();
final acceptedUpdate = await session.readAcceptedUpdateVersion();
if (!mounted) return;
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
if (UpdateChecker.needsUpdate(info.version, current.version)) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => UpdateScreen(
version: info.version,
url: UpdateChecker.downloadUrl(),
notes: info.notes,
onUpdateAccepted: () =>
session.saveAcceptedUpdateVersion(info.version),
),
));
return;
-16
View File
@@ -2,17 +2,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static const _acceptedUpdateKey = 'accepted_update_version';
static String? _cachedToken;
static String? _cachedPhone;
static String? _cachedAcceptedUpdate;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
@@ -24,18 +20,6 @@ class SessionStore {
return _cachedPhone = await _storage.read(key: _phoneKey);
}
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
Future<String> readAcceptedUpdateVersion() async {
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
return _cachedAcceptedUpdate =
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
}
Future<void> saveAcceptedUpdateVersion(String version) async {
_cachedAcceptedUpdate = version;
await _storage.write(key: _acceptedUpdateKey, value: version);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
+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,
),
],
);
}
}
@@ -34,6 +34,9 @@ class DetectionResult {
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
/// 疑似(生境预警)类别:类别索引 >0 或训练标签为 suspect
bool get isSuspect => classId > 0 || label == 'suspect';
DetectionResult copyWith({
double? score,
double? left,
+20 -6
View File
@@ -229,15 +229,12 @@ Future<void> _workerMain(SendPort mainPort) async {
for (final entry in list[1] as List) {
final e = entry as List;
final name = e.length > 3 ? e[3] as String : '';
// 模型名带档位标识(数据集名+档位,框来源可辨 s/n
final variant = e.length > 4 ? e[4] as String : '';
final displayName =
variant.isEmpty ? name : '$name($variant)';
// modelName 仅标注来源数据集名档位码 s/n 内部记账,不展示给用户
final d = await TfliteDetector.fromBuffer(
e[0] as Uint8List,
(e[1] as List).cast<String>(),
modelId: (e[2] as num).toInt(),
modelName: displayName,
modelName: name,
);
if (d == null) {
failures.add(name.isEmpty ? 'unknown' : name);
@@ -475,8 +472,25 @@ Future<void> _workerMain(SendPort mainPort) async {
/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制
/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重,
/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。
///
/// 2026-09-03 补跨模型同目标窗口:小框被大框覆盖 > [iouThreshold] 直接去重;
/// 覆盖不足、但两框来自**不同模型**且 [sameTarget](小框中心在大框内、
/// 覆盖 ≥ 30%)也去重——不同输入分辨率模型对同一目标的框几何有系统性偏移,
/// 纯阈值会漏判。同模型框对(模型内已做过类内 NMS)不套用该窗口。
List<DetectionResult> mergeAcrossModels(
List<DetectionResult> all, double iouThreshold) {
if (all.length <= 1) return all;
return nms(all, iouThreshold);
final sorted = [...all]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final b in sorted) {
final dup = kept.any((k) {
if (boxOverlap(k, b) > iouThreshold) return true;
if (k.modelId == b.modelId || k.modelId < 0 || b.modelId < 0) {
return false; // 同模型/无来源:类内 NMS 已处理,不补窗
}
return sameTarget(k, b);
});
if (!dup) kept.add(b);
}
return kept;
}
+19
View File
@@ -35,3 +35,22 @@ List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
}
return kept;
}
/// 同目标判定(2026-09-03 用户实测修订:同一标注位置/重叠位置,同/异模型
/// 检出的物种只保留高置信度框)。不同模型对同一目标的框紧致度/偏移系统性
/// 不同,纯 boxOverlap 阈值(0.45)会漏判「几何明显指向同一位置」的偏移框;
/// 补判条件:小框被大框覆盖 ≥ [sameTargetMinCover] 且小框中心落在大框内
/// (相邻独立目标的中心不会落在对方框内,不会被误并)。
const double sameTargetMinCover = 0.3;
bool sameTarget(DetectionResult a, DetectionResult b) {
final cover = boxOverlap(a, b);
if (cover < sameTargetMinCover) return false;
final aBigger = a.width * a.height >= b.width * b.height;
final big = aBigger ? a : b;
final small = aBigger ? b : a;
return small.centerX >= big.left &&
small.centerX <= big.right &&
small.centerY >= big.top &&
small.centerY <= big.bottom;
}
@@ -11,7 +11,7 @@ import 'nms.dart';
/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors]
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
/// 输入为 NCHW [1, 3, H, W]litert 导出保留 torch 布局),H/W 随模型档位:
/// s 高识别 @1280、n 高性能 @704,输入尺寸取自模型自身。
/// s 高精度 @1280、n 高性能 @704,输入尺寸取自模型自身。
class TfliteDetector {
// 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集
// 训练 imgsz 可不同),默认 1280 兜底
+157 -102
View File
@@ -9,7 +9,7 @@ import 'package:path_provider/path_provider.dart';
import '../config/app_config.dart';
/// 识别档位标识:s = 高识别(@1280 精度优先,默认),n = 高性能(@704 速度优先)
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
const String kVariantS = 's';
const String kVariantN = 'n';
@@ -85,8 +85,17 @@ class ModelBundle {
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`
/// 各目录含 model.tflite + labels.json + meta.jsonmeta 记录 {version, sha256}
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型,
/// 切档即热加载新档位已激活模型([mode] 持久化本地)。
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
/// 激活集是**会话态**2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
/// 界面并自行调整。
/// 目录**缓存优先**2026-09-03):最近一次成功拉取的 models 目录落盘
/// catalog.json[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/自动更新只在网络成功(fetched)
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
@@ -103,7 +112,6 @@ class ModelManager extends ChangeNotifier {
final Set<ModelKey> _cancelRequested = {};
String _mode = kVariantS;
bool _modeLoaded = false;
bool _activeLoaded = false;
bool _ready = false;
bool _refreshing = false;
String? _error;
@@ -117,7 +125,8 @@ class ModelManager extends ChangeNotifier {
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
List<ModelCatalogItem> get catalog => _catalog;
/// 当前识别档位(s 高识别默认 / n 高性能),持久化,切档即热加载新档位模型
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集)
String get mode => _mode;
bool isActive(int datasetId, String variant) =>
@@ -154,7 +163,7 @@ class ModelManager extends ChangeNotifier {
_client = client ?? http.Client(),
_rootDirOverride = rootDir;
/// 已激活且已下载、且属于当前档位的模型列表(空 = 未加载任何模型,仅预览)
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
List<ModelBundle> get models => _models;
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空"
@@ -165,20 +174,19 @@ class ModelManager extends ChangeNotifier {
bool get refreshing => _refreshing;
/// 模型名摘要(诊断行展示):数据集名+档位
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
String get modelsLabel {
if (_models.isEmpty) return '未下载';
return _models.map((m) => '${m.datasetName}(${m.variant})').join(',');
return _models.map((m) => m.datasetName).join(',');
}
/// 切换识别档位:持久化本地并热加载该档位已激活模型(激活集跨档保留)
/// 切换识别目标档位:只改偏好并持久化(不切换已在运行的模型——
/// 每个动物的实际档位由激活集决定,卡片按钮会面向新目标档给出「改用」操作)
Future<void> setMode(String variant) async {
if (variant != kVariantS && variant != kVariantN) return;
if (_mode == variant) return;
_mode = variant;
await _saveMode();
_models = await _loadBundles(_catalog);
_revision++;
notifyListeners();
}
@@ -194,26 +202,55 @@ class ModelManager extends ChangeNotifier {
return _inFlight!;
}
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
void resetForSession() {
if (_active.isEmpty) return;
_active.clear();
_models = const [];
_revision++;
notifyListeners();
}
Future<void> _doRefresh() async {
try {
await _loadActive();
await _loadMode();
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 30));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
if (_catalog.isEmpty) {
await _loadCatalogCache();
if (_catalog.isNotEmpty) notifyListeners();
}
var fetched = false;
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 30));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
await _saveCatalogCache(list);
fetched = true;
} catch (e) {
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
if (!_ready) _error = '模型目录拉取失败:$e';
}
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
if (_catalog.isEmpty && !fetched) return;
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
final downloaded = <ModelKey>{};
for (final item in _catalog) {
if (await _isLocal(item)) {
if (await _hasFile(item)) {
downloaded.add((datasetId: item.datasetId, variant: item.variant));
}
}
@@ -221,18 +258,15 @@ class ModelManager extends ChangeNotifier {
..clear()
..addAll(downloaded);
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
if (!fetched) return;
await _prune(_catalog);
// 服务器已下线的 (数据集, 档位) 移出激活集
final catalogKeys = _catalog
.map((c) => (datasetId: c.datasetId, variant: c.variant))
.toSet();
final pruned = _active.where((k) => !catalogKeys.contains(k)).toList();
if (pruned.isNotEmpty) {
for (final k in pruned) {
_active.remove(k);
}
await _saveActive();
}
_active.removeWhere((k) => !catalogKeys.contains(k));
_models = await _loadBundles(_catalog);
_ready = true;
@@ -260,11 +294,14 @@ class ModelManager extends ChangeNotifier {
}
}
/// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta
/// 成功自动加入激活集(下载完成即使用;非当前档位则备好待切档)。
/// 失败重试一次并记录错误。
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
Future<bool> downloadModel(ModelCatalogItem item,
{void Function(int received, int total)? onProgress}) async {
{bool autoActivate = true,
void Function(int received, int total)? onProgress}) async {
final key = (datasetId: item.datasetId, variant: item.variant);
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
if (_progress.containsKey(key)) return false;
@@ -284,14 +321,25 @@ class ModelManager extends ChangeNotifier {
if (ok) {
_progress.remove(key);
_errors.remove(key);
final wasActive = _active.contains(key);
_downloaded.add(key);
if (item.variant == _mode) {
// 字节替换生效(当前档位模型更新需重建 worker 读新文件
if (wasActive) {
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
_revision++;
_models = await _loadBundles(_catalog);
} else if (autoActivate &&
!_active.any((k) => k.datasetId == item.datasetId)) {
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
final hasTarget = _catalog.any((c) =>
c.datasetId == item.datasetId && c.variant == _mode);
if (item.variant == _mode || !hasTarget) {
_active.add(key);
_revision++;
_models = await _loadBundles(_catalog);
}
}
_models = await _loadBundles(_catalog);
notifyListeners();
await setActive(item.datasetId, item.variant, true);
return true;
}
if (_cancelRequested.contains(key)) break;
@@ -423,84 +471,49 @@ class ModelManager extends ChangeNotifier {
}
}
/// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`
/// 激活跨档位保留(切档后仍生效);仅影响当前档位加载时才需重建推理 worker。
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
/// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
/// 变化即重建推理 worker。未下载的模型不可激活(下载完成按目标档自动激活)。
Future<void> setActive(int datasetId, String variant, bool active) async {
final key = (datasetId: datasetId, variant: variant);
final changed = active ? _active.add(key) : _active.remove(key);
if (!changed) return;
if (variant == _mode) {
_revision++;
_models = await _loadBundles(_catalog);
if (!active) {
if (!_active.remove(key)) return;
} else {
final keyActive = _active.contains(key);
final sameDsOthers = _active
.where((k) => k.datasetId == datasetId && k.variant != variant)
.toList();
if (keyActive && sameDsOthers.isEmpty) return; // 状态未变化
_active.removeAll(sameDsOthers);
_active.add(key);
}
await _saveActive();
_revision++;
_models = await _loadBundles(_catalog);
notifyListeners();
}
/// 自动更新:已下载/已激活的模型目录出现新版本时自动重下(保持原激活状态
/// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知
/// 自动更新:已下载的模型目录出现新版本时自动重下(不改变激活状态——
/// 激活只在用户显式下载/启用时发生,2026-09-03 会话制修订)
/// 使用中的模型原地更新则立即生效(重建推理 worker)。未下载的模型不自动拉取,
/// 避免无谓流量。下载进度经 downloadModel 通知。
/// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。
Future<void> autoUpdate() async {
if (_catalog.isEmpty) return;
final tracked = {..._downloaded, ..._active};
for (final item in _catalog) {
final key = (datasetId: item.datasetId, variant: item.variant);
if (!tracked.contains(key)) continue;
final wasActive = _active.contains(key);
if (!_downloaded.contains(key)) continue;
try {
// 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理),
// 不得向外抛未处理异步异常
if (await _isLocal(item)) continue;
final ok = await downloadModel(item);
// 原本未激活:下载完成自动激活后恢复原状态
if (ok && !wasActive) {
await setActive(item.datasetId, item.variant, false);
}
await downloadModel(item, autoActivate: false);
} catch (e) {
debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e');
}
}
}
Future<void> _saveActive() async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/active.json').writeAsString(jsonEncode({
'active': [
for (final k in _active) {'d': k.datasetId, 'v': k.variant}
]
}));
} catch (e) {
debugPrint('[ModelManager] 激活集持久化失败: $e');
}
}
/// 读取激活集。兼容旧版纯 int 列表(双档位前只有 s 档,int 一律归为 s)。
Future<void> _loadActive() async {
if (_activeLoaded) return;
_activeLoaded = true;
try {
final root = await _rootDir();
final f = File('${root.path}/active.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
for (final e in data['active'] as List? ?? const []) {
if (e is num) {
_active.add((datasetId: e.toInt(), variant: kVariantS));
} else if (e is Map) {
final v = e['v'] as String? ?? kVariantS;
final d = (e['d'] as num?)?.toInt();
if (d != null && v != kVariantS && v != kVariantN) continue;
if (d != null) _active.add((datasetId: d, variant: v));
}
}
} catch (e) {
debugPrint('[ModelManager] 激活集读取失败: $e');
}
}
Future<void> _saveMode() async {
try {
final root = await _rootDir();
@@ -527,11 +540,42 @@ class ModelManager extends ChangeNotifier {
}
}
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
Future<void> _loadCatalogCache() async {
try {
final root = await _rootDir();
final f = File('${root.path}/catalog.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final list = data['models'] as List? ?? const [];
if (list.isEmpty) return;
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[ModelManager] 目录缓存读取失败: $e');
}
}
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/catalog.json')
.writeAsString(jsonEncode({'models': rawModels}));
} catch (e) {
debugPrint('[ModelManager] 目录缓存保存失败: $e');
}
}
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
Future<List<ModelBundle>> _loadBundles(
List<ModelCatalogItem> catalog) async {
final bundles = <ModelBundle>[];
for (final item in catalog) {
if (item.variant != _mode) continue;
final key = (datasetId: item.datasetId, variant: item.variant);
if (!_active.contains(key)) continue;
try {
@@ -576,13 +620,24 @@ class ModelManager extends ChangeNotifier {
return Directory('${support.path}/models');
}
/// 档位目录:s 档 `models/<datasetId>/`legacy 无子目录,目录键 = 档位
/// 标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
/// 档位子路径(相对模型根目录s 档 `models/<datasetId>/`legacy 无子目录,
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
String _subPath(int datasetId, String variant) =>
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
/// 档位目录(不存在则创建)
Future<Directory> _modelDir(int datasetId, String variant) async {
final root = await _rootDir();
final sub = variant == kVariantS ? '' : '/$variant';
final dir = Directory('${root.path}/$datasetId$sub');
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
await dir.create(recursive: true);
return dir;
}
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
/// 新版本由 autoUpdate 自动补齐)
Future<bool> _hasFile(ModelCatalogItem item) async {
final root = await _rootDir();
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
.exists();
}
}
+7 -4
View File
@@ -6,7 +6,9 @@ import 'package:vibration/vibration.dart';
/// 提醒:同类目标 10s 内只提醒一次。
/// 震动/提示音默认开启,不提供关闭入口。
class Reminder {
final AudioPlayer _player = AudioPlayer();
// 惰性创建:AudioPlayer 构造即发起平台初始化(无插件环境下未处理错误会
// 泄漏为 unhandled async error),首响时才建
AudioPlayer? _player;
String? _lastAlertLabel;
int _lastAlertAt = 0;
@@ -24,11 +26,12 @@ class Reminder {
void _vibrate() => Vibration.vibrate(duration: 200);
Future<void> _playTone() async {
await _player.stop();
await _player.play(AssetSource('beep.wav'));
final p = _player ??= AudioPlayer();
await p.stop();
await p.play(AssetSource('beep.wav'));
}
void release() {
_player.dispose();
_player?.dispose();
}
}
-49
View File
@@ -1,49 +0,0 @@
import 'package:flutter/services.dart';
/// 安装进度事件(原生 PackageInstaller 会话回调经 EventChannel 回传)
class InstallEvent {
/// progress / finished / failed
final String event;
/// event=progress 时的安装进度 0-100
final int progress;
/// event=finished 时是否安装成功
final bool? success;
/// event=failed 时的错误描述
final String? error;
const InstallEvent({
required this.event,
this.progress = 0,
this.success,
this.error,
});
}
/// App 内安装 APK:原生侧 PackageInstaller 会话安装(InstallerChannel.kt
class ApkInstaller {
static const _method = MethodChannel('observer/installer');
static const _progress = EventChannel('observer/installer/progress');
/// 提交安装。返回 installing(已进入安装流程)/ permission_required
/// (未允许「安装未知应用」,原生侧已拉起系统设置页)。
static Future<String> install(String path) =>
_method.invokeMethod<String>('install', {'path': path}).then(
(v) => v ?? 'installing',
);
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
static Stream<InstallEvent> progress() {
return _progress.receiveBroadcastStream().map((e) {
final m = e as Map;
return InstallEvent(
event: m['event'] as String? ?? '',
progress: (m['progress'] as num?)?.toInt() ?? 0,
success: m['success'] as bool?,
error: m['error'] as String?,
);
});
}
}
+5 -6
View File
@@ -17,7 +17,7 @@ class AppUpdateInfo {
}
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
/// (无普通/强制之分)。
/// (无普通/强制之分)。2026-09-03 起更新走浏览器下载 APK 手动安装。
class UpdateChecker {
final String baseUrl;
final http.Client _client;
@@ -52,12 +52,11 @@ class UpdateChecker {
}
}
/// 是否需要更新:服务器版本高于已装版本与已确认接受版本」中的较大者。
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
/// 已更新完成再次启动也不会反复提示。
static bool needsUpdate(String server, String installed, String accepted) {
/// 是否需要更新:服务器版本高于已装版本2026-09-03 起更新改浏览器下载,
/// 安装结果由系统安装器完成,App 无法感知,故不再记录「已确认接受版本」)
static bool needsUpdate(String server, String installed) {
if (server.isEmpty || installed.isEmpty) return false;
return isNewer(server, installed) || isNewer(server, accepted);
return isNewer(server, installed);
}
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
+27 -201
View File
@@ -1,219 +1,53 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'installer.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
/// 进页自动在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),失败可手动重试,不再跳浏览器
/// 安装成功时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
/// 2026-09-03 改为浏览器下载:App 内 PackageInstaller 会话安装在小米等
/// ROM 上点系统确认框后失败(下载/写入正常但最终安装被拒),而系统浏览器
/// 下载 APK 后经通知栏走 ROM 自己的安装器可正常完成,故下载链接改由浏览器
/// 打开,不再 App 内下载/安装。仅点「立即更新」按钮时打开浏览器,不自动
/// 跳转;安装完成(versionName 追上服务器)后下次启动不再提示。
class UpdateScreen extends StatefulWidget {
final String version;
final String url;
final String notes;
final VoidCallback? onUpdateAccepted;
const UpdateScreen({
super.key,
required this.version,
required this.url,
this.notes = '',
this.onUpdateAccepted,
});
@override
State<UpdateScreen> createState() => _UpdateScreenState();
}
enum _Stage { idle, downloading, installing, finished, failed }
class _UpdateScreenState extends State<UpdateScreen> {
final http.Client _client = http.Client();
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
final File _apkPart =
File('${Directory.systemTemp.path}/observer-latest.apk.part');
_Stage _stage = _Stage.idle;
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
double? _progress;
String? _message;
StreamSubscription<InstallEvent>? _installSub;
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
Timer? _installTimer;
@override
void initState() {
super.initState();
// 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求)
WidgetsBinding.instance.addPostFrameCallback((_) => _launch());
}
@override
void dispose() {
_installTimer?.cancel();
_installSub?.cancel();
_client.close();
super.dispose();
}
Future<void> _launch() async {
if (_stage == _Stage.downloading ||
_stage == _Stage.installing ||
_stage == _Stage.finished) {
Future<void> _openBrowser() async {
final uri = Uri.tryParse(widget.url);
if (uri == null) {
setState(() => _message = '下载链接无效,请联系管理员');
return;
}
setState(() {
_stage = _Stage.idle;
_message = null;
});
if (!Platform.isAndroid) {
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
return;
}
// APK 已下载完成(下载是原子落盘,.part 改名后文件才存在)→ 跳过下载直接安装
if (_apkFile.existsSync()) {
await _install();
return;
}
await _download();
}
Future<void> _download() async {
setState(() {
_stage = _Stage.downloading;
_progress = 0;
});
try {
if (_apkPart.existsSync()) _apkPart.deleteSync();
// 下载无总时长上限(APK 几十 MB 慢网可能数分钟);连接/响应头与
// 数据流分别做 30s 停滞判定,避免断流黑洞永久卡死
final res = await _client
.send(http.Request('GET', Uri.parse(widget.url)))
.timeout(const Duration(seconds: 30));
if (res.statusCode != 200) {
throw HttpException('HTTP ${res.statusCode}');
}
final total = res.contentLength ?? -1;
final sink = _apkPart.openWrite();
var received = 0;
await for (final chunk
in res.stream.timeout(const Duration(seconds: 30))) {
sink.add(chunk);
received += chunk.length;
if (mounted && total > 0) {
setState(() => _progress = received / total * 100);
}
}
await sink.close();
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
_apkPart.renameSync(_apkFile.path);
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!mounted) return;
await _install();
setState(() {
_message = ok ? '已打开浏览器下载,完成后点击通知栏的安装提示即可更新' : '打开浏览器失败,请点「立即更新」重试';
});
} catch (_) {
if (_apkPart.existsSync()) _apkPart.deleteSync();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_progress = null;
_message = '下载失败,请检查网络后重试';
});
}
}
Future<void> _install() async {
setState(() {
_stage = _Stage.installing;
_progress = 0;
_message = null;
});
await _installSub?.cancel();
// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
_installTimer?.cancel();
_installTimer = Timer(const Duration(seconds: 120), () {
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装超时,请重试';
});
});
_installSub = ApkInstaller.progress().listen((e) {
if (!mounted) return;
switch (e.event) {
case 'progress':
setState(() => _progress = e.progress.toDouble());
break;
case 'finished':
_installTimer?.cancel();
final ok = e.success == true;
// 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试
if (ok) widget.onUpdateAccepted?.call();
setState(() {
_stage = ok ? _Stage.finished : _Stage.failed;
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
});
break;
case 'failed':
_installTimer?.cancel();
debugPrint('UpdateScreen: install failed: ${e.error}');
setState(() {
_stage = _Stage.failed;
_message = e.error ?? '安装失败,请重试';
});
break;
}
}, onError: (Object _) {
_installTimer?.cancel();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装失败,请重试';
});
});
final String result;
try {
result = await ApkInstaller.install(_apkFile.path);
} catch (_) {
// 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」
_installTimer?.cancel();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装启动失败,请重试';
});
return;
}
if (!mounted) return;
if (result == 'permission_required') {
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
_installTimer?.cancel();
setState(() {
_stage = _Stage.failed;
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
});
setState(() => _message = '打开浏览器失败,请点「立即更新」重试');
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final busy = _stage == _Stage.downloading || _stage == _Stage.installing;
final progressText = _stage == _Stage.downloading
? (_progress == null ? '下载中…' : '下载中 ${_progress!.round()}%')
: (_progress == null ? '安装中…' : '安装中 ${_progress!.round()}%');
return PopScope(
canPop: false,
child: Scaffold(
@@ -234,26 +68,22 @@ class _UpdateScreenState extends State<UpdateScreen> {
textAlign: TextAlign.center,
style: const TextStyle(height: 1.6)),
const SizedBox(height: 24),
if (busy) ...[
LinearProgressIndicator(
value: _progress == null ? null : _progress! / 100,
minHeight: 6,
),
const SizedBox(height: 12),
Text(progressText),
const SizedBox(height: 24),
],
FilledButton.icon(
onPressed: busy || _stage == _Stage.finished
? null
: _launch,
icon: const Icon(Icons.download),
label: Text(_stage == _Stage.finished ? '已完成' : '立即更新'),
onPressed: _openBrowser,
icon: const Icon(Icons.open_in_browser),
label: const Text('立即更新'),
style: FilledButton.styleFrom(
minimumSize: const Size(200, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
Text(
'新版本需在浏览器下载 APK 后手动安装\n安装完成后请重新打开应用',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
),
if (_message != null) ...[
const SizedBox(height: 12),
Text(
@@ -261,16 +91,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
textAlign: TextAlign.center,
style: TextStyle(
height: 1.5,
color: _stage == _Stage.failed
? theme.colorScheme.error
: Colors.green,
color: _message!.startsWith('已打开')
? Colors.green
: theme.colorScheme.error,
),
),
],
const SizedBox(height: 12),
Text('不更新将无法继续使用',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
],
),
),