迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)

This commit is contained in:
2026-08-24 12:35:24 +08:00
parent d056f01965
commit 961523d94c
218 changed files with 13391 additions and 3232 deletions
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'auth/auth_screen.dart';
import 'auth/session_store.dart';
import 'camera/camera_screen.dart';
import 'container.dart';
import 'home/home_screen.dart';
import 'payment/paywall_screen.dart';
class ObserverApp extends StatelessWidget {
final AppContainer container;
const ObserverApp({super.key, required this.container});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: container),
Provider.value(value: container.sessionStore),
ChangeNotifierProvider.value(value: container.authViewModel),
ChangeNotifierProvider.value(value: container.homeViewModel),
ChangeNotifierProvider.value(value: container.paywallViewModel),
],
child: MaterialApp(
title: '视野',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
),
initialRoute: '/gate',
routes: {
'/gate': (_) => const StartupGate(),
'/login': (_) => const AuthScreen(),
'/home': (_) => const HomeScreen(),
'/paywall': (_) => const PaywallScreen(),
'/camera': (_) => const CameraScreen(),
},
),
);
}
}
/// 启动门卫:无登录 token → 登录页;已登录 → 主界面(到期状态由主界面展示)
class StartupGate extends StatefulWidget {
const StartupGate({super.key});
@override
State<StartupGate> createState() => _StartupGateState();
}
class _StartupGateState extends State<StartupGate> {
@override
void initState() {
super.initState();
_check();
}
Future<void> _check() async {
final session = context.read<SessionStore>();
final token = await session.readToken();
if (!mounted) return;
Navigator.of(context)
.pushReplacementNamed(token == null ? '/login' : '/home');
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'auth_view_model.dart';
/// 登录/注册页:手机号 + 密码;注册成功后自动登录。
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _phoneCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _obscure = true;
@override
void dispose() {
_phoneCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
static final _phoneRe = RegExp(r'^1[3-9]\d{9}$');
Future<void> _submit() async {
final vm = context.read<AuthViewModel>();
final phone = _phoneCtrl.text.trim();
final password = _passwordCtrl.text;
if (!_phoneRe.hasMatch(phone)) {
vm.showError('请输入正确的 11 位手机号');
return;
}
if (password.length < 6) {
vm.showError('密码至少 6 位');
return;
}
final ok = await vm.submit(phone: phone, password: password);
if (ok && mounted) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
@override
Widget build(BuildContext context) {
final vm = context.watch<AuthViewModel>();
final isLogin = vm.mode == AuthMode.login;
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: '',
),
),
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),
),
],
],
),
),
),
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import '../payment/order_api.dart';
import 'session_store.dart';
enum AuthMode { login, register }
class AuthViewModel extends ChangeNotifier {
final OrderApi orderApi;
final SessionStore sessionStore;
AuthMode _mode = AuthMode.login;
bool _loading = false;
String? _error;
AuthMode get mode => _mode;
bool get loading => _loading;
String? get error => _error;
AuthViewModel({required this.orderApi, required this.sessionStore});
void switchMode() {
_mode = _mode == AuthMode.login ? AuthMode.register : AuthMode.login;
_error = null;
notifyListeners();
}
/// 本地输入校验失败提示(不进网络请求)
void showError(String message) {
_error = message;
notifyListeners();
}
/// 登录或注册;成功返回 true(调用方负责跳转主界面)
Future<bool> submit({required String phone, required String password}) async {
_loading = true;
_error = null;
notifyListeners();
try {
if (_mode == AuthMode.login) {
final token = await orderApi.login(phone: phone, password: password);
await sessionStore.save(phone, token);
} else {
await orderApi.register(phone: phone, password: password);
final token = await orderApi.login(phone: phone, password: password);
await sessionStore.save(phone, token);
}
return true;
} on OrderApiException catch (e) {
_error = e.message;
return false;
} finally {
_loading = false;
notifyListeners();
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static String? _cachedToken;
static String? _cachedPhone;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
return _cachedToken = await _storage.read(key: _tokenKey);
}
Future<String?> readPhone() async {
if (_cachedPhone != null) return _cachedPhone;
return _cachedPhone = await _storage.read(key: _phoneKey);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
await _storage.write(key: _phoneKey, value: phone);
await _storage.write(key: _tokenKey, value: token);
}
Future<void> clear() async {
_cachedPhone = null;
_cachedToken = null;
await _storage.delete(key: _phoneKey);
await _storage.delete(key: _tokenKey);
}
}
@@ -0,0 +1,92 @@
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'frame_analyzer.dart';
/// camera 插件封装:后摄图像流(对应 Kotlin CameraController)。
class AppCameraController {
final List<CameraDescription> cameras;
CameraController? controller;
/// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
int streamCallbacks = 0;
AppCameraController._(this.cameras);
static Future<AppCameraController?> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return null;
return AppCameraController._(cameras);
}
bool get isInitialized => controller?.value.isInitialized ?? false;
CameraController get currentController =>
controller ?? (throw StateError('camera not initialized'));
/// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。
/// 与 CameraX rotationDegrees 同公式;预览本身由平台旋转,检测框 overlay
/// 用同一角度映射即可对齐。
int get rotationDegrees {
final c = controller;
if (c == null) return 0;
final deviceDegrees = switch (c.value.deviceOrientation) {
DeviceOrientation.portraitUp => 0,
DeviceOrientation.landscapeLeft => 90,
DeviceOrientation.portraitDown => 180,
DeviceOrientation.landscapeRight => 270,
};
final sensor = c.description.sensorOrientation;
final isFront =
c.description.lensDirection == CameraLensDirection.front;
final degrees = (isFront ? sensor + deviceDegrees : sensor - deviceDegrees) % 360;
return degrees < 0 ? degrees + 360 : degrees;
}
Future<void> start(FrameAnalyzer analyzer) async {
await stop();
final desc = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first);
// iOS 用默认 bgra8888420v 在部分 iOS 版本上视频输出静默不送帧),
// Android 用 yuv420 多平面。
final fmt = defaultTargetPlatform == TargetPlatform.iOS
? ImageFormatGroup.bgra8888
: ImageFormatGroup.yuv420;
final c = CameraController(desc, ResolutionPreset.high,
enableAudio: false, imageFormatGroup: fmt);
controller = c;
await c.initialize();
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
analyzer.reset();
analyzer.worker?.reset();
debugPrint('[camera] initialized, starting image stream');
try {
await c.startImageStream((image) {
streamCallbacks++;
try {
analyzer.analyze(image, rotationDegrees);
} catch (e, st) {
debugPrint('[camera] analyze error: $e\n$st');
analyzer.recordStreamError('analyze: $e');
}
});
debugPrint('[camera] startImageStream ok');
} catch (e, st) {
debugPrint('[camera] startImageStream FAILED: $e\n$st');
analyzer.recordStreamError('startImageStream: $e');
rethrow;
}
}
Future<void> stop() async {
final c = controller;
if (c == null) return;
controller = null;
try {
await c.stopImageStream();
} catch (_) {}
await c.dispose();
}
}
+389
View File
@@ -0,0 +1,389 @@
import 'dart:ui' show PlatformDispatcher;
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../detection/detector_worker.dart';
import '../reminder/reminder.dart';
import 'app_camera_controller.dart';
import 'camera_view_model.dart';
import 'detection_overlay.dart';
import 'frame_analyzer.dart';
/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头)
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends State<CameraScreen> {
CameraViewModel? _viewModel;
FrameAnalyzer? _analyzer;
AppCameraController? _cameraController;
bool _initFailed = false;
bool _permissionGranted = false;
String? _globalError;
String? _initError;
@override
void initState() {
super.initState();
final oldPlatform = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError = 'Platform: $error');
return oldPlatform?.call(error, stack) ?? false;
};
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
WakelockPlus.enable();
}
Future<void> _init() async {
final granted = await Permission.camera.request().isGranted;
if (!mounted) return;
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型加载/推理在后台 isolate,不阻塞 UIworker 为 null 时仅预览并提示
final worker = await DetectorWorker.create();
final viewModel = CameraViewModel(reminder: Reminder());
viewModel.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel);
if (!mounted) {
analyzer.dispose();
viewModel.dispose();
return;
}
setState(() {
_viewModel = viewModel;
_analyzer = analyzer;
});
await _startCamera();
}
Future<void> _startCamera() async {
final analyzer = _analyzer;
if (analyzer == null) return;
try {
final controller = await AppCameraController.create();
if (controller == null) {
setState(() {
_initFailed = true;
_initError = '未找到可用摄像头';
});
return;
}
await controller.start(analyzer);
if (!mounted) {
controller.stop();
return;
}
setState(() {
_cameraController = controller;
_initFailed = false;
_initError = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_initFailed = true;
_initError = '$e';
});
}
}
@override
void dispose() {
WakelockPlus.disable();
_cameraController?.stop();
_analyzer?.dispose();
_viewModel?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vm = _viewModel;
final camera = _cameraController;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
if (!_permissionGranted)
_PermissionGuide(onRequest: () => _init())
else if (vm != null && (camera?.isInitialized ?? false))
// 预览 + 检测框同几何:overlay 作为 CameraPreview 的 child
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
ListenableBuilder(
listenable: vm,
builder: (context, _) => _ZoomablePreview(
controller: camera!.currentController,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
overlay: DetectionOverlay(
results: vm.state.results,
// iOS 纹理不旋转显示(_wrapInRotatedBox 仅 Android),
// 显示方向 = buffer 原样 = 检测方向,旋转必须为 0;
// Android 纹理被 RotatedBox 旋转,需用插件报告的 rotation。
rotation: defaultTargetPlatform == TargetPlatform.iOS
? 0
: vm.state.rotation,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
),
),
)
else if (camera?.isInitialized ?? false)
_ZoomablePreview(controller: camera!.currentController)
else
const Center(
child: Text('相机启动中…', style: TextStyle(color: Colors.white70)),
),
// 帧级动态层(横幅/诊断行)单独订阅 viewModel,避免整屏重建
if (vm != null)
ListenableBuilder(
listenable: vm,
builder: (context, _) => _buildDiagnosticsLayer(camera),
),
if (_initFailed)
Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('相机初始化失败', style: TextStyle(color: Colors.white)),
if (_initError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_initError!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent, fontSize: 11),
),
),
TextButton(
onPressed: () {
setState(() => _initFailed = false);
_startCamera();
},
child: const Text('重试'),
),
],
),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 0,
right: 0,
child: _CameraTopBar(
onClose: () => Navigator.of(context).pop(),
),
),
],
),
);
}
Widget _buildDiagnosticsLayer(AppCameraController? camera) {
final vm = _viewModel!;
return Stack(
fit: StackFit.expand,
children: [
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
if (!vm.state.modelReady)
Positioned(
left: 16,
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'识别模型加载失败:${DetectorWorker.lastLoadError ?? '未知原因'}\n最后步骤:${DetectorWorker.lastLog ?? '-'}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.orange, fontSize: 14),
),
),
),
Positioned(
left: 8,
right: 8,
bottom: MediaQuery.of(context).padding.bottom + 8,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'模型:${vm.state.modelReady ? '已加载' : '未加载'} 帧:${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} 旋:${vm.state.rotation}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (camera != null)
Text(
'streaming:${camera.currentController.value.isStreamingImages} '
'camErr:${camera.currentController.value.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 与纹理同几何(CameraPreview child
class _ZoomablePreview extends StatefulWidget {
final CameraController controller;
/// 检测框 overlay(随帧更新,作为 CameraPreview 的 child 与纹理同区域)
final Widget? overlay;
/// 当前帧图像尺寸(用于按 buffer 比例约束预览,保证无拉伸变形)
final int imageWidthPx;
final int imageHeightPx;
const _ZoomablePreview({
required this.controller,
this.overlay,
this.imageWidthPx = 0,
this.imageHeightPx = 0,
});
@override
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
}
class _ZoomablePreviewState extends State<_ZoomablePreview> {
double _minZoom = 1.0;
double _maxZoom = 1.0;
double _currentZoom = 1.0;
double _gestureStartZoom = 1.0;
@override
void initState() {
super.initState();
widget.controller.getMinZoomLevel().then((v) {
if (mounted) setState(() => _minZoom = v);
});
widget.controller.getMaxZoomLevel().then((v) {
if (mounted) setState(() => _maxZoom = v);
});
}
@override
Widget build(BuildContext context) {
final preview = GestureDetector(
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
onScaleUpdate: (d) {
final target =
(_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
if ((target - _currentZoom).abs() < 0.01) return;
_currentZoom = target;
widget.controller.setZoomLevel(target);
},
child: CameraPreview(widget.controller, child: widget.overlay),
);
final w = widget.imageWidthPx.toDouble();
final h = widget.imageHeightPx.toDouble();
if (w <= 0 || h <= 0) return preview;
// 按 buffer 比例约束显示区域:纹理与 overlay 同区域等比显示(无变形)
return Center(
child: AspectRatio(aspectRatio: w / h, child: preview),
);
}
}
class _CameraTopBar extends StatelessWidget {
final VoidCallback onClose;
const _CameraTopBar({
required this.onClose,
});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: onClose,
),
],
),
);
}
}
class _PermissionGuide extends StatelessWidget {
final VoidCallback onRequest;
const _PermissionGuide({required this.onRequest});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'需要相机权限才能进行实时识别',
style: TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
],
),
);
}
}
@@ -0,0 +1,238 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
import '../detection/tflite_detector.dart';
import '../reminder/reminder.dart';
@immutable
class CameraUiState {
final bool modelReady;
final List<DetectionResult> results;
final int rotation;
final int imageWidthPx;
final int imageHeightPx;
final double debugHighestScore;
final int debugDetectCalls;
final int debugDetectErrors;
final String? debugLastError;
final int framesReceived;
final int debugLastMs;
const CameraUiState({
this.modelReady = false,
this.results = const [],
this.rotation = 90,
this.imageWidthPx = 0,
this.imageHeightPx = 0,
this.debugHighestScore = 0,
this.debugDetectCalls = 0,
this.debugDetectErrors = 0,
this.debugLastError,
this.framesReceived = 0,
this.debugLastMs = 0,
});
}
/// 检测结果置信度分级与轨迹确认。
///
/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。
/// - [highConf]0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2,
/// 高于 0.35 视为强证据。
/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// (运动区域/背景新出现区域重叠)才确认显示。
class CameraViewModel extends ChangeNotifier {
static const int maxTracks = 30;
static const double motionBoost = 0.15;
static const double lowConf = TfliteDetector.minScore;
static const double highConf = 0.35;
static const int confirmFrames = 3;
static const double associateRadius = 0.12;
static const int displayAgeMs = 500;
static const int forgetMs = 2000;
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
bool modelReady = false;
final Reminder reminder;
CameraUiState _state;
CameraUiState get state => _state;
final Map<int, _Track> _tracks = {};
int _nextTrackId = 0;
CameraViewModel({required this.reminder}) : _state = const CameraUiState();
void setModelReady(bool ready) {
if (modelReady == ready) return;
modelReady = ready;
_state = CameraUiState(modelReady: ready);
notifyListeners();
}
/// 帧分析回调(分析流调用)
void onFramesAnalyzed(
List<DetectionResult> results,
int rotation,
int imageWidthPx,
int imageHeightPx,
List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions, {
int detectCalls = 0,
int detectErrors = 0,
String? lastError,
int framesReceived = 0,
int lastProcessMs = 0,
}) {
final now = DateTime.now().millisecondsSinceEpoch;
_associate(results, motionRegions, noveltyRegions, now);
final visible = <DetectionResult>[];
for (final t in _tracks.values) {
if (now - t.firstSeenMs < displayAgeMs) continue;
if (now - t.lastSeenMs > forgetMs) continue;
if (!_shouldDisplay(t, motionRegions, noveltyRegions)) continue;
var r = t.result;
// 低分确认目标 + 活动证据 → 分数提升,便于视觉区分
if (t.confirmed &&
r.score < highConf &&
_hasActivity(r, motionRegions, noveltyRegions)) {
r = r.copyWith(score: (r.score + motionBoost).clamp(0.0, 1.0));
}
visible.add(r.copyWith(confirmed: t.confirmed));
}
// 提醒:仅新确认的野鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内)
for (final t in _tracks.values) {
if (t.label != 'pheasant' || !t.confirmed || t.reminded) continue;
final age = now - t.firstSeenMs;
if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
now - t.lastSeenMs <= 300) {
t.reminded = true;
reminder.onDetected(t.label);
}
}
var highest = 0.0;
for (final r in results) {
if (r.score > highest) highest = r.score;
}
_state = CameraUiState(
modelReady: modelReady,
results: visible,
rotation: rotation,
imageWidthPx: imageWidthPx,
imageHeightPx: imageHeightPx,
debugHighestScore: highest,
debugDetectCalls: detectCalls,
debugDetectErrors: detectErrors,
debugLastError: lastError,
framesReceived: framesReceived,
debugLastMs: lastProcessMs,
);
notifyListeners();
}
/// 检测框 → 轨迹关联:按中心距离就近匹配(同标签优先,跨标签收紧距离),
/// 未匹配则新建候选轨迹。
void _associate(
List<DetectionResult> results,
List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions,
int now) {
final matched = <int>{};
for (final r in results) {
if (!_plausible(r)) continue;
_Track? best;
var bestD = associateRadius;
for (final t in _tracks.values) {
if (matched.contains(t.id)) continue;
final d = _centerDist(t.result, r);
// 同标签宽松匹配;跨标签(野鸡↔疑似 抖动)收紧到 60%
final limit = t.label == r.label ? bestD : associateRadius * 0.6;
if (d < limit) {
bestD = d;
best = t;
}
}
if (best != null) {
matched.add(best.id);
best.update(r, now);
best.seenCount++;
if (best.seenCount >= confirmFrames || r.score >= highConf ||
_hasActivity(r, motionRegions, noveltyRegions)) {
best.confirmed = true;
}
} else {
final t = _Track(_nextTrackId++, now, r);
t.seenCount = 1;
t.confirmed = r.score >= highConf ||
_hasActivity(r, motionRegions, noveltyRegions);
_tracks[t.id] = t;
}
}
_tracks.removeWhere(
(id, t) => !matched.contains(id) && now - t.lastSeenMs > forgetMs);
}
/// 显示判定(按类别策略):
/// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化)
/// - 野鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions) {
if (t.label == 'suspect') return true;
if (t.confirmed) return true;
return t.result.score >= highConf ||
_hasActivity(t.result, motionRegions, noveltyRegions);
}
/// 活动证据:与运动区域或背景新出现区域重叠
bool _hasActivity(DetectionResult r, List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions) =>
motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) ||
noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m));
/// 物理合理性过滤:宽高比与相对尺寸(野鸡 20-100px@720 量级,参照标注脚本)
bool _plausible(DetectionResult r) {
final h = r.height;
final w = r.width;
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;
return h >= 0.01 && h <= 0.3;
}
double _centerDist(DetectionResult a, DetectionResult b) =>
math.sqrt(math.pow(a.centerX - b.centerX, 2) +
math.pow(a.centerY - b.centerY, 2));
@override
void dispose() {
reminder.release();
super.dispose();
}
}
class _Track {
final int id;
final String label;
int firstSeenMs;
int lastSeenMs;
int seenCount = 0;
bool confirmed = false;
bool reminded = false;
DetectionResult result;
_Track(this.id, this.firstSeenMs, this.result)
: lastSeenMs = firstSeenMs,
label = result.label;
void update(DetectionResult r, int now) {
lastSeenMs = now;
result = r;
}
}
@@ -0,0 +1,148 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../detection/coordinate_mapper.dart';
import '../detection/detection_result.dart';
/// 检测框绘制分级:
/// - 野鸡 confirmed:红色实线 3px(强证据)
/// - 野鸡 candidate:红色虚线 2px 半透明(待确认,弱提示)
/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染)
/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。
class DetectionOverlay extends StatelessWidget {
final List<DetectionResult> results;
final int rotation;
final int imageWidthPx;
final int imageHeightPx;
const DetectionOverlay({
super.key,
required this.results,
required this.rotation,
required this.imageWidthPx,
required this.imageHeightPx,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _OverlayPainter(results, rotation, imageWidthPx, imageHeightPx),
child: const SizedBox.expand(),
),
);
}
}
class _OverlayPainter extends CustomPainter {
final List<DetectionResult> results;
final int rotation;
final int imageWidthPx;
final int imageHeightPx;
_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};
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px5.1mm / 5.30mm 传感器),
/// 单目误差 ±30%,仅作参考
static const double focalPx = 1230;
static const double maxDistanceM = 120;
@override
void paint(Canvas canvas, Size size) {
for (final r in results) {
final rect = CoordinateMapper.mapToView(
r.left,
r.top,
r.right,
r.bottom,
rotation,
imageWidthPx,
imageHeightPx,
size.width,
size.height,
);
final color = _colors[r.label] ?? Colors.white;
final isSuspect = r.label == 'suspect';
final confirmed = r.confirmed && !isSuspect;
final paint = Paint()
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
..style = PaintingStyle.stroke
..strokeWidth = confirmed ? 3 : 2
..isAntiAlias = true;
final box = Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom);
if (confirmed) {
canvas.drawRect(box, paint);
} else {
_drawDashedRect(canvas, box, paint);
}
// 标签:框上方,含距离
final dist = _distanceLabel(r);
final text =
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
color: color.withValues(alpha: confirmed ? 1.0 : 0.8),
fontSize: 14,
fontWeight: FontWeight.w600,
shadows: const [Shadow(color: Colors.black, blurRadius: 3)],
),
),
textDirection: TextDirection.ltr,
)..layout();
final top = math.max(0.0, rect.top - 22);
final left = math.max(0.0, rect.left);
textPainter.paint(canvas, Offset(left + 4, top));
}
}
String _distanceLabel(DetectionResult r) {
final refH = _refSizeM[r.label];
if (refH == null) return '';
final hPx = r.height * imageHeightPx;
if (hPx < 8) return '';
final m = focalPx * refH / hPx;
if (m > maxDistanceM) return '';
return '${m.round()}m';
}
void _drawDashedRect(Canvas canvas, Rect r, Paint paint,
{double dash = 10, double gap = 6}) {
void dashLine(Offset a, Offset b) {
final total = (b - a).distance;
if (total <= 0) return;
final dir = (b - a) / total;
var d = 0.0;
while (d < total) {
final e = math.min(d + dash, total);
canvas.drawLine(a + dir * d, a + dir * e, paint);
d += dash + gap;
}
}
dashLine(r.topLeft, r.topRight);
dashLine(r.topRight, r.bottomRight);
dashLine(r.bottomRight, r.bottomLeft);
dashLine(r.bottomLeft, r.topLeft);
}
@override
bool shouldRepaint(_OverlayPainter oldDelegate) =>
oldDelegate.results != results ||
oldDelegate.rotation != rotation ||
oldDelegate.imageWidthPx != imageWidthPx ||
oldDelegate.imageHeightPx != imageHeightPx;
}
@@ -0,0 +1,97 @@
import 'package:camera/camera.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import 'camera_view_model.dart';
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
/// 推理在后台 isolateDetectorWorker)执行,主 isolate 只投递帧与收结果。
class FrameAnalyzer {
/// 连续检测:100ms 一帧
int intervalMs = 100;
/// null = 模型加载失败,仅预览不分析
final DetectorWorker? worker;
final CameraViewModel viewModel;
int _lastDetectMs = 0;
/// 诊断计数:推理调用/异常次数
int detectCalls = 0;
int detectErrors = 0;
String? lastError;
/// 最近一次完整处理(预处理+推理+运动检测)耗时 ms(worker 侧)
int lastProcessMs = 0;
/// 图像流回调是否到达(诊断用)
int framesReceived = 0;
FrameAnalyzer({required this.worker, required this.viewModel}) {
worker?.onResult = _onResult;
worker?.onError = _onError;
}
void recordStreamError(String msg) {
lastError = msg;
detectErrors++;
}
void _onResult(
List<DetectionResult> results,
List<MotionRegion> motion,
List<MotionRegion> novelty,
int rotation,
int width,
int height,
int processMs) {
detectCalls++;
lastProcessMs = processMs;
viewModel.onFramesAnalyzed(
results,
rotation,
width,
height,
motion,
novelty,
detectCalls: detectCalls,
detectErrors: detectErrors,
lastError: lastError,
framesReceived: framesReceived,
lastProcessMs: lastProcessMs,
);
}
void _onError(String msg) {
detectErrors++;
lastError = msg;
viewModel.onFramesAnalyzed(
const [],
90,
0,
0,
const [],
const [],
detectCalls: detectCalls,
detectErrors: detectErrors,
lastError: lastError,
framesReceived: framesReceived,
lastProcessMs: lastProcessMs,
);
}
void analyze(CameraImage image, int rotationDegrees) {
framesReceived++;
final w = worker;
if (w == null) return;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastDetectMs < intervalMs) return;
_lastDetectMs = now;
if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压
w.analyze(image, rotationDegrees);
}
void reset() => _lastDetectMs = 0;
void dispose() => worker?.dispose();
}
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
/// 轻量运动检测:相邻帧 Y 通道差分 + 分块聚合。
/// 小尺寸工作(约 128x128 内),在分析流中串行调用。
/// 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。
class MotionDetector {
final int maxWidth;
final int maxHeight;
List<int>? _prevGray;
MotionDetector({this.maxWidth = 128, this.maxHeight = 128});
/// 后台 isolate 用原始数据接口(不依赖 CameraImage)。
List<MotionRegion> detectMotionRaw(
Uint8List yPlane, int yStride, int width, int height) {
final w = width, h = height;
final scale = maxWidth / w < maxHeight / h ? maxWidth / w : maxHeight / h;
final tw = (w * scale).toInt().clamp(1, maxWidth);
final th = (h * scale).toInt().clamp(1, maxHeight);
if (tw == 0 || th == 0) return const [];
// 取 Y 平面缩放灰度(最近邻下采样到 128x128 内)
final y = yPlane;
final gray = List<int>.filled(tw * th, 0);
for (var oy = 0; oy < th; oy++) {
final sy = (oy / scale).toInt().clamp(0, h - 1);
for (var ox = 0; ox < tw; ox++) {
final sx = (ox / scale).toInt().clamp(0, w - 1);
gray[oy * tw + ox] = y[sy * yStride + sx];
}
}
final prev = _prevGray;
_prevGray = List.of(gray);
if (prev == null || prev.length != gray.length) return const [];
final diff = MotionAggregator.diffMask(gray, prev);
final motionTotal = diff.fold(0, (a, b) => a + b);
// 全屏大差异 → 相机移动/大范围变化,忽略本帧
if (motionTotal > tw * th / 2) return const [];
if (motionTotal < 12) return const [];
return MotionAggregator.aggregate(diff, tw, th);
}
/// 相机切换后重置参考帧,避免旧帧误差
void reset() {
_prevGray = null;
}
}
+22
View File
@@ -0,0 +1,22 @@
/// 全局配置占位:接入真实支付前需替换以下值。
class AppConfig {
/// 后端服务器地址(订单创建/授权查询/套餐价格)
/// 默认 Android 模拟器 10.0.2.2iOS 模拟器构建时传
/// --dart-define=API_BASE_URL=http://127.0.0.1:8080
static const String apiBaseUrl =
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://10.0.2.2:8080');
/// 微信开放平台 AppID(需在微信开放平台注册包名+签名)
static const String wechatAppId = 'wx0000000000000000';
static const String wechatUniversalLink =
'https://YOUR_DOMAIN.com/wechat/';
/// 支付宝开放平台 AppID
static const String alipayAppId = '2020000000000000';
static const String alipayUniversalLink =
'https://YOUR_DOMAIN.com/alipay/';
/// iOS URL Scheme(微信/支付宝拉起回调,需与 Info.plist 一致)
static const String wechatUrlScheme = 'wx0000000000000000';
static const String alipayUrlScheme = 'alipay0000000000';
}
+35
View File
@@ -0,0 +1,35 @@
import 'auth/auth_view_model.dart';
import 'auth/session_store.dart';
import 'home/home_view_model.dart';
import 'payment/license_service.dart';
import 'payment/models.dart';
import 'payment/order_api.dart';
import 'payment/paywall_view_model.dart';
import 'payment/payment_service.dart';
/// 手动依赖注入容器(对应原 Kotlin AppContainer
class AppContainer {
late final SessionStore sessionStore;
late final OrderApi orderApi;
late final LicenseService licenseService;
late final AuthViewModel authViewModel;
late final HomeViewModel homeViewModel;
late final PaywallViewModel paywallViewModel;
AppContainer() {
sessionStore = SessionStore();
orderApi = OrderApi(sessionStore: sessionStore);
licenseService =
LicenseService(orderApi: orderApi, sessionStore: sessionStore);
authViewModel = AuthViewModel(orderApi: orderApi, sessionStore: sessionStore);
homeViewModel = HomeViewModel(licenseService: licenseService);
paywallViewModel = PaywallViewModel(
orderApi: orderApi,
licenseService: licenseService,
services: {
PayChannel.wechat: WechatPayService(orderApi: orderApi),
PayChannel.alipay: AlipayService(orderApi: orderApi),
},
);
}
}
@@ -0,0 +1,94 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'detection_result.dart';
import 'motion_aggregator.dart';
/// 静态场景背景建模:运行均值 + 方差,帧差高于自适应阈值的像素记为"新出现",
/// 分块聚合为新颖区域(novelty)。
///
/// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域;
/// 走进画面的目标(野鸡移动/新出现)才会触发。比相邻帧差分更强的证据:
/// 风吹草动是持续的背景更新,不会长期标记为新颖。
class BackgroundModel {
final int maxWidth;
final int maxHeight;
static const double learnRate = 0.05;
static const double kSigma = 2.5;
static const int minDiff = 15;
static const int minPixels = 12;
Float32List? _mean;
Float32List? _var;
int _tw = 0;
BackgroundModel({this.maxWidth = 128, this.maxHeight = 128});
/// 后台 isolate 用原始数据接口(与 MotionDetector 同源:直接取 planes[0])。
List<MotionRegion> updateRaw(
Uint8List yPlane, int yStride, int width, int height) {
final scale =
maxWidth / width < maxHeight / height ? maxWidth / width : maxHeight / height;
final tw = (width * scale).toInt().clamp(1, maxWidth);
final th = (height * scale).toInt().clamp(1, maxHeight);
if (tw == 0 || th == 0) return const [];
final gray = Float32List(tw * th);
for (var oy = 0; oy < th; oy++) {
final sy = (oy / scale).toInt().clamp(0, height - 1);
final idx = oy * tw;
for (var ox = 0; ox < tw; ox++) {
final sx = (ox / scale).toInt().clamp(0, width - 1);
gray[idx + ox] = yPlane[sy * yStride + sx].toDouble();
}
}
return update(gray, tw, th);
}
List<MotionRegion> update(Float32List gray, int tw, int th) {
final n = gray.length;
final mean = _mean;
final variance = _var;
if (mean == null || variance == null || mean.length != n || _tw != tw) {
_mean = Float32List.fromList(gray);
_var = Float32List(n);
_tw = tw;
return const [];
}
final diff = Uint8List(n);
var fgCount = 0;
for (var i = 0; i < n; i++) {
final g = gray[i];
final m = mean[i];
final d = (g - m).abs();
if (d > kSigma * math.sqrt(variance[i]) + minDiff) {
diff[i] = 1;
fgCount++;
// 前景像素不更新背景,避免把移动目标吸收进背景
} else {
// 静态像素缓慢吸收进背景,适应光照漂移
final nm = m + learnRate * (g - m);
mean[i] = nm;
variance[i] =
variance[i] + learnRate * ((g - nm) * (g - nm) - variance[i]);
}
}
// 全屏大变化 → 相机移动/场景切换,重建背景
if (fgCount > n ~/ 2) {
_mean = null;
_var = null;
return const [];
}
if (fgCount < minPixels) return const [];
return MotionAggregator.aggregate(diff, tw, th);
}
/// 相机切换后重置,避免旧场景背景
void reset() {
_mean = null;
_var = null;
}
}
@@ -0,0 +1,69 @@
class ViewRect {
final double left;
final double top;
final double right;
final double bottom;
const ViewRect(this.left, this.top, this.right, this.bottom);
double get width => right - left;
double get height => bottom - top;
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
}
/// 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪)。
class CoordinateMapper {
static ViewRect mapToView(
double normLeft,
double normTop,
double normRight,
double normBottom,
int rotation,
int imageW,
int imageH,
double viewW,
double viewH,
) {
// 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标)
late final double x0, y0, x1, y1;
switch (rotation) {
case 90:
x0 = 1 - normBottom;
y0 = normLeft;
x1 = 1 - normTop;
y1 = normRight;
case 180:
x0 = 1 - normRight;
y0 = 1 - normBottom;
x1 = 1 - normLeft;
y1 = 1 - normTop;
case 270:
x0 = normTop;
y0 = 1 - normRight;
x1 = normBottom;
y1 = 1 - normLeft;
default:
x0 = normLeft;
y0 = normTop;
x1 = normRight;
y1 = normBottom;
}
// 2) 旋转后图像在竖屏方向上的尺寸
final portrait = rotation == 90 || rotation == 270;
final portW = portrait ? imageH : imageW;
final portH = portrait ? imageW : imageH;
// 3) FIT_CENTER 缩放与居中偏移
final scale = viewW / portW < viewH / portH
? viewW / portW
: viewH / portH;
final offsetX = (viewW - portW * scale) / 2;
final offsetY = (viewH - portH * scale) / 2;
return ViewRect(
x0 * portW * scale + offsetX,
y0 * portH * scale + offsetY,
x1 * portW * scale + offsetX,
y1 * portH * scale + offsetY,
);
}
}
@@ -0,0 +1,56 @@
class DetectionResult {
final String label;
final double score;
final double left;
final double top;
final double right;
final double bottom;
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
final bool confirmed;
const DetectionResult({
required this.label,
required this.score,
required this.left,
required this.top,
required this.right,
required this.bottom,
this.confirmed = true,
});
double get width => right - left;
double get height => bottom - top;
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
DetectionResult copyWith({
double? score,
double? left,
double? top,
double? right,
double? bottom,
bool? confirmed,
}) =>
DetectionResult(
label: label,
score: score ?? this.score,
left: left ?? this.left,
top: top ?? this.top,
right: right ?? this.right,
bottom: bottom ?? this.bottom,
confirmed: confirmed ?? this.confirmed,
);
}
class MotionRegion {
final double left;
final double top;
final double right;
final double bottom;
const MotionRegion(this.left, this.top, this.right, this.bottom);
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
}
@@ -0,0 +1,265 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/services.dart' show rootBundle;
import '../camera/motion_detector.dart';
import 'background_model.dart';
import 'detection_result.dart';
import 'tflite_detector.dart';
import 'visual_prior.dart';
/// 推理工作单元:模型加载与检测全部在后台 isolate 执行,
/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。
class DetectorWorker {
static const String modelAsset = 'assets/model.tflite';
static const String labelsAsset = 'assets/labels.txt';
final Isolate _isolate;
final ReceivePort _responses;
final _controlPort = Completer<SendPort>();
final _ready = Completer<void>();
SendPort? _port;
/// 在途帧数(主 isolate 侧计数,用于丢帧)
int _inFlight = 0;
bool _dead = false;
/// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 / 处理耗时 ms
void Function(List<DetectionResult>, List<MotionRegion>, List<MotionRegion>,
int, int, int, int)? onResult;
/// 单帧处理异常回调(不影响相机流)
void Function(String)? onError;
/// 最近一次创建失败的诊断原因(UI 展示用)
static String? lastLoadError;
/// worker 最近上报的执行步骤(诊断用)
static String? lastLog;
DetectorWorker._(this._isolate, this._responses) {
_responses.listen(_onMessage, onDone: () {
_dead = true;
if (!_ready.isCompleted) {
_ready.completeError(StateError('推理进程异常退出'));
}
onError?.call('推理进程异常退出');
});
}
/// 读取模型资产并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
static Future<DetectorWorker?> create() async {
try {
final data = await rootBundle.load(modelAsset);
final modelBytes =
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
final labels = (await rootBundle.loadString(labelsAsset))
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
final responses = ReceivePort();
final isolate = await Isolate.spawn(_workerMain, responses.sendPort);
final worker = DetectorWorker._(isolate, responses);
final port = await worker._controlPort.future
.timeout(const Duration(seconds: 10),
onTimeout: () => throw TimeoutException('worker port timeout'));
worker._port = port;
port.send(['load', modelBytes, labels]);
await worker._ready.future
.timeout(const Duration(seconds: 20), onTimeout: () {
throw TimeoutException('model load timeout');
});
return worker;
} catch (e) {
lastLoadError = e.toString();
debugPrint('[DetectorWorker] create failed: $e');
return null;
}
}
/// 是否忙(上一帧尚未返回):忙则丢帧,避免在途积压
bool get busy => _inFlight > 0;
void analyze(CameraImage image, int rotationDegrees) {
final port = _port;
if (port == null || _dead) return;
_inFlight++;
port.send([
'frame',
[
image.planes.map((p) => p.bytes).toList(),
image.planes.map((p) => p.bytesPerRow).toList(),
image.width,
image.height,
image.format.group == ImageFormatGroup.bgra8888,
rotationDegrees,
],
]);
}
void _onMessage(dynamic msg) {
final list = msg as List;
switch (list[0] as String) {
case 'port':
_controlPort.complete(list[1] as SendPort);
break;
case 'ready':
_ready.complete();
break;
case 'load-error':
_ready.completeError(StateError(
list.length > 1 ? list[1] as String : 'model load failed'));
break;
case 'result':
_inFlight--;
final dets = (list[4] as List).map((d) {
final v = d as List;
return DetectionResult(
label: v[0] as String,
score: v[1] as double,
left: v[2] as double,
top: v[3] as double,
right: v[4] as double,
bottom: v[5] as double,
);
}).toList();
final motion = (list[5] as List)
.map((m) => m as List)
.map((v) => MotionRegion(
v[0] as double, v[1] as double, v[2] as double, v[3] as double))
.toList();
final novelty = (list[6] as List)
.map((m) => m as List)
.map((v) => MotionRegion(
v[0] as double, v[1] as double, v[2] as double, v[3] as double))
.toList();
onResult?.call(dets, motion, novelty, list[1] as int, list[2] as int,
list[3] as int, list[7] as int);
break;
case 'log':
lastLog = list[1] as String;
debugPrint('[DetectorWorker] $lastLog');
break;
case 'error':
_inFlight--;
onError?.call(list[1] as String);
}
}
/// 相机切换/场景变化后重置运动与背景参考
void reset() {
final port = _port;
if (port == null || _dead) return;
port.send(['reset']);
}
void dispose() {
_dead = true;
_isolate.kill(priority: Isolate.immediate);
_responses.close();
}
}
/// 后台 isolate 入口:串行处理 load / frame / reset 命令。
/// 所有回发必须走 [mainPort](主 isolate 的端口);control 是 worker 自己的
/// 收件箱,往 control.sendPort 发消息等于发给自己,主 isolate 永远收不到。
Future<void> _workerMain(SendPort mainPort) async {
final control = ReceivePort();
mainPort.send(['port', control.sendPort]);
mainPort.send(['log', 'worker-start']);
TfliteDetector? detector;
MotionDetector? motion;
BackgroundModel? background;
await for (final msg in control) {
try {
final list = msg as List;
switch (list[0] as String) {
case 'load':
mainPort.send(['log', 'load-received']);
try {
detector = await TfliteDetector.fromBuffer(
list[1] as Uint8List, (list[2] as List).cast<String>());
if (detector == null) {
mainPort.send(['load-error', 'fromBuffer 返回 null']);
} else {
mainPort.send(['log', 'fromBuffer-ok']);
motion = MotionDetector();
background = BackgroundModel();
mainPort.send(['ready']);
}
} catch (e) {
mainPort.send(['load-error', '$e']);
}
break;
case 'frame':
final d = detector;
final m = motion;
final b = background;
if (d == null || m == null || b == null) break;
final frame = list[1] as List;
final planes = (frame[0] as List).cast<Uint8List>();
final strides = (frame[1] as List).cast<int>();
final width = frame[2] as int;
final height = frame[3] as int;
final isBgra = frame[4] as bool;
final rotation = frame[5] as int;
final sw = Stopwatch()..start();
var results = d.detectRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
);
// 低分野鸡框过视觉先验(颜色/位置),减少户外误报
results = VisualPrior.filter(
results,
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
);
final motionRegions = m.detectMotionRaw(
planes[0], strides[0], width, height);
final noveltyRegions =
b.updateRaw(planes[0], strides[0], width, height);
sw.stop();
mainPort.send([
'result',
rotation,
width,
height,
results
.map((r) =>
[r.label, r.score, r.left, r.top, r.right, r.bottom])
.toList(),
motionRegions
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
.toList(),
noveltyRegions
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
.toList(),
sw.elapsedMilliseconds,
]);
break;
case 'reset':
motion?.reset();
background?.reset();
}
} catch (e) {
mainPort.send(['error', '$e']);
}
}
}
@@ -0,0 +1,96 @@
import 'dart:math' as math;
import 'detection_result.dart';
/// 帧差运动聚合:每像素 0/1 差分掩码 → 8x8 分块统计 → 连通块聚合为运动区域。
class MotionAggregator {
static const int blockGrid = 8;
static const double blockActiveRatio = 0.30;
static const int maxRegions = 3;
static const int diffThreshold = 25;
static List<MotionRegion> aggregate(List<int> diff, int width, int height) {
final bw = width ~/ blockGrid;
final bh = height ~/ blockGrid;
if (bw == 0 || bh == 0) return const [];
final active = List<bool>.filled(blockGrid * blockGrid, false);
for (var by = 0; by < blockGrid; by++) {
for (var bx = 0; bx < blockGrid; bx++) {
final blockW = bx == blockGrid - 1 ? width - bx * bw : bw;
final blockH = by == blockGrid - 1 ? height - by * bh : bh;
var motion = 0;
for (var y = by * bh; y < by * bh + blockH; y++) {
var idx = y * width + bx * bw;
for (var x = 0; x < blockW; x++) {
motion += diff[idx + x];
}
idx += width;
}
active[by * blockGrid + bx] =
motion > blockW * blockH * blockActiveRatio;
}
}
final regions = <MotionRegion>[];
final visited = List<bool>.filled(active.length, false);
for (var i = 0; i < active.length; i++) {
if (!active[i] || visited[i]) continue;
var minX = blockGrid, minY = blockGrid, maxX = -1, maxY = -1;
final stack = <int>[i];
visited[i] = true;
while (stack.isNotEmpty) {
final cur = stack.removeLast();
final bx = cur % blockGrid;
final by = cur ~/ blockGrid;
if (bx < minX) minX = bx;
if (bx > maxX) maxX = bx;
if (by < minY) minY = by;
if (by > maxY) maxY = by;
for (final nb in neighbors(cur)) {
if (active[nb] && !visited[nb]) {
visited[nb] = true;
stack.add(nb);
}
}
}
if (maxX - minX > 3 || maxY - minY > 3) continue; // 全屏噪声过滤
regions.add(MotionRegion(
minX * bw / width,
minY * bh / height,
math.min((maxX + 1) * bw, width) / width,
math.min((maxY + 1) * bh, height) / height,
));
if (regions.length >= maxRegions) break;
}
return regions;
}
static List<int> neighbors(int i) {
final bx = i % blockGrid;
final by = i ~/ blockGrid;
final list = <int>[];
if (bx > 0) list.add(i - 1);
if (bx < blockGrid - 1) list.add(i + 1);
if (by > 0) list.add(i - blockGrid);
if (by < blockGrid - 1) list.add(i + blockGrid);
return list;
}
/// 检测框中心是否落在运动区域内(用于置信度提升判定)
static bool centerInRegion(DetectionResult box, MotionRegion region) =>
box.centerX >= region.left &&
box.centerX <= region.right &&
box.centerY >= region.top &&
box.centerY <= region.bottom;
/// 帧差掩码:|g - prev| > threshold → 1
static List<int> diffMask(List<int> gray, List<int> prev,
[int threshold = diffThreshold]) {
final diff = List<int>.filled(gray.length, 0);
for (var i = 0; i < gray.length; i++) {
diff[i] = (gray[i] - prev[i]).abs() > threshold ? 1 : 0;
}
return diff;
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'detection_result.dart';
double iou(DetectionResult a, DetectionResult b) {
final x0 = a.left > b.left ? a.left : b.left;
final y0 = a.top > b.top ? a.top : b.top;
final x1 = a.right < b.right ? a.right : b.right;
final y1 = a.bottom < b.bottom ? a.bottom : b.bottom;
if (x1 <= x0 || y1 <= y0) return 0;
final inter = (x1 - x0) * (y1 - y0);
final union = a.width * a.height + b.width * b.height - inter;
return union <= 0 ? 0 : inter / union;
}
List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final b in sorted) {
if (!kept.any((k) => iou(b, k) > iouThreshold)) kept.add(b);
}
return kept;
}
@@ -0,0 +1,312 @@
import 'dart:typed_data';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'detection_result.dart';
import 'nms.dart';
/// YOLOv8n 端侧推理实现(对应 Kotlin TFLiteDetector)。
/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors]
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
/// 输入为 NCHW [1, 3, 704, 704]litert 导出保留 torch 布局)。
class TfliteDetector {
static const int inputSize = 704;
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升
static const double minScore = 0.10;
static const double iouThreshold = 0.45;
static const int maxDetections = 20;
static const String modelAsset = 'assets/model.tflite';
static const String labelsAsset = 'assets/labels.txt';
final Interpreter _interpreter;
final List<String> _labels;
final int _numClasses;
final int _numAnchors;
final Float32List _input =
Float32List(1 * inputSize * inputSize * 3);
/// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织,
/// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。
final List<List<List<double>>> _output;
TfliteDetector._(this._interpreter, this._labels, this._numClasses,
this._numAnchors, this._output);
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
static Future<TfliteDetector?> fromBuffer(
Uint8List bytes, List<String> labels) async {
try {
final interpreter = Interpreter.fromBuffer(
bytes,
options: InterpreterOptions()..threads = 4,
);
return TfliteDetector._fromModel(interpreter, labels);
} catch (_) {
return null;
}
}
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
factory TfliteDetector._fromModel(
Interpreter interpreter, List<String> labels) {
final shape = interpreter.getOutputTensor(0).shape;
final numClasses =
shape.length >= 3 && shape[1] > 4 ? shape[1] - 4 : labels.length;
final numAnchors = shape.length >= 3 && shape[2] > 0 ? shape[2] : 2100;
final output = List.generate(
1,
(_) => List.generate(
numClasses + 4,
(_) => List<double>.filled(numAnchors, 0),
),
);
return TfliteDetector._(
interpreter, labels, numClasses, numAnchors, output);
}
/// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。
/// 输出坐标统一反算为原图归一化空间(与 MotionDetector 一致),
/// 否则 CENTER_CROP 裁剪偏移会让检测框系统性偏移。
List<DetectionResult> detectRaw({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
}) {
preprocess(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra);
// 传原始字节视图而非 Float32Listtflite_flutter 会对非 ByteBuffer/Uint8List
// 输入调用 resizeInputTensor1 维 [1486848]),使 node 0 TRANSPOSE prepare 失败
_interpreter.run(_input.buffer.asUint8List(), _output);
final dets = postprocess();
// 反算与 preprocess 的 scale/dx/dy 公式一致(704 输入空间 → 原图归一化)
final scale = inputSize / width < inputSize / height
? inputSize / width
: inputSize / height;
final dx = (inputSize - width * scale) / 2;
final dy = (inputSize - height * scale) / 2;
if (dx == 0 && dy == 0) return dets;
return dets
.map((r) => r.copyWith(
left: (r.left * inputSize - dx) / (width * scale),
right: (r.right * inputSize - dx) / (width * scale),
top: (r.top * inputSize - dy) / (height * scale),
bottom: (r.bottom * inputSize - dy) / (height * scale),
))
.toList();
}
/// 按像素格式分派:iOS bgra8888 单平面 / Android yuv420 多平面。
void preprocess({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
}) {
if (isBgra) {
_preprocessBgra(planes[0], strides[0], width, height);
} else {
_preprocessYuv(planes, strides, width, height);
}
}
/// BGRA8888 单平面(iOS):每像素 4 字节 [b,g,r,a],双线性采样,
/// letterbox(等比缩到长边 704,短边黑边补 0,与 YOLO 训练一致)。
void _preprocessBgra(Uint8List src, int stride, int srcW, int srcH) {
final plane = inputSize * inputSize;
final scale = inputSize / srcW < inputSize / srcH
? inputSize / srcW
: inputSize / srcH;
final dx = (inputSize - srcW * scale) / 2;
final dy = (inputSize - srcH * scale) / 2;
for (var oy = 0; oy < inputSize; oy++) {
final syf = (oy - dy) / scale;
if (syf < 0 || syf >= srcH) {
for (var ox = 0; ox < inputSize; ox++) {
final p = oy * inputSize + ox;
_input[p] = 0;
_input[p + plane] = 0;
_input[p + 2 * plane] = 0;
}
continue;
}
for (var ox = 0; ox < inputSize; ox++) {
final p = oy * inputSize + ox;
final sxf = (ox - dx) / scale;
if (sxf < 0 || sxf >= srcW) {
_input[p] = 0;
_input[p + plane] = 0;
_input[p + 2 * plane] = 0;
continue;
}
final x0 = sxf.floor(), y0 = syf.floor();
final x1 = x0 < srcW - 1 ? x0 + 1 : x0;
final y1 = y0 < srcH - 1 ? y0 + 1 : y0;
final fx = sxf - x0, fy = syf - y0;
// BGRA 字节序:+0 B、+1 G、+2 R、+3 A
final i00 = y0 * stride + x0 * 4;
final i10 = y0 * stride + x1 * 4;
final i01 = y1 * stride + x0 * 4;
final i11 = y1 * stride + x1 * 4;
final r00 = src[i00 + 2].toDouble();
final g00 = src[i00 + 1].toDouble();
final b00 = src[i00].toDouble();
final r10 = src[i10 + 2].toDouble();
final g10 = src[i10 + 1].toDouble();
final b10 = src[i10].toDouble();
final r01 = src[i01 + 2].toDouble();
final g01 = src[i01 + 1].toDouble();
final b01 = src[i01].toDouble();
final r11 = src[i11 + 2].toDouble();
final g11 = src[i11 + 1].toDouble();
final b11 = src[i11].toDouble();
_input[p] = _bl(r00, r10, r01, r11, fx, fy) / 255.0;
_input[p + plane] = _bl(g00, g10, g01, g11, fx, fy) / 255.0;
_input[p + 2 * plane] = _bl(b00, b10, b01, b11, fx, fy) / 255.0;
}
}
}
/// letterbox 缩放 + YUV → RGB 归一化 0~1NCHW),双线性采样。
/// 兼容 NV12iOS 双平面,UV 交错)与 I420Android 三平面)。
void _preprocessYuv(
List<Uint8List> planes, List<int> strides, int srcW, int srcH) {
final plane = inputSize * inputSize;
final y = planes[0];
final nv12 = planes.length == 2;
final uv = nv12 ? planes[1] : null;
final u = nv12 ? null : planes[1];
final v = nv12 ? null : planes[2];
final yStride = strides[0];
final uvStride = strides[1];
// U/V 平面采样(nv12:偶位 U 奇位 V;i420:三平面分离)
double uAt(int x, int y) => nv12
? uv![y * uvStride + x * 2] - 128.0
: u![y * uvStride + x] - 128.0;
double vAt(int x, int y) => nv12
? uv![y * uvStride + x * 2 + 1] - 128.0
: v![y * uvStride + x] - 128.0;
final scale = inputSize / srcW < inputSize / srcH
? inputSize / srcW
: inputSize / srcH;
final dx = (inputSize - srcW * scale) / 2;
final dy = (inputSize - srcH * scale) / 2;
for (var oy = 0; oy < inputSize; oy++) {
final syf = (oy - dy) / scale;
if (syf < 0 || syf >= srcH) {
for (var ox = 0; ox < inputSize; ox++) {
final p = oy * inputSize + ox;
_input[p] = 0;
_input[p + plane] = 0;
_input[p + 2 * plane] = 0;
}
continue;
}
for (var ox = 0; ox < inputSize; ox++) {
final p = oy * inputSize + ox;
final sxf = (ox - dx) / scale;
if (sxf < 0 || sxf >= srcW) {
_input[p] = 0;
_input[p + plane] = 0;
_input[p + 2 * plane] = 0;
continue;
}
final x0 = sxf.floor(), y0 = syf.floor();
final x1 = x0 < srcW - 1 ? x0 + 1 : x0;
final y1 = y0 < srcH - 1 ? y0 + 1 : y0;
final fx = sxf - x0, fy = syf - y0;
// Y 双线性
final y00 = y[y0 * yStride + x0].toDouble();
final y10 = y[y0 * yStride + x1].toDouble();
final y01 = y[y1 * yStride + x0].toDouble();
final y11 = y[y1 * yStride + x1].toDouble();
final yy = _bl(y00, y10, y01, y11, fx, fy);
// U/V 双线性(4:2:0 半分辨率,按像素坐标定位后除 2)
final maxUx = srcW ~/ 2 - 1;
final maxUy = srcH ~/ 2 - 1;
final ux0 = (x0 ~/ 2).clamp(0, maxUx).toInt();
final uy0 = (y0 ~/ 2).clamp(0, maxUy).toInt();
final ux1 = (x1 ~/ 2).clamp(0, maxUx).toInt();
final uy1 = (y1 ~/ 2).clamp(0, maxUy).toInt();
final u00 = uAt(ux0, uy0);
final u10 = uAt(ux1, uy0);
final u01 = uAt(ux0, uy1);
final u11 = uAt(ux1, uy1);
final uu = _bl(u00, u10, u01, u11, fx, fy);
final v00 = vAt(ux0, uy0);
final v10 = vAt(ux1, uy0);
final v01 = vAt(ux0, uy1);
final v11 = vAt(ux1, uy1);
final vv = _bl(v00, v10, v01, v11, fx, fy);
// 有限范围展开(VideoRange Y 16~235Cb/Cr 16~240
final yr = (yy - 16.0) * (255.0 / 219.0);
final un = uu * (255.0 / 224.0);
final vn = vv * (255.0 / 224.0);
// NCHWr/g/b 分平面存储
_input[p] = (yr + 1.402 * vn) / 255.0;
_input[p + plane] = (yr - 0.344136 * un - 0.714136 * vn) / 255.0;
_input[p + 2 * plane] = (yr + 1.772 * un) / 255.0;
}
}
}
static double _bl(double a, double b, double c, double d, double fx,
double fy) =>
(1 - fx) * (1 - fy) * a + fx * (1 - fy) * b +
(1 - fx) * fy * c + fx * fy * d;
List<DetectionResult> postprocess() {
final out = _output[0];
final boxes = <DetectionResult>[];
for (var a = 0; a < _numAnchors; a++) {
final cx = out[0][a];
final cy = out[1][a];
final w = out[2][a];
final h = out[3][a];
var bestCls = 0;
var bestScore = 0.0;
for (var c = 0; c < _numClasses; c++) {
final s = out[4 + c][a];
if (s > bestScore) {
bestScore = s;
bestCls = c;
}
}
final label =
bestCls < _labels.length ? _labels[bestCls] : 'unknown';
// 低分池保留,供运动检测提升显示
if (bestScore < minScore) continue;
boxes.add(DetectionResult(
label: label,
score: bestScore,
left: (cx - w / 2).clamp(0.0, 1.0),
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),
));
}
final kept = nms(boxes, iouThreshold);
return kept.take(maxDetections).toList();
}
void dispose() => _interpreter.close();
}
+113
View File
@@ -0,0 +1,113 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'detection_result.dart';
/// 运行时视觉先验:对低置信度野鸡框做多线索过滤,降低户外误报。
///
/// 仅对 score < [maxScore]0.35)的 pheasant 框生效;高分框与
/// suspect(生境预警)不参与过滤,避免误杀。
///
/// 线索:
/// - 颜色:绿色主导(草/叶)、蓝色主导(天空/水)、平坦低饱和(键盘/石头/文字)
/// - 位置:中心在画面上部 15%(天空区)——野鸡是地栖动物,不会出现在天空
///
/// 采样在原始 planes 上进行(后台 isolate 内,不依赖 UI 线程)。
class VisualPrior {
static const double maxScore = 0.35;
static const double skyTopRatio = 0.15;
// 颜色判定阈值(与 tflite_detector 的 YUV 有限范围展开一致)
static const double greenDiff = 20;
static const double blueDiff = 10;
static const double flatRange = 10;
// 采样点中满足条件的比例超过即拒绝
static const double greenRatio = 0.5;
static const double blueRatio = 0.4;
static const double flatRatio = 0.6;
static List<DetectionResult> filter(
List<DetectionResult> results, {
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
}) {
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;
if (lowConfPheasant && _reject(r, planes, strides, width, height, isBgra)) {
continue;
}
kept.add(r);
}
return kept;
}
static bool _reject(DetectionResult r, List<Uint8List> planes,
List<int> strides, int width, int height, bool isBgra) {
// 位置线索:detectRaw 输出为图像坐标系,centerY 直接可判天空区
if (r.centerY < skyTopRatio) return true;
// 颜色线索:框中心 ±20% 区域 5×5 采样(小框采样点重合也没关系)
final cx = (r.centerX * width).round().clamp(0, width - 1).toInt();
final cy = (r.centerY * height).round().clamp(0, height - 1).toInt();
final halfW = math.max(1.0, r.width * width * 0.2);
final halfH = math.max(1.0, r.height * height * 0.2);
var green = 0, blue = 0, flat = 0, total = 0;
for (var gy = -2; gy <= 2; gy++) {
for (var gx = -2; gx <= 2; gx++) {
final px = (cx + gx * halfW / 2).round().clamp(0, width - 1).toInt();
final py = (cy + gy * halfH / 2).round().clamp(0, height - 1).toInt();
final (r_, g_, b_) = _pixel(planes, strides, px, py, width, height, isBgra);
total++;
final mn = math.min(r_, math.min(g_, b_));
final mx = math.max(r_, math.max(g_, b_));
if (g_ - r_ > greenDiff && g_ - b_ > greenDiff) green++;
if (b_ > r_ + blueDiff) blue++;
if (mx - mn < flatRange) flat++;
}
}
if (total == 0) return false;
if (green / total > greenRatio) return true;
if (blue / total > blueRatio) return true;
if (flat / total > flatRatio) return true;
return false;
}
/// 读取单像素 RGB0~255)。
/// BGRA 单平面:每像素 4 字节 [b,g,r,a]
/// YUVy 平面 + 4:2:0 半分辨率 U/VNV12 交错或 I420 分离)。
static (double, double, double) _pixel(List<Uint8List> planes,
List<int> strides, int x, int y, int width, int height, bool isBgra) {
if (isBgra) {
final src = planes[0];
final i = y * strides[0] + x * 4;
return (src[i + 2].toDouble(), src[i + 1].toDouble(), src[i].toDouble());
}
final yy =
(planes[0][y * strides[0] + x] - 16.0) * (255.0 / 219.0);
final nv12 = planes.length == 2;
final ux = (x ~/ 2).clamp(0, width ~/ 2 - 1).toInt();
final uy = (y ~/ 2).clamp(0, height ~/ 2 - 1).toInt();
final uvStride = strides[1];
final un = ((nv12
? planes[1][uy * uvStride + ux * 2].toDouble()
: planes[1][uy * uvStride + ux].toDouble()) -
128.0) *
(255.0 / 224.0);
final vn = ((nv12
? planes[1][uy * uvStride + ux * 2 + 1].toDouble()
: planes[2][uy * uvStride + ux].toDouble()) -
128.0) *
(255.0 / 224.0);
final r = yy + 1.402 * vn;
final g = yy - 0.344136 * un - 0.714136 * vn;
final b = yy + 1.772 * un;
return (r, g, b);
}
}
+216
View File
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../auth/session_store.dart';
import 'home_view_model.dart';
/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HomeViewModel>().refresh();
});
}
Future<void> _onSearch() async {
final vm = context.read<HomeViewModel>();
final allowed = await vm.verifyForCamera();
if (!mounted) return;
if (vm.sessionExpired) {
_toLogin();
return;
}
if (!allowed) {
if (vm.error != null) {
// 网络/服务端异常:无法确认授权状态,提示错误
_showBlocked(vm.error);
return;
}
// 服务端确认无有效授权:直接进充值页
await _onRecharge();
return;
}
await Navigator.of(context).pushNamed('/camera');
if (mounted) vm.refresh();
}
Future<void> _onRecharge() async {
await Navigator.of(context).pushNamed('/paywall');
if (mounted) context.read<HomeViewModel>().refresh();
}
Future<void> _toLogin() async {
final container = context.read<SessionStore>();
await container.clear();
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/login');
}
void _showBlocked(String? error) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('授权不可用'),
content: Text(error ?? '授权已过期,请充值后续费'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
_onRecharge();
},
child: const Text('去充值'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final vm = context.watch<HomeViewModel>();
final license = vm.license;
final active = license?.isActive ?? false;
final statusText = vm.loading
? '加载中…'
: (license == null || license.expiresAt == null
? '未充值'
: (active
? '有效至 ${_fmt(license.expiresAt!)}'
: '已过期(${_fmt(license.expiresAt!)}'));
return Scaffold(
appBar: AppBar(
title: const Text('视野'),
centerTitle: true,
actions: [
IconButton(
tooltip: '退出登录',
icon: const Icon(Icons.logout),
onPressed: _toLogin,
),
],
),
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),
),
),
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),
),
],
],
),
),
),
);
}
static String _fmt(DateTime t) {
String p(int n) => n.toString().padLeft(2, '0');
return '${t.year}-${p(t.month)}-${p(t.day)} ${p(t.hour)}:${p(t.minute)}';
}
}
class _StatusCard extends StatelessWidget {
final String licenseText;
final bool active;
const _StatusCard({
required this.licenseText,
required this.active,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
active ? Icons.verified_user : Icons.error_outline,
color: active ? Colors.green : Colors.orange,
size: 36,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前账户',
style: TextStyle(
color: Colors.grey.shade600, fontSize: 13)),
const SizedBox(height: 4),
Text(
licenseText,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
),
],
),
),
],
),
),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import '../payment/license_service.dart';
import '../payment/models.dart';
import '../payment/order_api.dart';
/// 主界面状态:到期时间展示 + 搜索入口强制校验 + 退出登录
class HomeViewModel extends ChangeNotifier {
final LicenseService licenseService;
LicenseStatus? _license;
bool _loading = true;
bool _verifying = false;
String? _error;
bool _sessionExpired = false;
LicenseStatus? get license => _license;
bool get loading => _loading;
bool get verifying => _verifying;
String? get error => _error;
bool get sessionExpired => _sessionExpired;
HomeViewModel({required this.licenseService});
/// 进入主界面/支付返回后刷新(本地缓存优先,服务端为准)
Future<void> refresh() async {
_loading = true;
_error = null;
notifyListeners();
try {
_license = await licenseService.check();
} catch (_) {
// check 内部已兜底,这里仅防御
} finally {
_loading = false;
notifyListeners();
}
}
/// 搜索按钮:强制服务端校验授权,active 才允许进入相机。
/// 网络失败/会话失效一律不放行;返回值表示是否可进入。
Future<bool> verifyForCamera() async {
_verifying = true;
_sessionExpired = false;
_error = null;
notifyListeners();
try {
final license = await licenseService.verifyServer();
_license = license;
return license.isActive;
} on SessionExpiredException {
_sessionExpired = true;
return false;
} on OrderApiException catch (e) {
_error = e.message;
return false;
} finally {
_verifying = false;
notifyListeners();
}
}
void clearSessionExpired() {
_sessionExpired = false;
notifyListeners();
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:fluwx/fluwx.dart' as fluwx;
import 'package:flutter/material.dart';
import 'package:tobias/tobias.dart' as tobias;
import 'app.dart';
import 'config/app_config.dart';
import 'container.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 注册微信/支付宝 SDK(AppID 占位,接入真实商户后替换 app_config.dart
try {
await fluwx.Fluwx().registerApi(
appId: AppConfig.wechatAppId,
universalLink: AppConfig.wechatUniversalLink,
);
} catch (_) {}
try {
await tobias.Tobias().registerApp(
AppConfig.alipayAppId,
universalLink: AppConfig.alipayUniversalLink,
);
} catch (_) {}
runApp(ObserverApp(container: AppContainer()));
}
@@ -0,0 +1,79 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth/session_store.dart';
import 'models.dart';
import 'order_api.dart';
/// 授权管理:本地缓存用于主界面展示到期时间;识别入口强制服务端校验。
/// 缓存按手机号隔离(键含账号),切换账号不会读到上一账号的到期时间。
class LicenseService {
static const _storage = FlutterSecureStorage();
final OrderApi orderApi;
final SessionStore sessionStore;
LicenseService({required this.orderApi, required this.sessionStore});
/// 主界面展示/启动加载:先读本账号本地缓存,有效则直接用;
/// 无效或缺失时询问服务端(网络失败按缓存兜底,未登录按过期处理)。
Future<LicenseStatus> check() async {
final phone = await sessionStore.readPhone();
if (phone == null) return const LicenseStatus(active: false);
final cached = await _readCache(phone);
final now = DateTime.now();
if (cached != null && cached.expiresAt!.isAfter(now)) return cached;
if (await sessionStore.readToken() == null) {
return const LicenseStatus(active: false);
}
try {
final remote = await orderApi.fetchLicense();
try {
if (remote.active && remote.expiresAt != null) {
await _writeCache(phone, remote.expiresAt!);
} else if (!remote.active) {
await _clearCache(phone);
}
} catch (_) {
// 本地缓存不可用不影响授权状态展示
}
return remote;
} on OrderApiException {
return cached ?? const LicenseStatus(active: false);
}
}
/// 识别入口强制校验:必须走服务端且 active 才放行,失败即抛错(不进相机)
Future<LicenseStatus> verifyServer() async {
if (await sessionStore.readToken() == null) {
throw const SessionExpiredException();
}
return orderApi.fetchLicense();
}
/// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰)
Future<LicenseStatus> refresh() async {
final phone = await sessionStore.readPhone();
if (phone != null) await _clearCache(phone);
return orderApi.fetchLicense();
}
Future<LicenseStatus?> _readCache(String phone) async {
try {
final raw = await _storage.read(key: _cacheKey(phone));
final t = raw == null ? null : DateTime.tryParse(raw);
if (t == null || !t.isAfter(DateTime.now())) return null;
return LicenseStatus(active: true, expiresAt: t);
} catch (_) {
return null;
}
}
Future<void> _writeCache(String phone, DateTime expiresAt) =>
_storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String());
Future<void> _clearCache(String phone) => _storage.delete(key: _cacheKey(phone));
static String _cacheKey(String phone) => 'license_expires_at:$phone';
}
+67
View File
@@ -0,0 +1,67 @@
/// 充值套餐(来自后端 GET /api/v1/plans,价格以服务端 config.yml 为准)
class Plan {
final String id;
final int days;
final int priceYuan;
const Plan({
required this.id,
required this.days,
required this.priceYuan,
});
/// 展示名由天数派生(接口无 label 字段)
String get label => '$days天';
factory Plan.fromJson(Map<String, dynamic> json) => Plan(
id: json['planId'] as String,
days: json['days'] as int,
priceYuan: (json['priceCents'] as num) ~/ 100,
);
}
/// 支付渠道
enum PayChannel { wechat, alipay }
/// 订单(由后端创建)
class Order {
final String orderId;
/// 微信支付参数(prepay_id / partner_id / nonce_str / time_stamp / sign 等)
final Map<String, dynamic> wechatParams;
/// 支付宝订单串(orderStr
final String? alipayOrderStr;
const Order({
required this.orderId,
this.wechatParams = const {},
this.alipayOrderStr,
});
}
/// 授权状态(服务端为准)
class LicenseStatus {
final bool active;
final DateTime? expiresAt;
const LicenseStatus({required this.active, this.expiresAt});
bool get isActive => active && (expiresAt?.isAfter(DateTime.now()) ?? false);
factory LicenseStatus.fromJson(Map<String, dynamic> json) => LicenseStatus(
active: json['active'] == true,
expiresAt: json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String)
: null,
);
}
/// 支付结果
class PayResult {
final bool success;
final String? message;
final String? orderId;
const PayResult({required this.success, this.message, this.orderId});
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:cupertino_http/cupertino_http.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
import '../auth/session_store.dart';
import '../config/app_config.dart';
import 'models.dart';
/// 后端账号/支付/授权 API 客户端(契约见 docs/PaymentApi.md)。
/// 后端未部署或请求失败时抛出 [OrderApiException];登录失效抛出 [SessionExpiredException]
/// 由 UI 层回登录页。
class OrderApiException implements Exception {
final String message;
const OrderApiException(this.message);
@override
String toString() => message;
}
/// 登录已失效(后端返回 code 61):token 过期/被清,UI 应清除会话回登录页
class SessionExpiredException extends OrderApiException {
const SessionExpiredException() : super('登录已失效,请重新登录');
}
class OrderApi {
final String baseUrl;
final SessionStore sessionStore;
final http.Client _client;
// iOS 26 对 dart:io 原生 socket 访问本地网络存在拦截 bug(权限已允许仍
// 拒绝连接),改用 NSURLSession 网络栈(CupertinoClient)绕过;非 Apple
// 平台回退 IOClient。
OrderApi({String? baseUrl, required this.sessionStore, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? _defaultHttpClient();
static http.Client _defaultHttpClient() {
if (!kIsWeb && Platform.isIOS) {
return CupertinoClient.defaultSessionConfiguration();
}
return http.Client();
}
/// 注册账号;重复注册等业务错误由 [_decode] 抛出
Future<void> register({
required String phone,
required String password,
}) async {
final res = await _post('/api/v1/auth/register',
jsonEncode({'phone': phone, 'password': password}),
auth: false);
_decode(res);
}
/// 登录,成功返回 token(由调用方存入 SessionStore
Future<String> login({
required String phone,
required String password,
}) async {
final res = await _post('/api/v1/auth/login',
jsonEncode({'phone': phone, 'password': password}),
auth: false);
final json = _decode(res);
return json['token'] as String;
}
/// 创建订单,返回支付参数(微信 prepay 参数或支付宝 orderStr
Future<Order> createOrder({
required String planId,
required PayChannel channel,
}) async {
final body = jsonEncode({'planId': planId, 'channel': channel.name});
final res = await _post('/api/v1/orders', body);
final json = _decode(res);
final params = (json['payParams'] as Map?)?.cast<String, dynamic>() ?? {};
return Order(
orderId: json['orderId'] as String,
wechatParams: params,
alipayOrderStr: params['orderStr'] as String?,
);
}
/// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权
Future<void> confirmOrder(String orderId) async {
final res =
await _post('/api/v1/orders/$orderId/confirm', jsonEncode({}));
_decode(res);
}
/// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码)
Future<List<Plan>> fetchPlans() async {
final http.Response res;
try {
res = await _client.get(
Uri.parse('$baseUrl/api/v1/plans'),
headers: {'Accept': 'application/json', ...await _authHeaders()},
).timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
final json = _decode(res);
return (json['list'] as List)
.map((e) => Plan.fromJson(e as Map<String, dynamic>))
.toList();
}
/// 查询授权状态(服务端为准)
Future<LicenseStatus> fetchLicense() async {
final http.Response res;
try {
res = await _client.get(
Uri.parse('$baseUrl/api/v1/license'),
headers: {'Accept': 'application/json', ...await _authHeaders()},
).timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
final json = _decode(res);
return LicenseStatus.fromJson(json);
}
Future<http.Response> _post(String path, String body,
{bool auth = true}) async {
try {
return await _client
.post(
Uri.parse('$baseUrl$path'),
headers: {
'Content-Type': 'application/json',
...auth ? await _authHeaders() : const <String, String>{},
},
body: body,
)
.timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
}
Future<Map<String, String>> _authHeaders() async {
final token = await sessionStore.readToken();
if (token == null || token.isEmpty) {
throw const SessionExpiredException();
}
return {'Authorization': 'Bearer $token'};
}
Map<String, dynamic> _decode(http.Response res) {
final Map<String, dynamic> json;
try {
json = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw OrderApiException('服务端响应异常 (${res.statusCode})');
}
if (res.statusCode != 200 || json['code'] != 0) {
final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})';
if (json['code'] == 61) {
throw const SessionExpiredException();
}
throw OrderApiException(message);
}
return json['data'] as Map<String, dynamic>;
}
}
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:fluwx/fluwx.dart' as fluwx;
import 'package:tobias/tobias.dart' as tobias;
import '../config/app_config.dart';
import 'models.dart';
import 'order_api.dart';
/// 支付服务抽象:创建订单 + 拉起渠道支付 + 返回支付结果
abstract class PaymentService {
/// 支付完成后会通知服务端落授权;成功与否以 [PayResult.success] 为准
Future<PayResult> pay(Plan plan, PayChannel channel);
}
/// 微信支付(fluwx 实现)
class WechatPayService implements PaymentService {
WechatPayService({required this.orderApi});
final OrderApi orderApi;
@override
Future<PayResult> pay(Plan plan, PayChannel channel) async {
assert(channel == PayChannel.wechat);
final order =
await orderApi.createOrder(planId: plan.id, channel: channel);
final p = order.wechatParams;
final launched = await fluwx.Fluwx().pay(
which: fluwx.Payment(
appId: AppConfig.wechatAppId,
partnerId: p['partnerId'] as String? ?? '',
prepayId: p['prepayId'] as String? ?? '',
packageValue: p['packageValue'] as String? ?? 'Sign=WXPay',
nonceStr: p['nonceStr'] as String? ?? '',
timestamp: int.tryParse('${p['timeStamp'] ?? p['timestamp']}') ?? 0,
sign: p['sign'] as String? ?? '',
),
);
if (!launched) {
return const PayResult(
success: false, message: '未安装微信或拉起支付失败,请稍后再试');
}
// 等待微信回调(errCode == 0 为成功),10s 超时
final completer = Completer<PayResult>();
final cancel = fluwx.Fluwx().addSubscriber((response) {
if (response is! fluwx.WeChatPaymentResponse) return;
completer.complete(PayResult(
success: response.isSuccessful,
message: response.isSuccessful ? null : (response.errStr ?? '微信支付未完成'),
orderId: order.orderId,
));
});
try {
final result =
await completer.future.timeout(const Duration(seconds: 10));
if (result.success) await orderApi.confirmOrder(order.orderId);
return result;
} on TimeoutException {
return PayResult(
success: false,
message: '等待微信支付结果超时,请确认支付状态',
orderId: order.orderId);
} finally {
cancel.cancel();
}
}
}
/// 支付宝支付(tobias 实现,resultStatus 9000 为成功)
class AlipayService implements PaymentService {
AlipayService({required this.orderApi});
final OrderApi orderApi;
@override
Future<PayResult> pay(Plan plan, PayChannel channel) async {
assert(channel == PayChannel.alipay);
final order =
await orderApi.createOrder(planId: plan.id, channel: channel);
final orderStr = order.alipayOrderStr;
if (orderStr == null) {
return const PayResult(success: false, message: '订单缺少支付参数');
}
final map = await tobias.Tobias().pay(
orderStr,
universalLink: AppConfig.alipayUniversalLink,
);
final status = map['resultStatus']?.toString() ?? '';
final ok = status == '9000';
if (ok) await orderApi.confirmOrder(order.orderId);
return PayResult(
success: ok,
message: ok ? null : _alipayMessage(status),
orderId: order.orderId,
);
}
static String _alipayMessage(String status) => switch (status) {
'8000' => '支付结果确认中,请稍后查看',
'6001' => '用户取消支付',
'6002' => '网络异常,支付未完成',
'6004' => '支付结果未知,请查询订单状态',
_ => '支付宝支付未完成',
};
}
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'models.dart';
import 'paywall_view_model.dart';
/// 付费墙:拉取套餐价格 → 选套餐 → 微信/支付宝支付 → 解锁进入相机
class PaywallScreen extends StatefulWidget {
const PaywallScreen({super.key});
@override
State<PaywallScreen> createState() => _PaywallScreenState();
}
class _PaywallScreenState extends State<PaywallScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<PaywallViewModel>().loadPlans();
});
}
@override
Widget build(BuildContext context) {
final vm = context.watch<PaywallViewModel>();
final loading = vm.state.state == PayState.loading;
return Scaffold(
appBar: AppBar(title: const Text('视野 · 会员'), centerTitle: true),
body: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Text(
'开通会员解锁完整功能,按自然日计费,到期自动失效',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
_PlansSection(vm: vm, loading: loading),
const SizedBox(height: 24),
_PayButton(
label: '微信支付',
icon: Icons.wechat,
color: const Color(0xFF07C160),
enabled: !loading && vm.state.selectedPlan != null,
onTap: loading
? null
: () async {
final license = await vm.pay(PayChannel.wechat);
if (license != null && context.mounted) {
_onPaid(context);
}
},
),
const SizedBox(height: 12),
_PayButton(
label: '支付宝支付',
icon: Icons.account_balance_wallet,
color: const Color(0xFF1677FF),
enabled: !loading && vm.state.selectedPlan != null,
onTap: loading
? null
: () async {
final license = await vm.pay(PayChannel.alipay);
if (license != null && context.mounted) {
_onPaid(context);
}
},
),
if (vm.state.state == PayState.failed)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
vm.state.message ?? '支付失败',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
),
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
/// 支付成功:返回主界面(主界面刷新展示新到期时间)
void _onPaid(BuildContext context) {
Navigator.of(context).pop();
}
}
/// 套餐区三态:加载中 / 失败可重试 / 套餐卡片列表
class _PlansSection extends StatelessWidget {
final PaywallViewModel vm;
final bool loading;
const _PlansSection({required this.vm, required this.loading});
@override
Widget build(BuildContext context) {
if (vm.plansLoading && vm.plans.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: CircularProgressIndicator()),
);
}
if (vm.plansError != null && vm.plans.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Text(
vm.plansError!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.read<PaywallViewModel>().loadPlans(),
child: const Text('重试'),
),
],
),
);
}
return Column(
children: [
...vm.plans.map((p) => _PlanCard(
plan: p,
selected: vm.state.selectedPlan?.id == p.id,
enabled: !loading,
onTap: () => vm.selectPlan(p),
)),
],
);
}
}
class _PlanCard extends StatelessWidget {
final Plan plan;
final bool selected;
final bool enabled;
final VoidCallback onTap;
const _PlanCard({
required this.plan,
required this.selected,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: selected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: selected ? Colors.orange : Colors.grey.shade300,
width: selected ? 2 : 1,
),
),
child: ListTile(
onTap: enabled ? onTap : null,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
title: Text(
plan.label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 17),
),
subtitle: Text('${plan.days} 天 · 自然日'),
trailing: Text(
'¥${plan.priceYuan}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
);
}
}
class _PayButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool enabled;
final VoidCallback? onTap;
const _PayButton({
required this.label,
required this.icon,
required this.color,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: color,
disabledBackgroundColor: Colors.grey.shade300,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: enabled ? onTap : null,
icon: Icon(icon, size: 20),
label: Text(label, style: const TextStyle(fontSize: 16)),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/foundation.dart';
import 'license_service.dart';
import 'models.dart';
import 'order_api.dart';
import 'payment_service.dart';
enum PayState { idle, loading, success, failed }
@immutable
class PaywallUiState {
final PayState state;
final String? message;
final Plan? selectedPlan;
const PaywallUiState({
this.state = PayState.idle,
this.message,
this.selectedPlan,
});
}
class PaywallViewModel extends ChangeNotifier {
final OrderApi orderApi;
final LicenseService licenseService;
final Map<PayChannel, PaymentService> services;
PaywallUiState _state = const PaywallUiState();
PaywallUiState get state => _state;
List<Plan> _plans = const [];
bool _plansLoading = false;
String? _plansError;
/// 套餐价格方案(来自后端,客户端不硬编码)
List<Plan> get plans => _plans;
bool get plansLoading => _plansLoading;
String? get plansError => _plansError;
PaywallViewModel({
required this.orderApi,
required this.licenseService,
required this.services,
});
/// 进充值页时拉取套餐;已有数据或加载中跳过,失败可重试
Future<void> loadPlans() async {
if (_plans.isNotEmpty || _plansLoading) return;
_plansLoading = true;
_plansError = null;
notifyListeners();
try {
_plans = await orderApi.fetchPlans();
} on SessionExpiredException {
_plansError = '登录已失效,请重新登录后再充值';
} on OrderApiException catch (e) {
_plansError = e.message;
} finally {
_plansLoading = false;
notifyListeners();
}
}
void selectPlan(Plan? plan) {
_state = PaywallUiState(selectedPlan: plan);
notifyListeners();
}
/// 发起支付;成功后刷新授权并通知页面进入相机
Future<LicenseStatus?> pay(PayChannel channel) async {
final plan = _state.selectedPlan;
if (plan == null) return null;
final service = services[channel];
if (service == null) return null;
_state = PaywallUiState(state: PayState.loading, selectedPlan: plan);
notifyListeners();
try {
final result = await service.pay(plan, channel);
if (!result.success) {
_state = PaywallUiState(
state: PayState.failed,
message: result.message ?? '支付失败,请重试',
selectedPlan: plan,
);
notifyListeners();
return null;
}
final license = await licenseService.refresh();
_state = PaywallUiState(
state: PayState.success,
selectedPlan: plan,
);
notifyListeners();
return license;
} on SessionExpiredException {
_state = PaywallUiState(
state: PayState.failed,
message: '登录已失效,请重新登录后再充值',
selectedPlan: plan,
);
notifyListeners();
return null;
} on OrderApiException catch (e) {
_state = PaywallUiState(
state: PayState.failed,
message: '支付不可用: ${e.message}',
selectedPlan: plan,
);
notifyListeners();
return null;
}
}
void reset() {
_state = const PaywallUiState();
notifyListeners();
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:vibration/vibration.dart';
/// 提醒:同类目标 10s 内只提醒一次。
/// 震动/提示音默认开启,不提供关闭入口。
class Reminder {
final AudioPlayer _player = AudioPlayer();
String? _lastAlertLabel;
int _lastAlertAt = 0;
/// 同类目标 10s 内只提醒一次
void onDetected(String label) {
final now = DateTime.now().millisecondsSinceEpoch;
if (label == _lastAlertLabel && now - _lastAlertAt < 10000) return;
_lastAlertAt = now;
_lastAlertLabel = label;
_vibrate();
_playTone();
}
void _vibrate() => Vibration.vibrate(duration: 200);
Future<void> _playTone() async {
await _player.stop();
await _player.play(AssetSource('beep.wav'));
}
void release() {
_player.dispose();
}
}