1
This commit is contained in:
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
|
||||
Navigator.of(context).pushReplacementNamed('/terms');
|
||||
return;
|
||||
}
|
||||
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
|
||||
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
|
||||
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
|
||||
// 更新完成后再次启动不反复提示。
|
||||
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装
|
||||
// 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装
|
||||
// (2026-09-03:App 内安装在小米等 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 双平台即时生效;重启相机会重新
|
||||
// initialize(iOS ~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('授权相机')),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 替换推理 worker(null = 停识别仅预览)。旧 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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 兜底
|
||||
|
||||
@@ -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.json,meta 记录 {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 无 charset,http 包默认按 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 无 charset,http 包默认按 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user