Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
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
literal 0
HcmV?d00001
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8
GIT binary patch
literal 1418
zcmV;51$Fv~P)q
zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+
zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq
z^={4hPQv)y=I|4n+?>7Fim=dxt1
z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT
zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf`
zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_>
z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3
zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF
z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a
z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE
z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(
zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;?
zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-<
z{s<&cCV_1`^TD^ia9!*mQDq&
zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw
zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv
zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF
z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC
YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H
literal 0
HcmV?d00001
diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flutter_app/ios/Runner/Base.lproj/Main.storyboard b/flutter_app/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..cd31a81
--- /dev/null
+++ b/flutter_app/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flutter_app/ios/Runner/Info.plist b/flutter_app/ios/Runner/Info.plist
new file mode 100644
index 0000000..1f9a4d8
--- /dev/null
+++ b/flutter_app/ios/Runner/Info.plist
@@ -0,0 +1,108 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ 视野
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ observer
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ wx0000000000000000
+
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ alipay0000000000
+
+
+
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSApplicationQueriesSchemes
+
+ weixin
+ weixinULAPI
+ weixinURLParamsAPI
+ alipay
+ alipays
+
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsArbitraryLoadsInWebContent
+
+
+ NSCameraUsageDescription
+ 需要使用相机进行野生动物实时识别
+ NSLocalNetworkUsageDescription
+ 需要通过本地网络连接服务器进行账号验证和支付
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/flutter_app/ios/Runner/Runner-Bridging-Header.h b/flutter_app/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/flutter_app/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/flutter_app/ios/Runner/Runner.entitlements b/flutter_app/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..6631ffa
--- /dev/null
+++ b/flutter_app/ios/Runner/Runner.entitlements
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/flutter_app/ios/Runner/SceneDelegate.swift b/flutter_app/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/flutter_app/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/flutter_app/ios/RunnerTests/RunnerTests.swift b/flutter_app/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/flutter_app/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart
new file mode 100644
index 0000000..0a1b306
--- /dev/null
+++ b/flutter_app/lib/app.dart
@@ -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 createState() => _StartupGateState();
+}
+
+class _StartupGateState extends State {
+ @override
+ void initState() {
+ super.initState();
+ _check();
+ }
+
+ Future _check() async {
+ final session = context.read();
+ 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()),
+ );
+ }
+}
diff --git a/flutter_app/lib/auth/auth_screen.dart b/flutter_app/lib/auth/auth_screen.dart
new file mode 100644
index 0000000..0f868ba
--- /dev/null
+++ b/flutter_app/lib/auth/auth_screen.dart
@@ -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 createState() => _AuthScreenState();
+}
+
+class _AuthScreenState extends State {
+ 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 _submit() async {
+ final vm = context.read();
+ 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();
+ 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),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter_app/lib/auth/auth_view_model.dart b/flutter_app/lib/auth/auth_view_model.dart
new file mode 100644
index 0000000..8b52862
--- /dev/null
+++ b/flutter_app/lib/auth/auth_view_model.dart
@@ -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 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();
+ }
+ }
+}
diff --git a/flutter_app/lib/auth/session_store.dart b/flutter_app/lib/auth/session_store.dart
new file mode 100644
index 0000000..9c025ef
--- /dev/null
+++ b/flutter_app/lib/auth/session_store.dart
@@ -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 readToken() async {
+ if (_cachedToken != null) return _cachedToken;
+ return _cachedToken = await _storage.read(key: _tokenKey);
+ }
+
+ Future readPhone() async {
+ if (_cachedPhone != null) return _cachedPhone;
+ return _cachedPhone = await _storage.read(key: _phoneKey);
+ }
+
+ Future 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 clear() async {
+ _cachedPhone = null;
+ _cachedToken = null;
+ await _storage.delete(key: _phoneKey);
+ await _storage.delete(key: _tokenKey);
+ }
+}
diff --git a/flutter_app/lib/camera/app_camera_controller.dart b/flutter_app/lib/camera/app_camera_controller.dart
new file mode 100644
index 0000000..2adadf3
--- /dev/null
+++ b/flutter_app/lib/camera/app_camera_controller.dart
@@ -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 cameras;
+ CameraController? controller;
+
+ /// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
+ int streamCallbacks = 0;
+
+ AppCameraController._(this.cameras);
+
+ static Future 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 start(FrameAnalyzer analyzer) async {
+ await stop();
+ final desc = cameras.firstWhere(
+ (c) => c.lensDirection == CameraLensDirection.back,
+ orElse: () => cameras.first);
+ // iOS 用默认 bgra8888(420v 在部分 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 stop() async {
+ final c = controller;
+ if (c == null) return;
+ controller = null;
+ try {
+ await c.stopImageStream();
+ } catch (_) {}
+ await c.dispose();
+ }
+}
diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart
new file mode 100644
index 0000000..59acee1
--- /dev/null
+++ b/flutter_app/lib/camera/camera_screen.dart
@@ -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 createState() => _CameraScreenState();
+}
+
+class _CameraScreenState extends State {
+ 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 _init() async {
+ final granted = await Permission.camera.request().isGranted;
+ if (!mounted) return;
+ setState(() => _permissionGranted = granted);
+ if (!granted) return;
+
+ // 模型加载/推理在后台 isolate,不阻塞 UI;worker 为 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 _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('授权相机')),
+ ],
+ ),
+ );
+ }
+}
diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart
new file mode 100644
index 0000000..bae2886
--- /dev/null
+++ b/flutter_app/lib/camera/camera_view_model.dart
@@ -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 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 _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 results,
+ int rotation,
+ int imageWidthPx,
+ int imageHeightPx,
+ List motionRegions,
+ List 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 = [];
+ 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 results,
+ List motionRegions,
+ List noveltyRegions,
+ int now) {
+ final matched = {};
+ 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 motionRegions,
+ List 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 motionRegions,
+ List 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;
+ }
+}
diff --git a/flutter_app/lib/camera/detection_overlay.dart b/flutter_app/lib/camera/detection_overlay.dart
new file mode 100644
index 0000000..888f925
--- /dev/null
+++ b/flutter_app/lib/camera/detection_overlay.dart
@@ -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