1
This commit is contained in:
@@ -3,6 +3,17 @@
|
||||
动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见
|
||||
[`docs/PaymentApi.md`](docs/PaymentApi.md)。
|
||||
|
||||
## Android 打包
|
||||
|
||||
```bash
|
||||
./build_apk.sh # 用当前 pubspec 版本构建,产物 build/app/outputs/flutter-apk/observer-x.y.z.apk
|
||||
./build_apk.sh --bump # 自动递增 patch+1、build+1 后构建
|
||||
./build_apk.sh 1.0.7 # 用指定版本(versionName)构建,build+1
|
||||
```
|
||||
|
||||
产物命名 `observer-x.y.z.apk`(管理端上传版本号从文件名识别);构建的
|
||||
`app-release.apk` 中间产物由脚本清理,只保留规范命名文件。
|
||||
|
||||
## iOS 真机部署(iPhone)
|
||||
|
||||
### 构建与安装
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# 打包 release APK,产物命名 observer-x.y.z.apk(管理端上传版本号从文件名识别)。
|
||||
# 用法:
|
||||
# build_apk.sh 用当前 pubspec 版本构建
|
||||
# build_apk.sh --bump 自动递增 patch+1、build+1 后构建
|
||||
# build_apk.sh 1.0.7 用指定版本(versionName)构建, build+1
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
CUR=$(grep -m1 '^version:' pubspec.yaml | awk '{print $2}')
|
||||
VER=${CUR%+*}
|
||||
BLD=${CUR#*+}
|
||||
|
||||
case "${1:-}" in
|
||||
"")
|
||||
;;
|
||||
--bump)
|
||||
VER=$(awk -F. '{print $1"."$2"."($3+1)}' <<<"$VER")
|
||||
BLD=$((BLD + 1))
|
||||
;;
|
||||
*)
|
||||
VER="$1"
|
||||
BLD=$((BLD + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW="$VER+$BLD"
|
||||
if [ "$NEW" != "$CUR" ]; then
|
||||
sed -i '' "s/^version: .*/version: $NEW/" pubspec.yaml
|
||||
echo "版本号: $CUR -> $NEW"
|
||||
fi
|
||||
|
||||
flutter build apk --release
|
||||
|
||||
APK="build/app/outputs/flutter-apk/observer-${VER}.apk"
|
||||
mv -f build/app/outputs/flutter-apk/app-release.apk "$APK"
|
||||
rm -f build/app/outputs/apk/release/app-release.apk
|
||||
echo "APK: $APK"
|
||||
@@ -12,6 +12,7 @@ import 'app_camera_controller.dart';
|
||||
import 'camera_view_model.dart';
|
||||
import 'detection_overlay.dart';
|
||||
import 'frame_analyzer.dart';
|
||||
import 'model_catalog_section.dart';
|
||||
|
||||
/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头)
|
||||
class CameraScreen extends StatefulWidget {
|
||||
@@ -40,50 +41,61 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
void _openSettings() {
|
||||
final vm = _viewModel;
|
||||
if (vm == null) return;
|
||||
ModelManager.instance.refresh();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black87,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheetState) => Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('识别设置',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('置信度阈值',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.05,
|
||||
max: 0.50,
|
||||
divisions: 45,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
|
||||
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
builder: (ctx, setSheetState) => SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('识别设置',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('置信度阈值',
|
||||
style:
|
||||
TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.05,
|
||||
max: 0.50,
|
||||
divisions: 45,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
|
||||
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Colors.white12),
|
||||
const SizedBox(height: 8),
|
||||
ModelCatalogSection(manager: ModelManager.instance),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/model_manager.dart';
|
||||
|
||||
/// 设置弹层「模型清单」区块:2 列封面缩略图网格。
|
||||
/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活;
|
||||
/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。
|
||||
class ModelCatalogSection extends StatelessWidget {
|
||||
final ModelManager manager;
|
||||
|
||||
const ModelCatalogSection({super.key, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: manager,
|
||||
builder: (context, _) {
|
||||
final items = manager.catalog;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (manager.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(manager.error!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orange, fontSize: 12)),
|
||||
),
|
||||
if (items.isEmpty)
|
||||
const Text('暂无已发布模型',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13))
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_ModelCard(item: items[i], manager: manager),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelCard extends StatelessWidget {
|
||||
final ModelCatalogItem item;
|
||||
final ModelManager manager;
|
||||
|
||||
const _ModelCard({required this.item, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = manager.isActive(item.datasetId);
|
||||
final downloaded = manager.isDownloaded(item.datasetId);
|
||||
final progress = manager.progressOf(item.datasetId);
|
||||
final error = manager.errorOf(item.datasetId);
|
||||
|
||||
final thumb = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Image.network(
|
||||
'${manager.baseUrl}${item.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)))),
|
||||
errorBuilder: (context, error, stack) => Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(Icons.image_not_supported_outlined,
|
||||
color: Colors.white38),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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),
|
||||
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) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent)),
|
||||
child: const Text('已使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else if (downloaded) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, 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: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: Center(child: thumb)),
|
||||
const SizedBox(height: 6),
|
||||
Text(item.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)),
|
||||
const SizedBox(height: 6),
|
||||
action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ class ModelManager extends ChangeNotifier {
|
||||
final Set<int> _downloadedIds = {};
|
||||
final Map<int, double> _progress = {};
|
||||
final Map<int, String> _errors = {};
|
||||
final Set<int> _cancelRequested = {};
|
||||
bool _activeLoaded = false;
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
@@ -104,6 +105,12 @@ class ModelManager extends ChangeNotifier {
|
||||
/// 下载失败原因(失败后可重试)
|
||||
String? errorOf(int datasetId) => _errors[datasetId];
|
||||
|
||||
/// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。
|
||||
/// 取消不记错误,可再次下载。
|
||||
void cancelDownload(int datasetId) {
|
||||
_cancelRequested.add(datasetId);
|
||||
}
|
||||
|
||||
ModelManager._({String? baseUrl, http.Client? client})
|
||||
: this(baseUrl: baseUrl, client: client);
|
||||
|
||||
@@ -209,11 +216,13 @@ class ModelManager extends ChangeNotifier {
|
||||
// 并发保护:同一数据集已有进行中的下载则直接短路(预置 0 先占位,
|
||||
// 使 onProgress 首次回调前的双击/refresh 交错也被 containsKey 拦下)
|
||||
if (_progress.containsKey(item.datasetId)) return false;
|
||||
_cancelRequested.remove(item.datasetId);
|
||||
_progress[item.datasetId] = 0;
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
try {
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
if (_cancelRequested.contains(item.datasetId)) break;
|
||||
final ok = await _downloadAndVerify(item, dir, file,
|
||||
onProgress: (r, t) {
|
||||
_progress[item.datasetId] = t == 0 ? 0 : r / t;
|
||||
@@ -227,17 +236,39 @@ class ModelManager extends ChangeNotifier {
|
||||
await setActive(item.datasetId, true);
|
||||
return true;
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) break;
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
// 用户取消:清理残留,不记错误
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
_progress.remove(item.datasetId);
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||||
return false;
|
||||
}
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载失败,请重试';
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
_progress.remove(item.datasetId);
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||||
return false;
|
||||
}
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载异常:$e';
|
||||
notifyListeners();
|
||||
@@ -262,10 +293,15 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
final total = res.contentLength ?? item.sizeBytes;
|
||||
await for (final chunk in res.stream) {
|
||||
if (_cancelRequested.contains(item.datasetId)) break; // 用户取消
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, total);
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
await sink.close();
|
||||
return false;
|
||||
}
|
||||
await sink.close();
|
||||
final bytes = await part.readAsBytes();
|
||||
final hex = sha256.convert(bytes).toString();
|
||||
|
||||
@@ -2,7 +2,7 @@ name: observer
|
||||
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.6+7
|
||||
version: 1.0.7+8
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:observer/camera/model_catalog_section.dart';
|
||||
import 'package:observer/models/model_manager.dart';
|
||||
|
||||
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
|
||||
Stream<List<int>> _delayedChunks() async* {
|
||||
yield [1, 2, 3, 4];
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
yield [5, 6, 7, 8];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _item() => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '环颈雉鸡数据集',
|
||||
'version': 'v1.0.0',
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': _shaHex(_modelBytes),
|
||||
'downloadUrl': '/download/models/7/latest.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('catalog-section-test');
|
||||
});
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
ModelManager manager() => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
})),
|
||||
200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
return http.Response.bytes(_modelBytes, 200);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
|
||||
Future<void> pumpSection(WidgetTester tester, ModelManager m) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ModelCatalogSection(manager: m))));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async {
|
||||
final m = manager();
|
||||
await m.refresh();
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('环颈雉鸡数据集'), findsOneWidget);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isTrue);
|
||||
expect(m.isActive(7), isTrue);
|
||||
expect(find.text('已使用'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('已激活:再次点击取消使用', (tester) async {
|
||||
final m = manager();
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first);
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('已使用'), findsOneWidget);
|
||||
await tester.tap(find.text('已使用'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('目录为空:显示暂无已发布模型', (tester) async {
|
||||
final m = ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((_) async => http.Response.bytes(
|
||||
utf8.encode(jsonEncode(
|
||||
{'code': 0, 'message': 'ok', 'data': {'models': []}})),
|
||||
200)),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
await m.refresh();
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('暂无已发布模型'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async {
|
||||
final m = ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
})),
|
||||
200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
return http.Response.fromStream(http.StreamedResponse(
|
||||
_delayedChunks(), 200,
|
||||
contentLength: _modelBytes.length));
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
await m.refresh();
|
||||
await pumpSection(tester, m);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tester.pump(); // 首个分块到达,进度条与取消按钮出现
|
||||
expect(find.text('取消'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isFalse);
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
|
||||
Stream<List<int>> _delayedChunks() async* {
|
||||
yield [1, 2, 3, 4];
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
yield [5, 6, 7, 8];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
@@ -194,4 +201,36 @@ void main() {
|
||||
expect(broken.ready, isFalse);
|
||||
expect(broken.error, isNotNull);
|
||||
});
|
||||
|
||||
test('取消下载:中断后恢复未下载、不记错误', () async {
|
||||
final m = manager(MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode(_catalog([_item()]))), 200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
downloadHits++;
|
||||
return http.Response.fromStream(http.StreamedResponse(
|
||||
_delayedChunks(), 200,
|
||||
contentLength: _modelBytes.length));
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}));
|
||||
await m.refresh();
|
||||
|
||||
final fut = m.downloadModel(m.catalog.first);
|
||||
// 首个分块到达后取消(模拟用户在下载中点取消)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
m.cancelDownload(7);
|
||||
final ok = await fut;
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(downloadHits, 1);
|
||||
expect(m.isDownloaded(7), isFalse);
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(m.progressOf(7), isNull);
|
||||
expect(m.errorOf(7), isNull, reason: '取消不记错误');
|
||||
expect(await File('${root.path}/7/model.tflite.part').exists(), isFalse,
|
||||
reason: '取消后 .part 残留应被清理');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:observer/camera/model_catalog_section.dart';
|
||||
import 'package:observer/models/model_manager.dart';
|
||||
|
||||
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
Map<String, dynamic> _item() => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '环颈雉鸡数据集',
|
||||
'version': 'v1.0.0',
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': _shaHex(_modelBytes),
|
||||
'downloadUrl': '/download/models/7/latest.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('catalog-diag');
|
||||
});
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
ModelManager manager() => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
})),
|
||||
200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
return http.Response.bytes(_modelBytes, 200);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
|
||||
testWidgets('diag step marker', (tester) async {
|
||||
print('=== BEFORE manager()');
|
||||
final m = manager();
|
||||
print('=== BEFORE refresh()');
|
||||
await m.refresh();
|
||||
print('=== AFTER refresh() ready=${m.ready} catalog=${m.catalog.length}');
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(body: ModelCatalogSection(manager: m))));
|
||||
print('=== AFTER pumpWidget');
|
||||
for (var i = 0; i < 15; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final spinners = find.byType(CircularProgressIndicator).evaluate().length;
|
||||
final icons = find.byIcon(Icons.image_not_supported_outlined).evaluate().length;
|
||||
print('pump $i scheduled=${tester.binding.hasScheduledFrame} '
|
||||
'spinner=$spinners errIcon=$icons');
|
||||
}
|
||||
print('=== bounded pumpAndSettle attempt');
|
||||
try {
|
||||
await tester.pumpAndSettle(
|
||||
const Duration(milliseconds: 100),
|
||||
EnginePhase.sendSemanticsUpdate,
|
||||
const Duration(seconds: 5));
|
||||
print('=== pumpAndSettle OK');
|
||||
} catch (e) {
|
||||
print('=== pumpAndSettle TIMEOUT: $e');
|
||||
}
|
||||
print('=== DONE');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user