This commit is contained in:
2026-09-01 15:18:05 +08:00
parent dcde05d67c
commit abccda0bd6
19 changed files with 381 additions and 160 deletions
+96 -76
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../config/app_version.dart';
import 'auth_view_model.dart';
/// 登录/注册页:手机号 + 密码;注册成功后自动登录。
@@ -51,85 +52,104 @@ class _AuthScreenState extends State<AuthScreen> {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.pets, size: 64, color: Colors.green),
const SizedBox(height: 12),
const Text(
'视野',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'动物实时识别',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 32),
TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: '手机号',
prefixIcon: Icon(Icons.phone_android),
border: OutlineInputBorder(),
counterText: '',
child: Column(
children: [
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.pets, size: 64, color: Colors.green),
const SizedBox(height: 12),
const Text(
'视野',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'动物实时识别',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 32),
TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: '手机号',
prefixIcon: Icon(Icons.phone_android),
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordCtrl,
obscureText: _obscure,
maxLength: 64,
decoration: InputDecoration(
labelText: '密码',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
counterText: '',
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: vm.loading ? null : _submit,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: vm.loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isLogin ? '登录' : '注册并登录'),
),
const SizedBox(height: 12),
TextButton(
onPressed: vm.loading ? null : vm.switchMode,
child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'),
),
if (vm.error != null) ...[
const SizedBox(height: 12),
Text(
vm.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
],
],
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordCtrl,
obscureText: _obscure,
maxLength: 64,
decoration: InputDecoration(
labelText: '密码',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
counterText: '',
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: vm.loading ? null : _submit,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: vm.loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isLogin ? '登录' : '注册并登录'),
),
const SizedBox(height: 12),
TextButton(
onPressed: vm.loading ? null : vm.switchMode,
child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'),
),
if (vm.error != null) ...[
const SizedBox(height: 12),
Text(
vm.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
],
],
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FutureBuilder<String>(
future: appVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
),
],
),
),
);
+1 -1
View File
@@ -91,7 +91,7 @@ class _CameraScreenState extends State<CameraScreen> {
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
'环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
'不同模型置信度分布不同,识别不到目标时可适当调低阈值',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 16),
@@ -105,9 +105,10 @@ class CameraViewModel extends ChangeNotifier {
visible.add(r.copyWith(confirmed: t.confirmed));
}
// 提醒:仅新确认的环颈雉鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内)
// 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡确认瞬间触发一次,10s 同类冷却在 Reminder 内)
for (final t in _tracks.values) {
if (t.label != 'pheasant' || !t.confirmed || t.reminded) continue;
final isSuspect = t.result.classId > 0 || t.label == 'suspect';
if (isSuspect || !t.confirmed || t.reminded) continue;
final age = now - t.firstSeenMs;
if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
now - t.lastSeenMs <= 300) {
@@ -184,7 +185,7 @@ class CameraViewModel extends ChangeNotifier {
/// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions) {
if (t.label == 'suspect') return true;
if (t.result.classId > 0 || t.label == 'suspect') return true;
if (t.confirmed) return true;
return t.result.score >= highConf ||
_hasActivity(t.result, motionRegions, noveltyRegions);
@@ -203,7 +204,7 @@ class CameraViewModel extends ChangeNotifier {
if (w <= 0 || h <= 0) return false;
final aspect = w / h;
if (aspect < 0.3 || aspect > 3.0) return false;
if (r.label == 'suspect') return h >= 0.01 && h <= 0.5;
if (r.classId > 0 || r.label == 'suspect') return h >= 0.01 && h <= 0.5;
return h >= 0.01 && h <= 0.3;
}
+10 -10
View File
@@ -44,14 +44,11 @@ class _OverlayPainter extends CustomPainter {
_OverlayPainter(this.results, this.rotation, this.imageWidthPx,
this.imageHeightPx);
static const _colors = {
'pheasant': Color(0xFFE53935),
'suspect': Color(0xFFFDD835),
};
static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'};
/// 参考体型(米):环颈雉鸡身高 / 植被高度(参照旧 DistanceEstimator
static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50};
/// 参考体型(米):目标物种(class 0,如环颈雉鸡身高 / suspect 植被高度
static const double _refSizeSpeciesM = 0.45;
static const double _refSizeSuspectM = 0.50;
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px5.1mm / 5.30mm 传感器),
/// 单目误差 ±30%,仅作参考
@@ -72,8 +69,10 @@ class _OverlayPainter extends CustomPainter {
size.width,
size.height,
);
final color = _colors[r.label] ?? Colors.white;
final isSuspect = r.label == 'suspect';
// 颜色按类别索引而非 label 文本:模型类别名可能为中文(环颈雉)或
// 随数据集变化,class 0 恒为目标物种(红),其余类恒为 suspect(黄)
final isSuspect = r.classId > 0 || r.label == 'suspect';
final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935);
final confirmed = r.confirmed && !isSuspect;
final paint = Paint()
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
@@ -113,8 +112,9 @@ class _OverlayPainter extends CustomPainter {
}
String _distanceLabel(DetectionResult r) {
final refH = _refSizeM[r.label];
if (refH == null) return '';
final refH = (r.classId > 0 || r.label == 'suspect')
? _refSizeSuspectM
: _refSizeSpeciesM;
final hPx = r.height * imageHeightPx;
if (hPx < 8) return '';
final m = focalPx * refH / hPx;
+11
View File
@@ -0,0 +1,11 @@
import 'package:package_info_plus/package_info_plus.dart';
String? _cached;
/// 当前 App 版本号(一次获取后缓存,用于登录页/首页底部展示)
Future<String> appVersion() async {
if (_cached != null) return _cached!;
final info = await PackageInfo.fromPlatform();
_cached = info.version;
return _cached!;
}
@@ -9,6 +9,9 @@ class DetectionResult {
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
final bool confirmed;
/// 类别索引:0 = 目标物种(红色框),>0 = suspect(黄色框);-1 = 未知
final int classId;
/// 产出该框的模型(数据集 id 与名称;无来源为 -1/空)
final int modelId;
final String modelName;
@@ -21,6 +24,7 @@ class DetectionResult {
required this.right,
required this.bottom,
this.confirmed = true,
this.classId = -1,
this.modelId = -1,
this.modelName = '',
});
@@ -46,6 +50,7 @@ class DetectionResult {
right: right ?? this.right,
bottom: bottom ?? this.bottom,
confirmed: confirmed ?? this.confirmed,
classId: classId,
modelId: modelId,
modelName: modelName,
);
@@ -148,6 +148,7 @@ class DetectorWorker {
bottom: v[5] as double,
modelId: v.length > 6 ? (v[6] as num).toInt() : -1,
modelName: v.length > 7 ? v[7] as String : '',
classId: v.length > 8 ? (v[8] as num).toInt() : -1,
);
}).toList();
final motion = (list[5] as List)
@@ -421,6 +422,7 @@ Future<void> _workerMain(SendPort mainPort) async {
r.bottom,
r.modelId,
r.modelName,
r.classId,
])
.toList(),
motionRegions
@@ -479,6 +479,7 @@ class TfliteDetector {
top: (cy - h / 2).clamp(0.0, 1.0),
right: (cx + w / 2).clamp(0.0, 1.0),
bottom: (cy + h / 2).clamp(0.0, 1.0),
classId: bestCls,
modelId: modelId,
modelName: modelName,
));
+2 -1
View File
@@ -39,7 +39,8 @@ class VisualPrior {
if (results.isEmpty || width <= 0 || height <= 0) return results;
final kept = <DetectionResult>[];
for (final r in results) {
final lowConfPheasant = r.label == 'pheasant' && r.score < maxScore;
final lowConfPheasant =
(r.classId == 0 || r.label == 'pheasant') && r.score < maxScore;
if (lowConfPheasant &&
_reject(r, planes, strides, width, height, isBgra, rgbaOrder)) {
continue;
+71 -51
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../auth/session_store.dart';
import '../config/app_version.dart';
import 'home_view_model.dart';
/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口
@@ -104,61 +105,80 @@ class _HomeScreenState extends State<HomeScreen> {
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
_StatusCard(
licenseText: statusText,
active: active,
),
const SizedBox(height: 40),
SizedBox(
height: 88,
child: FilledButton.icon(
onPressed: vm.verifying ? null : _onSearch,
style: FilledButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(44),
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
_StatusCard(
licenseText: statusText,
active: active,
),
),
icon: vm.verifying
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.visibility, size: 32),
label: Text(
vm.verifying ? '正在校验授权…' : '打开视野',
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w600),
const SizedBox(height: 40),
SizedBox(
height: 88,
child: FilledButton.icon(
onPressed: vm.verifying ? null : _onSearch,
style: FilledButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(44),
),
),
icon: vm.verifying
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.visibility, size: 32),
label: Text(
vm.verifying ? '正在校验授权…' : '打开视野',
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 16),
SizedBox(
height: 52,
child: OutlinedButton.icon(
onPressed: _onRecharge,
icon: const Icon(Icons.payment),
label: const Text('充值', style: TextStyle(fontSize: 16)),
),
),
if (vm.error != null && vm.license != null) ...[
const SizedBox(height: 16),
Text(
vm.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
],
],
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FutureBuilder<String>(
future: appVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
const SizedBox(height: 16),
SizedBox(
height: 52,
child: OutlinedButton.icon(
onPressed: _onRecharge,
icon: const Icon(Icons.payment),
label: const Text('充值', style: TextStyle(fontSize: 16)),
),
),
if (vm.error != null && vm.license != null) ...[
const SizedBox(height: 16),
Text(
vm.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
],
],
),
),
],
),
),
);
+31 -1
View File
@@ -46,8 +46,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
String? _message;
StreamSubscription<InstallEvent>? _installSub;
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
Timer? _installTimer;
@override
void dispose() {
_installTimer?.cancel();
_installSub?.cancel();
_client.close();
super.dispose();
@@ -125,6 +129,15 @@ class _UpdateScreenState extends State<UpdateScreen> {
_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) {
@@ -132,6 +145,7 @@ class _UpdateScreenState extends State<UpdateScreen> {
setState(() => _progress = e.progress.toDouble());
break;
case 'finished':
_installTimer?.cancel();
final ok = e.success == true;
setState(() {
_stage = ok ? _Stage.finished : _Stage.failed;
@@ -139,6 +153,8 @@ class _UpdateScreenState extends State<UpdateScreen> {
});
break;
case 'failed':
_installTimer?.cancel();
debugPrint('UpdateScreen: install failed: ${e.error}');
setState(() {
_stage = _Stage.failed;
_message = e.error ?? '安装失败,请重试';
@@ -146,16 +162,30 @@ class _UpdateScreenState extends State<UpdateScreen> {
break;
}
}, onError: (Object _) {
_installTimer?.cancel();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装失败,请重试';
});
});
final result = await ApkInstaller.install(_apkFile.path);
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 已缓存,无需重新下载)';