diff --git a/.gitignore b/.gitignore
index df6af72..df8341d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,16 @@ captures/
.DS_Store
training/datasets/
training/venv/
+training/runs/
*.pt
+*.tflite
+
+# 后端运行时数据(SQLite 库,删除即丢授权/订单)
+server/data/
+server/biz/service/testdata/*.db
+
+# 管理端构建产物(server_admin 构建生成,由后端托管)
+server/admin_dist/
# 文生图配置(含API key,不入库)
#training/gen_images_config.json
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
deleted file mode 100644
index 2767ee3..0000000
--- a/app/build.gradle.kts
+++ /dev/null
@@ -1,80 +0,0 @@
-plugins {
- alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.compose)
-}
-
-android {
- namespace = "com.example.observer"
- compileSdk = 35
-
- defaultConfig {
- applicationId = "com.example.observer"
- minSdk = 24
- targetSdk = 35
- versionCode = 1
- versionName = "1.0"
-
- ndk {
- abiFilters += listOf("arm64-v8a", "armeabi-v7a")
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro",
- )
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = "17"
- }
-
- buildFeatures {
- compose = true
- }
-
- packaging {
- resources {
- excludes += "/META-INF/{AL2.0,LGPL2.1}"
- }
- }
-}
-
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.lifecycle.viewmodel.compose)
- implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.androidx.activity.compose)
-
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.compose.ui)
- implementation(libs.androidx.compose.ui.graphics)
- implementation(libs.androidx.compose.ui.tooling.preview)
- implementation(libs.androidx.compose.material3)
- debugImplementation(libs.androidx.compose.ui.tooling)
-
- implementation(libs.androidx.camera.core)
- implementation(libs.androidx.camera.camera2)
- implementation(libs.androidx.camera.lifecycle)
- implementation(libs.androidx.camera.view)
-
- implementation(libs.org.tensorflow.lite)
- implementation(libs.org.tensorflow.lite.gpu)
-
- implementation(libs.androidx.datastore.preferences)
- implementation(libs.kotlinx.coroutines.android)
-
- testImplementation(libs.junit)
-}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
deleted file mode 100644
index 2927948..0000000
--- a/app/proguard-rules.pro
+++ /dev/null
@@ -1,3 +0,0 @@
-# TensorFlow Lite
--keep class org.tensorflow.** { *; }
--dontwarn org.tensorflow.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 6879cfb..0000000
--- a/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/java/com/example/observer/MainActivity.kt b/app/src/main/java/com/example/observer/MainActivity.kt
deleted file mode 100644
index 6366940..0000000
--- a/app/src/main/java/com/example/observer/MainActivity.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example.observer
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import com.example.observer.ui.ObserverApp
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- enableEdgeToEdge()
- super.onCreate(savedInstanceState)
- setContent {
- ObserverApp()
- }
- }
-}
diff --git a/app/src/main/java/com/example/observer/ObserverApp.kt b/app/src/main/java/com/example/observer/ObserverApp.kt
deleted file mode 100644
index d9fb221..0000000
--- a/app/src/main/java/com/example/observer/ObserverApp.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.example.observer
-
-import android.app.Application
-import android.content.Context
-import com.example.observer.data.SettingsRepository
-import com.example.observer.detection.Detector
-import com.example.observer.detection.TFLiteDetector
-import com.example.observer.distance.DistanceEstimator
-import com.example.observer.reminder.Reminder
-
-class ObserverApp : Application() {
- lateinit var container: AppContainer
- private set
-
- override fun onCreate() {
- super.onCreate()
- container = AppContainer(this)
- }
-}
-
-class AppContainer(context: Context) {
- val settingsRepository = SettingsRepository(context)
- val detector: Detector? = TFLiteDetector.create(context)
- val distanceEstimator = DistanceEstimator(context)
- val reminder = Reminder(context)
-}
diff --git a/app/src/main/java/com/example/observer/camera/CameraController.kt b/app/src/main/java/com/example/observer/camera/CameraController.kt
deleted file mode 100644
index c5ec446..0000000
--- a/app/src/main/java/com/example/observer/camera/CameraController.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.example.observer.camera
-
-import android.util.Log
-import androidx.camera.core.Camera
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.ImageAnalysis
-import androidx.camera.core.Preview
-import androidx.camera.camera2.interop.Camera2CameraInfo
-import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.camera.view.PreviewView
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.LifecycleOwner
-import java.util.concurrent.Executor
-
-class CameraController(
- private val lifecycleOwner: LifecycleOwner,
- private val analysisExecutor: Executor,
-) {
- var onCameraIdChanged: ((String) -> Unit)? = null
-
- var onInitError: (() -> Unit)? = null
-
- var currentCameraId: String? = null
- private set
-
- private var cameraProvider: ProcessCameraProvider? = null
- private var isFrontFacing = false
-
- fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
- val future = ProcessCameraProvider.getInstance(previewView.context)
- future.addListener({
- try {
- val provider = future.get()
- cameraProvider = provider
- bind(previewView, analyzer)
- } catch (e: Exception) {
- Log.e(TAG, "相机初始化失败", e)
- onInitError?.invoke()
- }
- }, ContextCompat.getMainExecutor(previewView.context))
- }
-
- fun switchCamera(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
- isFrontFacing = !isFrontFacing
- bind(previewView, analyzer)
- }
-
- private fun bind(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) {
- val provider = cameraProvider ?: return
- provider.unbindAll()
-
- val preview = Preview.Builder().build().also {
- it.surfaceProvider = previewView.surfaceProvider
- }
- val imageAnalysis = ImageAnalysis.Builder()
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
- .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
- .build()
- analyzer?.let { imageAnalysis.setAnalyzer(analysisExecutor, it) }
-
- val camera: Camera = provider.bindToLifecycle(
- lifecycleOwner,
- currentSelector(),
- preview,
- imageAnalysis,
- )
- currentCameraId = try {
- Camera2CameraInfo.from(camera.cameraInfo).cameraId
- } catch (e: Exception) {
- Log.w(TAG, "获取 cameraId 失败", e)
- null
- }
- currentCameraId?.let { onCameraIdChanged?.invoke(it) }
- }
-
- private fun currentSelector(): CameraSelector =
- if (isFrontFacing) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
-
- companion object {
- private const val TAG = "CameraController"
- }
-}
diff --git a/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt b/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt
deleted file mode 100644
index 75d9a15..0000000
--- a/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.example.observer.camera
-
-import android.os.SystemClock
-import androidx.camera.core.ImageAnalysis
-import androidx.camera.core.ImageProxy
-import com.example.observer.detection.DetectionResult
-import com.example.observer.detection.Detector
-import com.example.observer.detection.MotionRegion
-
-class FrameAnalyzer(
- private val detector: Detector,
- private val motionDetector: MotionDetector,
- private val onResult: (results: List, rotation: Int, imageWidthPx: Int, imageHeightPx: Int, motionRegions: List) -> Unit,
-) : ImageAnalysis.Analyzer {
-
- @Volatile var intervalMs: Long = 100L
-
- private var lastDetectMs = 0L
-
- override fun analyze(imageProxy: ImageProxy) {
- val now = SystemClock.elapsedRealtime()
- if (now - lastDetectMs < intervalMs) {
- imageProxy.close()
- return
- }
- lastDetectMs = now
-
- val bitmap = imageProxy.toBitmap()
- val rotation = imageProxy.imageInfo.rotationDegrees
- try {
- val motionRegions = motionDetector.detectMotion(bitmap)
- val results = detector.detect(bitmap)
- onResult(results, rotation, bitmap.width, bitmap.height, motionRegions)
- } finally {
- imageProxy.close()
- }
- }
-}
diff --git a/app/src/main/java/com/example/observer/camera/MotionDetector.kt b/app/src/main/java/com/example/observer/camera/MotionDetector.kt
deleted file mode 100644
index e173899..0000000
--- a/app/src/main/java/com/example/observer/camera/MotionDetector.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.example.observer.camera
-
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.Rect
-import com.example.observer.detection.MotionAggregator
-import com.example.observer.detection.MotionRegion
-import kotlin.math.min
-
-/**
- * 轻量运动检测:相邻帧灰度差分 + 分块聚合。
- * 小尺寸工作(约 128x128 内),每帧开销亚毫秒级,在分析线程串行调用。
- * 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。
- */
-class MotionDetector(
- private val maxWidth: Int = 128,
- private val maxHeight: Int = 128,
-) {
-
- private val smallBitmap = Bitmap.createBitmap(maxWidth, maxHeight, Bitmap.Config.ARGB_8888)
- private val canvas = Canvas(smallBitmap)
- private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
-
- private var prevGray: IntArray? = null
- private var grayCache: IntArray = IntArray(0)
-
- fun detectMotion(bitmap: Bitmap): List {
- val scale = min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height)
- val w = (bitmap.width * scale).toInt()
- val h = (bitmap.height * scale).toInt()
- if (w == 0 || h == 0) return emptyList()
-
- canvas.drawBitmap(bitmap, null, Rect(0, 0, w, h), paint)
- if (grayCache.size != w * h) grayCache = IntArray(w * h)
- smallBitmap.getPixels(grayCache, 0, w, 0, 0, w, h)
-
- val prev = prevGray
- prevGray = grayCache.copyOf()
- if (prev == null || prev.size != grayCache.size) return emptyList()
-
- // 取绿色通道近似亮度
- val gray = IntArray(grayCache.size)
- for (i in grayCache.indices) gray[i] = (grayCache[i] shr 8) and 0xFF
- val prevGreen = IntArray(prev.size)
- for (i in prev.indices) prevGreen[i] = (prev[i] shr 8) and 0xFF
-
- val diff = MotionAggregator.diffMask(gray, prevGreen)
- val motionTotal = diff.sum()
- // 全屏大差异 → 相机移动/大范围变化,忽略本帧
- if (motionTotal > w * h / 2) return emptyList()
- if (motionTotal < 12) return emptyList()
- return MotionAggregator.aggregate(diff, w, h)
- }
-
- /** 相机切换后重置参考帧,避免旧帧误差 */
- fun reset() {
- prevGray = null
- }
-}
diff --git a/app/src/main/java/com/example/observer/data/SettingsRepository.kt b/app/src/main/java/com/example/observer/data/SettingsRepository.kt
deleted file mode 100644
index 9ae6eb0..0000000
--- a/app/src/main/java/com/example/observer/data/SettingsRepository.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.example.observer.data
-
-import android.content.Context
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.floatPreferencesKey
-import androidx.datastore.preferences.core.stringPreferencesKey
-import androidx.datastore.preferences.preferencesDataStore
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-
-enum class DetectMode(val intervalMs: Long, val label: String) {
- CONTINUOUS(100L, "连续"),
- STANDARD(300L, "标准"),
- POWER_SAVING(1000L, "省电"),
-}
-
-data class AppSettings(
- val confThreshold: Float = 0.40f,
- val habitatThreshold: Float = 0.35f,
- val detectMode: DetectMode = DetectMode.CONTINUOUS,
- val vibrateEnabled: Boolean = true,
- val soundEnabled: Boolean = true,
- val showDistance: Boolean = true,
- val habitatAlertEnabled: Boolean = true,
-)
-
-private val Context.settingsDataStore by preferencesDataStore(name = "settings")
-
-class SettingsRepository(private val context: Context) {
-
- val settings: Flow = context.settingsDataStore.data.map { prefs ->
- AppSettings(
- confThreshold = prefs[KEY_CONF] ?: 0.40f,
- habitatThreshold = prefs[KEY_HABITAT] ?: 0.35f,
- detectMode = DetectMode.entries.firstOrNull { it.name == prefs[KEY_MODE] }
- ?: DetectMode.CONTINUOUS,
- vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
- soundEnabled = prefs[KEY_SOUND] ?: true,
- showDistance = prefs[KEY_DISTANCE] ?: true,
- habitatAlertEnabled = prefs[KEY_HABITAT_ALERT] ?: true,
- )
- }
-
- suspend fun setConfThreshold(value: Float) =
- context.settingsDataStore.edit { it[KEY_CONF] = value }
-
- suspend fun setHabitatThreshold(value: Float) =
- context.settingsDataStore.edit { it[KEY_HABITAT] = value }
-
- suspend fun setDetectMode(mode: DetectMode) =
- context.settingsDataStore.edit { it[KEY_MODE] = mode.name }
-
- suspend fun setVibrateEnabled(value: Boolean) =
- context.settingsDataStore.edit { it[KEY_VIBRATE] = value }
-
- suspend fun setSoundEnabled(value: Boolean) =
- context.settingsDataStore.edit { it[KEY_SOUND] = value }
-
- suspend fun setShowDistance(value: Boolean) =
- context.settingsDataStore.edit { it[KEY_DISTANCE] = value }
-
- suspend fun setHabitatAlertEnabled(value: Boolean) =
- context.settingsDataStore.edit { it[KEY_HABITAT_ALERT] = value }
-
- companion object {
- private val KEY_CONF = floatPreferencesKey("conf_threshold")
- private val KEY_HABITAT = floatPreferencesKey("habitat_threshold")
- private val KEY_MODE = stringPreferencesKey("detect_mode")
- private val KEY_VIBRATE = booleanPreferencesKey("vibrate_enabled")
- private val KEY_SOUND = booleanPreferencesKey("sound_enabled")
- private val KEY_DISTANCE = booleanPreferencesKey("show_distance")
- private val KEY_HABITAT_ALERT = booleanPreferencesKey("habitat_alert_enabled")
- }
-}
diff --git a/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt b/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt
deleted file mode 100644
index dcd84ec..0000000
--- a/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.example.observer.detection
-
-/** 预览视图像素坐标矩形(纯 JVM 类型,便于单元测试) */
-data class ViewRect(
- val left: Float,
- val top: Float,
- val right: Float,
- val bottom: Float,
-) {
- val width: Float get() = right - left
- val height: Float get() = bottom - top
- val centerX: Float get() = (left + right) / 2f
- val centerY: Float get() = (top + bottom) / 2f
-}
-
-object CoordinateMapper {
-
- /** 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪) */
- fun mapToView(
- normLeft: Float,
- normTop: Float,
- normRight: Float,
- normBottom: Float,
- rotation: Int, // ImageProxy.imageInfo.rotationDegrees
- imageW: Int,
- imageH: Int, // 分析图像尺寸(横屏原图)
- viewW: Int,
- viewH: Int, // 预览视图尺寸
- ): ViewRect {
- // 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标)
- val (x0, y0, x1, y1) = when (rotation) {
- 90 -> Quad(1 - normBottom, normLeft, 1 - normTop, normRight)
- 180 -> Quad(1 - normRight, 1 - normBottom, 1 - normLeft, 1 - normTop)
- 270 -> Quad(normTop, 1 - normRight, normBottom, 1 - normLeft)
- else -> Quad(normLeft, normTop, normRight, normBottom)
- }
- // 2) 旋转后图像在竖屏方向上的尺寸
- val portrait = rotation == 90 || rotation == 270
- val portW = if (portrait) imageH else imageW
- val portH = if (portrait) imageW else imageH
- // 3) FIT_CENTER 缩放与居中偏移
- val scale = minOf(viewW.toFloat() / portW, viewH.toFloat() / portH)
- val offsetX = (viewW - portW * scale) / 2f
- val offsetY = (viewH - portH * scale) / 2f
- return ViewRect(
- left = x0 * portW * scale + offsetX,
- top = y0 * portH * scale + offsetY,
- right = x1 * portW * scale + offsetX,
- bottom = y1 * portH * scale + offsetY,
- )
- }
-
- private data class Quad(val x0: Float, val y0: Float, val x1: Float, val y1: Float)
-}
diff --git a/app/src/main/java/com/example/observer/detection/DetectionResult.kt b/app/src/main/java/com/example/observer/detection/DetectionResult.kt
deleted file mode 100644
index 0d49bc3..0000000
--- a/app/src/main/java/com/example/observer/detection/DetectionResult.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.example.observer.detection
-
-data class DetectionResult(
- val label: String,
- val score: Float,
- val left: Float,
- val top: Float,
- val right: Float,
- val bottom: Float,
- val distanceM: Float? = null,
-) {
- val width: Float get() = right - left
- val height: Float get() = bottom - top
- val centerX: Float get() = (left + right) / 2f
- val centerY: Float get() = (top + bottom) / 2f
- val isHabitat: Boolean get() = label == HABITAT_LABEL
-
- companion object {
- const val HABITAT_LABEL = "cover"
- }
-}
diff --git a/app/src/main/java/com/example/observer/detection/Detector.kt b/app/src/main/java/com/example/observer/detection/Detector.kt
deleted file mode 100644
index 4c4df00..0000000
--- a/app/src/main/java/com/example/observer/detection/Detector.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.example.observer.detection
-
-import android.graphics.Bitmap
-
-interface Detector {
- /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */
- fun detect(bitmap: Bitmap): List
-}
diff --git a/app/src/main/java/com/example/observer/detection/MotionAggregator.kt b/app/src/main/java/com/example/observer/detection/MotionAggregator.kt
deleted file mode 100644
index 0ead024..0000000
--- a/app/src/main/java/com/example/observer/detection/MotionAggregator.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package com.example.observer.detection
-
-import kotlin.math.abs
-
-/**
- * 帧差运动聚合(纯 JVM,可单测)。
- * 输入:每像素 0/1 差分掩码,按 8x8 分块统计激活块,连通块聚合为运动区域。
- */
-object MotionAggregator {
-
- private const val BLOCK_GRID = 8
- private const val BLOCK_ACTIVE_RATIO = 0.30f
- private const val MAX_REGIONS = 3
-
- fun aggregate(
- diff: IntArray,
- width: Int,
- height: Int,
- ): List {
- val bw = width / BLOCK_GRID
- val bh = height / BLOCK_GRID
- if (bw == 0 || bh == 0) return emptyList()
-
- val active = BooleanArray(BLOCK_GRID * BLOCK_GRID)
- for (by in 0 until BLOCK_GRID) {
- for (bx in 0 until BLOCK_GRID) {
- val blockW = if (bx == BLOCK_GRID - 1) width - bx * bw else bw
- val blockH = if (by == BLOCK_GRID - 1) height - by * bh else bh
- var motion = 0
- for (y in by * bh until by * bh + blockH) {
- var idx = y * width + bx * bw
- for (x in 0 until blockW) {
- motion += diff[idx + x]
- }
- idx += width
- }
- active[by * BLOCK_GRID + bx] = motion > blockW * blockH * BLOCK_ACTIVE_RATIO
- }
- }
-
- // 连通块聚合(4 邻域)
- val regions = ArrayList(MAX_REGIONS)
- val visited = BooleanArray(active.size)
- for (i in active.indices) {
- if (!active[i] || visited[i]) continue
- var minX = BLOCK_GRID
- var minY = BLOCK_GRID
- var maxX = -1
- var maxY = -1
- val stack = ArrayDeque()
- stack.add(i)
- visited[i] = true
- while (stack.isNotEmpty()) {
- val cur = stack.removeLast()
- val bx = cur % BLOCK_GRID
- val by = cur / BLOCK_GRID
- minX = minOf(minX, bx)
- maxX = maxOf(maxX, bx)
- minY = minOf(minY, by)
- maxY = maxOf(maxY, by)
- for (nb in neighbors(cur)) {
- if (active[nb] && !visited[nb]) {
- visited[nb] = true
- stack.add(nb)
- }
- }
- }
- if (maxX - minX > 3 || maxY - minY > 3) continue // 全屏噪声过滤
- regions += MotionRegion(
- left = minX * bw / width.toFloat(),
- top = minY * bh / height.toFloat(),
- right = ((maxX + 1) * bw).coerceAtMost(width) / width.toFloat(),
- bottom = ((maxY + 1) * bh).coerceAtMost(height) / height.toFloat(),
- )
- if (regions.size >= MAX_REGIONS) break
- }
- return regions
- }
-
- private fun neighbors(i: Int): List {
- val bx = i % BLOCK_GRID
- val by = i / BLOCK_GRID
- val list = ArrayList(4)
- if (bx > 0) list += i - 1
- if (bx < BLOCK_GRID - 1) list += i + 1
- if (by > 0) list += i - BLOCK_GRID
- if (by < BLOCK_GRID - 1) list += i + BLOCK_GRID
- return list
- }
-
- /** 检测框中心是否落在运动区域内(用于置信度提升判定) */
- fun centerInRegion(box: DetectionResult, region: MotionRegion): Boolean =
- box.centerX in region.left..region.right && box.centerY in region.top..region.bottom
-
- /** 帧差掩码生成(纯 JVM):|g - prev| > threshold → 1 */
- fun diffMask(gray: IntArray, prev: IntArray, threshold: Int = 25): IntArray {
- val diff = IntArray(gray.size)
- for (i in gray.indices) {
- diff[i] = if (abs(gray[i] - prev[i]) > threshold) 1 else 0
- }
- return diff
- }
-}
diff --git a/app/src/main/java/com/example/observer/detection/MotionRegion.kt b/app/src/main/java/com/example/observer/detection/MotionRegion.kt
deleted file mode 100644
index cb49cf9..0000000
--- a/app/src/main/java/com/example/observer/detection/MotionRegion.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.example.observer.detection
-
-/** 运动区域(归一化坐标) */
-data class MotionRegion(
- val left: Float,
- val top: Float,
- val right: Float,
- val bottom: Float,
-) {
- val centerX: Float get() = (left + right) / 2f
- val centerY: Float get() = (top + bottom) / 2f
-}
diff --git a/app/src/main/java/com/example/observer/detection/Nms.kt b/app/src/main/java/com/example/observer/detection/Nms.kt
deleted file mode 100644
index 8aca430..0000000
--- a/app/src/main/java/com/example/observer/detection/Nms.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.example.observer.detection
-
-fun iou(a: DetectionResult, b: DetectionResult): Float {
- val x0 = maxOf(a.left, b.left)
- val y0 = maxOf(a.top, b.top)
- val x1 = minOf(a.right, b.right)
- val y1 = minOf(a.bottom, b.bottom)
- if (x1 <= x0 || y1 <= y0) return 0f
- val inter = (x1 - x0) * (y1 - y0)
- val union = a.width * a.height + b.width * b.height - inter
- return if (union <= 0f) 0f else inter / union
-}
-
-fun nms(boxes: List, iouThreshold: Float): List {
- val sorted = boxes.sortedByDescending { it.score }
- val kept = mutableListOf()
- for (b in sorted) {
- if (kept.none { iou(b, it) > iouThreshold }) kept += b
- }
- return kept
-}
diff --git a/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt b/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt
deleted file mode 100644
index 81e2f4c..0000000
--- a/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-package com.example.observer.detection
-
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Matrix
-import org.tensorflow.lite.Interpreter
-import org.tensorflow.lite.gpu.GpuDelegate
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-
-/**
- * YOLOv8n 端侧推理实现。
- * 模型输出布局(ultralytics tflite 导出):[1, 4 + nc, 8400],
- * cx/cy/w/h 已归一化,类别得分已过 sigmoid;按列平铺:index = c * 8400 + anchor。
- */
-class TFLiteDetector private constructor(
- private val interpreter: Interpreter,
- private val labels: List,
-) : Detector {
-
- @Volatile var confThreshold: Float = 0.40f
-
- @Volatile var habitatThreshold: Float = 0.35f
-
- private val scaledBitmap = Bitmap.createBitmap(INPUT_SIZE, INPUT_SIZE, Bitmap.Config.ARGB_8888)
- private val scaleCanvas = Canvas(scaledBitmap)
- private val scaleMatrix = Matrix()
- private val pixels = IntArray(INPUT_SIZE * INPUT_SIZE)
-
- private val inputBuffer: ByteBuffer =
- ByteBuffer.allocateDirect(1 * INPUT_SIZE * INPUT_SIZE * 3 * 4)
- .order(ByteOrder.nativeOrder())
-
- private val outputFloats = FloatArray((4 + labels.size) * NUM_ANCHORS)
-
- override fun detect(bitmap: Bitmap): List {
- preprocess(bitmap)
- interpreter.run(inputBuffer, outputFloats)
- return postprocess()
- }
-
- private fun preprocess(bitmap: Bitmap) {
- // CENTER_CROP 缩放保持宽高比
- val scale = maxOf(INPUT_SIZE.toFloat() / bitmap.width, INPUT_SIZE.toFloat() / bitmap.height)
- val dx = (INPUT_SIZE - bitmap.width * scale) / 2f
- val dy = (INPUT_SIZE - bitmap.height * scale) / 2f
- scaleMatrix.reset()
- scaleMatrix.setScale(scale, scale)
- scaleMatrix.postTranslate(dx, dy)
- scaleCanvas.drawBitmap(bitmap, scaleMatrix, null)
-
- inputBuffer.rewind()
- scaledBitmap.getPixels(pixels, 0, INPUT_SIZE, 0, 0, INPUT_SIZE, INPUT_SIZE)
- for (p in pixels) {
- inputBuffer.putFloat(((p shr 16) and 0xFF) / 255f)
- inputBuffer.putFloat(((p shr 8) and 0xFF) / 255f)
- inputBuffer.putFloat((p and 0xFF) / 255f)
- }
- }
-
- private fun postprocess(): List {
- val boxes = ArrayList(8)
- for (a in 0 until NUM_ANCHORS) {
- val cx = outputFloats[a]
- val cy = outputFloats[NUM_ANCHORS + a]
- val w = outputFloats[2 * NUM_ANCHORS + a]
- val h = outputFloats[3 * NUM_ANCHORS + a]
- var bestCls = 0
- var bestScore = 0f
- for (c in 0 until labels.size) {
- val s = outputFloats[(4 + c) * NUM_ANCHORS + a]
- if (s > bestScore) {
- bestScore = s
- bestCls = c
- }
- }
- val label = labels.getOrElse(bestCls) { "unknown" }
- // cover 用独立阈值;动物类保留低分池,供运动检测提升
- val threshold = if (label == DetectionResult.HABITAT_LABEL) habitatThreshold else MIN_SCORE
- if (bestScore < threshold) continue
- boxes += DetectionResult(
- label = label,
- score = bestScore,
- left = (cx - w / 2f).coerceIn(0f, 1f),
- top = (cy - h / 2f).coerceIn(0f, 1f),
- right = (cx + w / 2f).coerceIn(0f, 1f),
- bottom = (cy + h / 2f).coerceIn(0f, 1f),
- )
- }
- return nms(boxes, IOU_THRESHOLD).take(MAX_DETECTIONS)
- }
-
- companion object {
- const val INPUT_SIZE = 320
-
- /** 动物类最低保留分数:低于此分不输出(低分候选由运动检测提升显示) */
- const val MIN_SCORE = 0.20f
- private const val NUM_ANCHORS = 8400
- private const val IOU_THRESHOLD = 0.45f
- private const val MAX_DETECTIONS = 20
- private const val MODEL_ASSET = "model.tflite"
- private const val LABELS_ASSET = "labels.txt"
-
- /** 模型缺失或加载失败返回 null(App 降级为仅预览) */
- fun create(context: Context): TFLiteDetector? {
- return try {
- val options = Interpreter.Options().apply {
- setNumThreads(4)
- try {
- addDelegate(GpuDelegate())
- } catch (_: Throwable) {
- // GPU 不可用,回退 CPU
- }
- }
- val interpreter = Interpreter(loadModelFile(context), options)
- val labels = context.assets.open(LABELS_ASSET).bufferedReader().readLines()
- TFLiteDetector(interpreter, labels)
- } catch (e: Exception) {
- null
- }
- }
-
- private fun loadModelFile(context: Context): ByteBuffer {
- val bytes = context.assets.open(MODEL_ASSET).use { it.readBytes() }
- return ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder())
- .apply { put(bytes); rewind() }
- }
- }
-}
diff --git a/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt b/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt
deleted file mode 100644
index 81a178d..0000000
--- a/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.example.observer.distance
-
-import android.content.Context
-import android.hardware.camera2.CameraCharacteristics
-import android.hardware.camera2.CameraManager
-import kotlin.math.roundToInt
-import kotlin.math.tan
-
-/**
- * 单目距离估计(针孔模型):距离 = 焦距px × 参考体型 / 框高px。
- * 误差预期 ±30%(5~50m);生境区域按植被高度估算,均仅供参考。
- */
-class DistanceEstimator(context: Context) {
-
- private val cameraManager =
- context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
-
- private val focalPxCache = HashMap()
-
- // 参考体型(米)
- private val speciesSizeM = mapOf(
- "pheasant" to 0.45f, // 身高
- "cover" to 0.50f, // 植被高度(误差大)
- )
-
- fun estimate(
- label: String,
- boxHeightNorm: Float,
- visibleHeightPx: Int,
- cameraId: String?,
- ): Float? {
- val realH = speciesSizeM[label] ?: return null
- val boxH = boxHeightNorm * visibleHeightPx
- if (boxH < 8f) return null // 过小目标不估算
- val focalPx = focalPx(visibleHeightPx, cameraId)
- if (focalPx <= 0f) return null
- return (focalPx * realH / boxH).roundToInt().toFloat()
- }
-
- /** focal_px = focal_mm × (imageHeightPx / sensorHeightMm);内参缺失时用视场角推算 */
- private fun focalPx(imageHeightPx: Int, cameraId: String?): Float {
- val key = "$cameraId:$imageHeightPx"
- focalPxCache[key]?.let { return it }
- val id = cameraId ?: return -1f
- val value = try {
- val c = cameraManager.getCameraCharacteristics(id)
- val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
- ?.firstOrNull()
- val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
- if (focalMm != null && sensor != null) {
- focalMm * imageHeightPx / sensor.height
- } else {
- fovFallback(imageHeightPx, c)
- }
- } catch (e: Exception) {
- -1f
- }
- focalPxCache[key] = value
- return value
- }
-
- /** focal_px = imageHeightPx / (2·tan(fovV/2)) */
- private fun fovFallback(imageHeightPx: Int, c: CameraCharacteristics): Float {
- val fovV = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_VERTICAL_VIEW_ANGLES)
- ?.firstOrNull() ?: return -1f
- return (imageHeightPx / (2.0 * tan(Math.toRadians(fovV / 2.0)))).toFloat()
- }
-}
diff --git a/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt b/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt
deleted file mode 100644
index b710d5f..0000000
--- a/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.example.observer.overlay
-
-import android.graphics.Paint
-import androidx.compose.foundation.Canvas
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.PathEffect
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.graphics.nativeCanvas
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.unit.dp
-import com.example.observer.detection.CoordinateMapper
-import com.example.observer.detection.DetectionResult
-import kotlin.math.roundToInt
-
-private val speciesColors = mapOf(
- "pheasant" to Color(0xFFE53935),
- "cover" to Color(0xFFFDD835),
-)
-
-private val speciesLabels = mapOf(
- "pheasant" to "野鸡",
- "cover" to "疑似区域",
-)
-
-@Composable
-fun DetectionOverlay(
- results: List,
- rotation: Int,
- imageW: Int,
- imageH: Int,
- modifier: Modifier = Modifier,
-) {
- Canvas(modifier = modifier) {
- val viewW = size.width.toInt()
- val viewH = size.height.toInt()
- val textPaint = Paint().apply {
- isAntiAlias = true
- textSize = 30f
- }
- results.forEach { r ->
- val rect = CoordinateMapper.mapToView(
- r.left, r.top, r.right, r.bottom,
- rotation, imageW, imageH, viewW, viewH,
- )
- val color = speciesColors[r.label] ?: Color.White
- val stroke = Stroke(
- width = 6.dp.toPx(),
- pathEffect = if (r.isHabitat) {
- PathEffect.dashPathEffect(floatArrayOf(20.dp.toPx(), 12.dp.toPx()))
- } else {
- null
- },
- )
- drawRect(
- color = color,
- topLeft = Offset(rect.left, rect.top),
- size = Size(rect.width, rect.height),
- style = stroke,
- )
- textPaint.color = color.toArgb()
- val text = buildString {
- append(speciesLabels[r.label] ?: r.label)
- append(" ${(r.score * 100).toInt()}%")
- r.distanceM?.let { append(" · 约${it.roundToInt()}m") }
- }
- val labelTop = (rect.top - textPaint.textSize - 6).coerceAtLeast(0f)
- drawContext.canvas.nativeCanvas.drawText(
- text,
- rect.left.coerceAtLeast(0f) + 4f,
- labelTop + textPaint.textSize,
- textPaint,
- )
- }
- }
-}
diff --git a/app/src/main/java/com/example/observer/reminder/Reminder.kt b/app/src/main/java/com/example/observer/reminder/Reminder.kt
deleted file mode 100644
index 47cdf3a..0000000
--- a/app/src/main/java/com/example/observer/reminder/Reminder.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.example.observer.reminder
-
-import android.content.Context
-import android.media.AudioManager
-import android.media.ToneGenerator
-import android.os.Build
-import android.os.SystemClock
-import android.os.VibrationEffect
-import android.os.Vibrator
-
-class Reminder(context: Context) {
-
- @Volatile var vibrateEnabled: Boolean = true
-
- @Volatile var soundEnabled: Boolean = true
-
- @Volatile var habitatAlertEnabled: Boolean = true
-
- private val vibrator: Vibrator? =
- context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
-
- private val toneGenerator: ToneGenerator? = try {
- ToneGenerator(AudioManager.STREAM_NOTIFICATION, 60)
- } catch (e: RuntimeException) {
- null
- }
-
- private var lastAlertLabel: String? = null
- private var lastAlertAt = 0L
-
- /** 同类目标 10s 内只提醒一次;生境区域(cover)提醒方式与动物检测区分 */
- fun onDetected(label: String) {
- val now = SystemClock.elapsedRealtime()
- if (label == lastAlertLabel && now - lastAlertAt < 10_000) return
- lastAlertAt = now
- lastAlertLabel = label
-
- val isHabitat = label == "cover"
- if (isHabitat && !habitatAlertEnabled) return
-
- if (vibrateEnabled) vibrate(isHabitat)
- if (soundEnabled) playTone(isHabitat)
- }
-
- private fun vibrate(isHabitat: Boolean) {
- val v = vibrator ?: return
- if (Build.VERSION.SDK_INT >= 26) {
- val effect = if (isHabitat) {
- VibrationEffect.createWaveform(longArrayOf(0, 150, 80, 150), -1)
- } else {
- VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)
- }
- v.vibrate(effect)
- } else {
- @Suppress("DEPRECATION")
- v.vibrate(if (isHabitat) longArrayOf(0, 150, 80, 150) else longArrayOf(0, 200), -1)
- }
- }
-
- private fun playTone(isHabitat: Boolean) {
- val tone = toneGenerator ?: return
- if (isHabitat) {
- // 双短音:生境预警
- tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120)
- tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120)
- } else {
- tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 200)
- }
- }
-
- fun release() {
- toneGenerator?.release()
- }
-}
diff --git a/app/src/main/java/com/example/observer/ui/ObserverApp.kt b/app/src/main/java/com/example/observer/ui/ObserverApp.kt
deleted file mode 100644
index 54bbc2b..0000000
--- a/app/src/main/java/com/example/observer/ui/ObserverApp.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.example.observer.ui
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import com.example.observer.ui.camera.CameraScreen
-import com.example.observer.ui.settings.SettingsScreen
-import com.example.observer.ui.theme.ObserverTheme
-
-@Composable
-fun ObserverApp() {
- ObserverTheme {
- var showSettings by rememberSaveable { mutableStateOf(false) }
- if (showSettings) {
- SettingsScreen(onBack = { showSettings = false })
- } else {
- CameraScreen(onOpenSettings = { showSettings = true })
- }
- }
-}
diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt b/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt
deleted file mode 100644
index 74102ec..0000000
--- a/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt
+++ /dev/null
@@ -1,244 +0,0 @@
-package com.example.observer.ui.camera
-
-import android.Manifest
-import android.content.pm.PackageManager
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.camera.view.PreviewView
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.example.observer.ObserverApp
-import com.example.observer.camera.CameraController
-import com.example.observer.camera.FrameAnalyzer
-import com.example.observer.camera.MotionDetector
-import com.example.observer.data.AppSettings
-import com.example.observer.data.DetectMode
-import com.example.observer.overlay.DetectionOverlay
-import kotlinx.coroutines.launch
-import java.util.concurrent.Executors
-
-@Composable
-fun CameraScreen(onOpenSettings: () -> Unit) {
- val context = LocalContext.current
- val lifecycleOwner = LocalLifecycleOwner.current
- val container = (context.applicationContext as ObserverApp).container
- val viewModel: CameraViewModel = viewModel(
- factory = remember { CameraViewModelFactory(container) },
- )
- val state by viewModel.state.collectAsStateWithLifecycle()
- val settings by container.settingsRepository.settings.collectAsStateWithLifecycle(
- initialValue = AppSettings(),
- )
- val scope = rememberCoroutineScope()
-
- val executor = remember { Executors.newSingleThreadExecutor() }
- val previewView = remember {
- PreviewView(context).apply {
- scaleType = PreviewView.ScaleType.FIT_CENTER
- implementationMode = PreviewView.ImplementationMode.PERFORMANCE
- }
- }
- val cameraController = remember { CameraController(lifecycleOwner, executor) }
- val motionDetector = remember { MotionDetector() }
- val analyzer = remember(container.detector) {
- container.detector?.let { d ->
- FrameAnalyzer(d, motionDetector) { results, rotation, w, h, motion ->
- viewModel.onFramesAnalyzed(results, rotation, w, h, motion)
- }
- }
- }
- val interval by viewModel.analyzerIntervalMs.collectAsState()
-
- var hasCameraPermission by remember {
- mutableStateOf(
- ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
- PackageManager.PERMISSION_GRANTED,
- )
- }
- val permissionLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission(),
- ) { granted -> hasCameraPermission = granted }
- var retryKey by remember { mutableIntStateOf(0) }
- var initFailed by remember { mutableStateOf(false) }
-
- DisposableEffect(Unit) {
- cameraController.onCameraIdChanged = { viewModel.onCameraIdChanged(it) }
- onDispose { executor.shutdown() }
- }
- LaunchedEffect(interval) { analyzer?.intervalMs = interval }
- LaunchedEffect(retryKey) {
- cameraController.onInitError = { initFailed = true }
- if (hasCameraPermission) cameraController.start(previewView, analyzer)
- }
- LaunchedEffect(Unit) {
- if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
- }
-
- Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
- if (hasCameraPermission) {
- AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
-
- if (state.modelReady) {
- DetectionOverlay(
- results = state.results,
- rotation = state.rotation,
- imageW = state.imageWidthPx,
- imageH = state.imageHeightPx,
- modifier = Modifier.fillMaxSize(),
- )
- } else {
- Box(
- modifier = Modifier.fillMaxSize().padding(24.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- "模型未加载:请将训练好的 model.tflite 放入 app/src/main/assets 后重新构建",
- color = Color.White,
- textAlign = TextAlign.Center,
- )
- }
- }
-
- if (initFailed) {
- Column(
- modifier = Modifier
- .align(Alignment.Center)
- .background(Color(0x99000000), RoundedCornerShape(12.dp))
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text("相机初始化失败", color = Color.White)
- Spacer(Modifier.height(8.dp))
- TextButton(onClick = {
- initFailed = false
- retryKey++
- }) { Text("重试") }
- }
- }
-
- CameraTopBar(
- detectMode = settings.detectMode,
- onCycleMode = {
- val next = DetectMode.entries[
- (settings.detectMode.ordinal + 1) % DetectMode.entries.size
- ]
- scope.launch { container.settingsRepository.setDetectMode(next) }
- },
- onSwitchCamera = { cameraController.switchCamera(previewView, analyzer) },
- modifier = Modifier.align(Alignment.TopCenter),
- )
- CameraBottomBar(
- vibrateEnabled = settings.vibrateEnabled,
- onToggleVibrate = {
- scope.launch {
- container.settingsRepository.setVibrateEnabled(!settings.vibrateEnabled)
- }
- },
- onOpenSettings = onOpenSettings,
- modifier = Modifier.align(Alignment.BottomCenter),
- )
- } else {
- PermissionGuide(
- onRequest = { permissionLauncher.launch(Manifest.permission.CAMERA) },
- modifier = Modifier.align(Alignment.Center),
- )
- }
- }
-}
-
-@Composable
-private fun CameraTopBar(
- detectMode: DetectMode,
- onCycleMode: () -> Unit,
- onSwitchCamera: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier = modifier
- .fillMaxWidth()
- .background(Color(0x99000000))
- .padding(horizontal = 12.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- TextButton(onClick = onCycleMode) {
- Text("频率:${detectMode.label}", color = Color.White)
- }
- TextButton(onClick = onSwitchCamera) {
- Text("切换摄像头", color = Color.White)
- }
- }
-}
-
-@Composable
-private fun CameraBottomBar(
- vibrateEnabled: Boolean,
- onToggleVibrate: () -> Unit,
- onOpenSettings: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier = modifier
- .fillMaxWidth()
- .background(Color(0x99000000))
- .padding(horizontal = 12.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- TextButton(onClick = onToggleVibrate) {
- Text(if (vibrateEnabled) "提醒:开" else "提醒:关", color = Color.White)
- }
- TextButton(onClick = onOpenSettings) {
- Text("设置", color = Color.White)
- }
- }
-}
-
-@Composable
-private fun PermissionGuide(onRequest: () -> Unit, modifier: Modifier = Modifier) {
- Column(
- modifier = modifier.padding(32.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- "需要相机权限才能进行实时识别",
- color = Color.White,
- textAlign = TextAlign.Center,
- )
- Spacer(Modifier.height(16.dp))
- Button(onClick = onRequest) { Text("授权相机") }
- }
-}
diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt b/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt
deleted file mode 100644
index d9e906b..0000000
--- a/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-package com.example.observer.ui.camera
-
-import android.os.SystemClock
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
-import com.example.observer.AppContainer
-import com.example.observer.data.DetectMode
-import com.example.observer.data.SettingsRepository
-import com.example.observer.detection.DetectionResult
-import com.example.observer.detection.MotionAggregator
-import com.example.observer.detection.MotionRegion
-import com.example.observer.detection.TFLiteDetector
-import com.example.observer.distance.DistanceEstimator
-import com.example.observer.reminder.Reminder
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import java.util.LinkedHashMap
-
-data class CameraUiState(
- val modelReady: Boolean = false,
- val results: List = emptyList(),
- val rotation: Int = 90,
- val imageWidthPx: Int = 0,
- val imageHeightPx: Int = 0,
-)
-
-class CameraViewModel(
- private val detector: TFLiteDetector?,
- private val distanceEstimator: DistanceEstimator,
- private val reminder: Reminder,
- settingsRepository: SettingsRepository,
-) : ViewModel() {
-
- private val _state = MutableStateFlow(CameraUiState(modelReady = detector != null))
- val state: StateFlow = _state.asStateFlow()
-
- private val _analyzerIntervalMs = MutableStateFlow(DetectMode.CONTINUOUS.intervalMs)
- val analyzerIntervalMs: StateFlow = _analyzerIntervalMs.asStateFlow()
-
- @Volatile
- private var showDistance = true
-
- @Volatile
- private var confThreshold = 0.40f
-
- @Volatile
- private var cameraId: String? = null
-
- private val tracks = LinkedHashMap()
-
- init {
- viewModelScope.launch {
- settingsRepository.settings.collect { s ->
- detector?.confThreshold = s.confThreshold
- detector?.habitatThreshold = s.habitatThreshold
- reminder.vibrateEnabled = s.vibrateEnabled
- reminder.soundEnabled = s.soundEnabled
- reminder.habitatAlertEnabled = s.habitatAlertEnabled
- showDistance = s.showDistance
- confThreshold = s.confThreshold
- _analyzerIntervalMs.value = s.detectMode.intervalMs
- }
- }
- }
-
- fun onCameraIdChanged(id: String) {
- cameraId = id
- }
-
- /** 帧分析回调(分析线程调用,需线程安全) */
- fun onFramesAnalyzed(
- results: List,
- rotation: Int,
- imageWidthPx: Int,
- imageHeightPx: Int,
- motionRegions: List,
- ) {
- val now = SystemClock.elapsedRealtime()
- val byKey = HashMap(results.size)
- results.forEach { byKey[keyOf(it)] = it }
-
- synchronized(this) {
- // 过期目标清除(消失 2s 后移除)
- val expired = tracks.entries
- .filter { it.key !in byKey && now - it.value.lastSeenMs > 2000 }
- .map { it.key }
- expired.forEach { tracks.remove(it) }
-
- byKey.forEach { (k, r) ->
- val t = tracks[k]
- tracks[k] = if (t == null) Track(now, now, r) else Track(t.firstSeenMs, now, r)
- }
- if (tracks.size > MAX_TRACKS) {
- val oldest = tracks.entries.sortedBy { it.value.lastSeenMs }.take(tracks.size - MAX_TRACKS)
- oldest.forEach { tracks.remove(it.key) }
- }
-
- // 目标停留 ≥ 0.5s 才展示;消失 2s 内不闪断
- val visible = tracks.values
- .filter { now - it.firstSeenMs >= 500 && now - it.lastSeenMs <= 2000 }
- .filter { shouldShow(it.result, motionRegions) }
- .map { t ->
- val r = t.result
- val boosted = r.score < confThreshold &&
- motionRegions.any { MotionAggregator.centerInRegion(r, it) }
- val distance = if (showDistance) {
- // 模型 CENTER_CROP 到方形输入, 归一化框高对应原图较短边(最大内接正方形)
- distanceEstimator.estimate(
- r.label, r.height, minOf(imageWidthPx, imageHeightPx), cameraId,
- )
- } else {
- null
- }
- r.copy(
- score = if (boosted) minOf(r.score + MOTION_BOOST, 1f) else r.score,
- distanceM = distance,
- )
- }
-
- // 提醒:刚变为可见的目标(防重复由 Reminder 按类别 10s 控制)
- tracks.values.forEach { t ->
- if (!shouldShow(t.result, motionRegions)) return@forEach
- val age = now - t.firstSeenMs
- if (age in 500..2100 && now - t.lastSeenMs <= 300) {
- reminder.onDetected(t.result.label)
- }
- }
-
- _state.update {
- it.copy(
- results = visible,
- rotation = rotation,
- imageWidthPx = imageWidthPx,
- imageHeightPx = imageHeightPx,
- )
- }
- }
- }
-
- /**
- * 显示判定:cover 已按生境阈值过滤;动物分低于阈值时,
- * 仅当与运动区域重叠(低分候选)才提升显示。
- */
- private fun shouldShow(r: DetectionResult, motionRegions: List): Boolean {
- if (r.isHabitat) return true
- if (r.score >= confThreshold) return true
- if (r.score < TFLiteDetector.MIN_SCORE) return false
- return motionRegions.any { MotionAggregator.centerInRegion(r, it) }
- }
-
- override fun onCleared() {
- reminder.release()
- }
-
- private fun keyOf(r: DetectionResult): String =
- "${r.label}:${(r.centerX * 10).toInt()}:${(r.centerY * 10).toInt()}"
-
- private data class Track(
- val firstSeenMs: Long,
- val lastSeenMs: Long,
- val result: DetectionResult,
- )
-
- companion object {
- private const val MAX_TRACKS = 30
-
- /** 低分目标与运动区域重叠时的置信度提升量 */
- private const val MOTION_BOOST = 0.15f
- }
-}
-
-class CameraViewModelFactory(private val container: AppContainer) : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(CameraViewModel::class.java)) {
- return CameraViewModel(
- detector = container.detector as? TFLiteDetector,
- distanceEstimator = container.distanceEstimator,
- reminder = container.reminder,
- settingsRepository = container.settingsRepository,
- ) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
- }
-}
diff --git a/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt
deleted file mode 100644
index 8235592..0000000
--- a/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package com.example.observer.ui.settings
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.example.observer.ObserverApp
-import com.example.observer.data.AppSettings
-import com.example.observer.data.DetectMode
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun SettingsScreen(onBack: () -> Unit) {
- val context = LocalContext.current
- val repository = (context.applicationContext as ObserverApp).container.settingsRepository
- val settings by repository.settings.collectAsStateWithLifecycle(initialValue = AppSettings())
- val scope = rememberCoroutineScope()
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("设置") },
- navigationIcon = {
- TextButton(onClick = onBack) { Text("返回") }
- },
- )
- },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text("动物识别阈值:${"%.2f".format(settings.confThreshold)}")
- Slider(
- value = settings.confThreshold,
- onValueChange = { scope.launch { repository.setConfThreshold(it) } },
- valueRange = 0.2f..0.7f,
- )
-
- Text("生境区域阈值:${"%.2f".format(settings.habitatThreshold)}")
- Slider(
- value = settings.habitatThreshold,
- onValueChange = { scope.launch { repository.setHabitatThreshold(it) } },
- valueRange = 0.2f..0.7f,
- )
-
- Text("检测频率")
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- DetectMode.entries.forEach { mode ->
- FilterChip(
- selected = settings.detectMode == mode,
- onClick = { scope.launch { repository.setDetectMode(mode) } },
- label = { Text(mode.label) },
- )
- }
- }
-
- SwitchRow("震动提醒", settings.vibrateEnabled) {
- scope.launch { repository.setVibrateEnabled(it) }
- }
- SwitchRow("提示音", settings.soundEnabled) {
- scope.launch { repository.setSoundEnabled(it) }
- }
- SwitchRow("距离标注", settings.showDistance) {
- scope.launch { repository.setShowDistance(it) }
- }
- SwitchRow("生境区域预警", settings.habitatAlertEnabled) {
- scope.launch { repository.setHabitatAlertEnabled(it) }
- }
- }
- }
-}
-
-@Composable
-private fun SwitchRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(title)
- Switch(checked = checked, onCheckedChange = onCheckedChange)
- }
-}
diff --git a/app/src/main/java/com/example/observer/ui/theme/Theme.kt b/app/src/main/java/com/example/observer/ui/theme/Theme.kt
deleted file mode 100644
index 97fc247..0000000
--- a/app/src/main/java/com/example/observer/ui/theme/Theme.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.example.observer.ui.theme
-
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.Color
-
-private val DarkColors = darkColorScheme(
- primary = Color(0xFF66BB6A),
- onPrimary = Color(0xFF00391F),
- secondary = Color(0xFF80CBC4),
- surface = Color(0xFF1C1C1C),
- background = Color(0xFF101010),
- onBackground = Color(0xFFE4E4E4),
- onSurface = Color(0xFFE4E4E4),
-)
-
-@Composable
-fun ObserverTheme(content: @Composable () -> Unit) {
- MaterialTheme(colorScheme = DarkColors, content = content)
-}
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
deleted file mode 100644
index 87f295d..0000000
--- a/app/src/main/res/drawable/ic_launcher.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
deleted file mode 100644
index 0b6f2d0..0000000
--- a/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- 野视
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
deleted file mode 100644
index 2a12f06..0000000
--- a/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
diff --git a/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt b/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt
deleted file mode 100644
index 2773bf3..0000000
--- a/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.example.observer.detection
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class CoordinateMapperTest {
-
- @Test
- fun rotation90_center_mapsToViewCenter() {
- // 横屏图像 1280x720,旋转 90 后竖屏显示 720x1280,视图 1080x1920(等比,无偏移)
- val rect = CoordinateMapper.mapToView(
- normLeft = 0.4f, normTop = 0.4f, normRight = 0.6f, normBottom = 0.6f,
- rotation = 90, imageW = 1280, imageH = 720,
- viewW = 1080, viewH = 1920,
- )
- assertEquals(540f, rect.centerX, 1f)
- assertEquals(960f, rect.centerY, 1f)
- }
-
- @Test
- fun rotation90_corner_appliesFitCenterOffset() {
- // 视图 1080x2400,竖屏图像 720x1280,scale=1.5,纵向偏移 (2400-1920)/2=240
- val rect = CoordinateMapper.mapToView(
- normLeft = 0f, normTop = 0f, normRight = 0.5f, normBottom = 0.5f,
- rotation = 90, imageW = 1280, imageH = 720,
- viewW = 1080, viewH = 2400,
- )
- // 旋转后:x0=1-bottom=0.5, y0=left=0, x1=1-top=1, y1=right=0.5
- assertEquals(540f, rect.left, 1f) // 0.5 * 720 * 1.5
- assertEquals(240f, rect.top, 1f) // 0 * 1280 * 1.5 + 240
- assertEquals(1080f, rect.right, 1f) // 1 * 720 * 1.5
- assertEquals(1200f, rect.bottom, 1f) // 0.5 * 1280 * 1.5 + 240
- }
-
- @Test
- fun rotation0_keepsCoordinates() {
- val rect = CoordinateMapper.mapToView(
- normLeft = 0.2f, normTop = 0.3f, normRight = 0.5f, normBottom = 0.7f,
- rotation = 0, imageW = 1080, imageH = 1920,
- viewW = 1080, viewH = 1920,
- )
- assertEquals(216f, rect.left, 1f)
- assertEquals(576f, rect.top, 1f)
- assertEquals(540f, rect.right, 1f)
- assertEquals(1344f, rect.bottom, 1f)
- }
-
- @Test
- fun rotation180_flipsBothAxes() {
- val rect = CoordinateMapper.mapToView(
- normLeft = 0.2f, normTop = 0.2f, normRight = 0.4f, normBottom = 0.4f,
- rotation = 180, imageW = 1080, imageH = 1920,
- viewW = 1080, viewH = 1920,
- )
- // 翻转:x'=1-x, y'=1-y
- assertEquals(648f, rect.left, 1f) // (1-0.4) * 1080
- assertEquals(1152f, rect.top, 1f) // (1-0.4) * 1920
- assertEquals(864f, rect.right, 1f) // (1-0.2) * 1080
- assertEquals(1536f, rect.bottom, 1f) // (1-0.2) * 1920
- }
-}
diff --git a/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt b/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt
deleted file mode 100644
index c8f3427..0000000
--- a/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.example.observer.detection
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class MotionAggregatorTest {
-
- // 96x64 图,8x8 块 → 每块 12x8 像素
-
- @Test
- fun noMotion_returnsEmpty() {
- val diff = IntArray(96 * 64)
- assertTrue(MotionAggregator.aggregate(diff, 96, 64).isEmpty())
- }
-
- @Test
- fun singleBlockMotion_detectsRegion() {
- val diff = IntArray(96 * 64)
- // 块 (2,3):x 24..35, y 24..31,全部置 1
- for (y in 24 until 32) {
- for (x in 24 until 36) diff[y * 96 + x] = 1
- }
- val regions = MotionAggregator.aggregate(diff, 96, 64)
- assertEquals(1, regions.size)
- val r = regions[0]
- assertTrue(r.left <= 24f / 96f && r.right >= 36f / 96f)
- assertTrue(r.top <= 24f / 64f && r.bottom >= 32f / 64f)
- }
-
- @Test
- fun twoSeparateMotions_detectsTwoRegions() {
- val diff = IntArray(96 * 64)
- for (y in 0 until 8) for (x in 0 until 12) diff[y * 96 + x] = 1
- for (y in 48 until 64) for (x in 72 until 96) diff[y * 96 + x] = 1
- val regions = MotionAggregator.aggregate(diff, 96, 64)
- assertEquals(2, regions.size)
- }
-
- @Test
- fun globalNoise_filteredOut() {
- val diff = IntArray(96 * 64)
- // 全屏散点噪声(随机块),但无连续连通域
- val rnd = java.util.Random(42)
- for (i in diff.indices) if (rnd.nextFloat() < 0.1f) diff[i] = 1
- val regions = MotionAggregator.aggregate(diff, 96, 64)
- assertTrue(regions.isEmpty())
- }
-
- @Test
- fun centerInRegion_matches() {
- val box = DetectionResult(
- label = "hare",
- score = 0.30f,
- left = 0.2f,
- top = 0.3f,
- right = 0.4f,
- bottom = 0.5f,
- )
- val region = MotionRegion(0.1f, 0.2f, 0.5f, 0.6f)
- assertTrue(MotionAggregator.centerInRegion(box, region))
- val outside = MotionRegion(0.6f, 0.7f, 0.9f, 0.9f)
- assertFalse(MotionAggregator.centerInRegion(box, outside))
- }
-}
diff --git a/app/src/test/java/com/example/observer/detection/NmsTest.kt b/app/src/test/java/com/example/observer/detection/NmsTest.kt
deleted file mode 100644
index f6ef17f..0000000
--- a/app/src/test/java/com/example/observer/detection/NmsTest.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.example.observer.detection
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class NmsTest {
-
- private fun box(l: Float, t: Float, r: Float, b: Float, score: Float, label: String = "x") =
- DetectionResult(label, score, l, t, r, b)
-
- @Test
- fun overlappingBoxes_keepHighestScore() {
- val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.8f)
- val b = box(0.12f, 0.12f, 0.52f, 0.52f, 0.6f)
- val result = nms(listOf(a, b), 0.45f)
- assertEquals(1, result.size)
- assertEquals(0.8f, result[0].score, 1e-6f)
- }
-
- @Test
- fun separateBoxes_bothKept() {
- val a = box(0.1f, 0.1f, 0.3f, 0.3f, 0.8f)
- val b = box(0.7f, 0.7f, 0.9f, 0.9f, 0.6f)
- val result = nms(listOf(a, b), 0.45f)
- assertEquals(2, result.size)
- }
-
- @Test
- fun lowScoreBox_suppressedByHigherScore() {
- val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.9f)
- val b = box(0.1f, 0.1f, 0.5f, 0.5f, 0.5f)
- val result = nms(listOf(b, a), 0.45f)
- assertEquals(1, result.size)
- assertTrue(result[0].score > 0.5f)
- }
-
- @Test
- fun iou_nonOverlapping_isZero() {
- val a = box(0.0f, 0.0f, 0.2f, 0.2f, 1f)
- val b = box(0.8f, 0.8f, 1f, 1f, 1f)
- assertEquals(0f, iou(a, b), 1e-6f)
- }
-
- @Test
- fun iou_identicalBoxes_isOne() {
- val a = box(0.1f, 0.1f, 0.5f, 0.5f, 1f)
- assertEquals(1f, iou(a, a), 1e-6f)
- }
-}
diff --git a/build.gradle.kts b/build.gradle.kts
deleted file mode 100644
index 9deb573..0000000
--- a/build.gradle.kts
+++ /dev/null
@@ -1,5 +0,0 @@
-plugins {
- alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
- alias(libs.plugins.kotlin.compose) apply false
-}
diff --git a/docs/01-技术方案.md b/docs/01-技术方案.md
deleted file mode 100644
index 5437a71..0000000
--- a/docs/01-技术方案.md
+++ /dev/null
@@ -1,320 +0,0 @@
-# Observer(野视)· 野生动物实时识别 Android App — 技术方案
-
-| 项目 | 内容 |
-| --- | --- |
-| 文档版本 | v1.1 |
-| 编写日期 | 2026-08-17 |
-| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) |
-| 适用产品 | 纯 Android 原生 App |
-
----
-
-## 1. 项目概述
-
-### 1.1 项目背景
-
-野生动物观察爱好者在户外需要一款便携工具:打开手机相机,即可从实时画面中识别野鸡,通过检测框与提醒辅助快速发现。
-
-### 1.2 项目目标
-
-- 端侧实时目标检测,**全程离线可用**,不依赖网络;
-- 野鸡实时框选识别,展示类别、置信度与**大致距离**;
-- 检测到目标时通过震动 / 声音提醒用户,辅助快速发现;
-- 纯 Android 原生 App(Kotlin),兼容 Android 7.0+ 主流机型,中端机流畅运行;
-- **不做拍照、记录、统计等留存功能**,专注实时识别这一核心体验。
-
-### 1.3 名词术语
-
-| 术语 | 说明 |
-| --- | --- |
-| ImageAnalysis | CameraX 的帧分析用例,用于逐帧回调图像数据 |
-| TFLite | TensorFlow Lite,Google 端侧推理框架 |
-| YOLO | You Only Look Once,单阶段目标检测算法 |
-| NMS | Non-Maximum Suppression,非极大值抑制 |
-| GPU Delegate | TFLite 的 GPU 加速委托,将算子下发 GPU 执行 |
-| mAP | mean Average Precision,目标检测平均精度指标 |
-| IoU | Intersection over Union,交并比 |
-
----
-
-## 2. 需求概述
-
-### 2.1 目标用户
-
-| 用户群 | 典型诉求 |
-| --- | --- |
-| 野生动物观察爱好者 | 户外实时识别画面中的野鸡,不惊扰、近距离观察 |
-| 户外徒步 / 摄影人群 | 快速发现野鸡,辅助取景构图 |
-| 自然教育、科普工作者 | 物种识别辅助教学 |
-
-### 2.2 核心场景
-
-| 编号 | 场景 | 描述 |
-| --- | --- | --- |
-| S1 | 野外实时识别 | 徒步 / 观鸟时打开相机,实时框选画面中的野鸡,显示类别、置信度与大致距离 |
-| S2 | 快速扫视寻找 | 移动取景快速扫视,检测到野鸡立即震动 / 声音提醒,无需停留操作 |
-
-### 2.3 功能需求摘要
-
-实时识别(P0)、识别结果叠加展示(P0)、检测提醒(P1)、生境区域预警(P1)、设置(P1)、合规提示(P0)、低光增强(P2)。详见《项目功能文档》。
-
-### 2.4 非功能需求摘要
-
-| 指标 | 目标 |
-| --- | --- |
-| 识别延迟 | 中端机单帧 ≤ 80ms |
-| 预览流畅度 | 连续模式 2~3 帧检测一次,预览不卡顿 |
-| 耗电 | 连续使用 1 小时耗电 ≤ 15% |
-| 兼容性 | Android 7.0+(minSdk 24),覆盖主流国产机与三星 |
-| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99% |
-
----
-
-## 3. 总体架构
-
-### 3.1 架构图
-
-```mermaid
-graph TD
- subgraph UI层["UI 层 · Jetpack Compose"]
- MainScreen["相机主界面
预览 + 检测叠加层"]
- SettingsScreen["设置界面"]
- end
-
- subgraph ViewModel层["ViewModel 层"]
- CameraViewModel["CameraViewModel"]
- SettingsViewModel["SettingsViewModel"]
- end
-
- subgraph 领域层["领域层"]
- Detector接口["Detector 接口"]
- end
-
- subgraph 基础设施层["基础设施层"]
- CameraX["CameraX
Preview / ImageAnalysis"]
- TFLite["TFLite 推理引擎
YOLOv8n 模型 + GPU Delegate"]
- DataStore["DataStore 设置存储"]
- end
-
- UI层 --> ViewModel层
- ViewModel层 --> 领域层
- 领域层 --> 基础设施层
-```
-
-### 3.2 分层说明
-
-| 层 | 职责 |
-| --- | --- |
-| UI 层 | Compose 页面渲染、检测叠加层绘制、交互 |
-| ViewModel 层 | 页面状态管理、识别结果分发、提醒触发 |
-| 领域层 | 检测器抽象接口 |
-| 基础设施层 | CameraX、TFLite、DataStore 等能力实现 |
-
-### 3.3 核心数据流(识别链路)
-
-```mermaid
-flowchart LR
- A["CameraX 相机帧"] --> B["ImageAnalysis 帧分析"]
- B --> C["预处理
缩放 320×320 / 归一化 / 旋转校正"]
- C --> D["TFLite 推理
YOLOv8n"]
- D --> E["后处理
解码 / NMS / 阈值过滤"]
- E --> F["叠加层渲染
边框 + 类别 + 置信度"]
- F --> G["UI 展示"]
- E --> H["结果提醒
震动 / 提示音"]
-```
-
-### 3.4 部署形态
-
-- **本地优先**:识别全部离线完成,运行期无网络请求;
-- **云端(V2 可选)**:仅用于模型版本更新下发。
-
----
-
-## 4. 技术选型
-
-| 类别 | 选型 | 理由 |
-| --- | --- | --- |
-| 开发语言 | Kotlin | Android 官方推荐,协程生态成熟 |
-| UI 框架 | Jetpack Compose(Material 3) | 声明式 UI,开发效率高,叠加层绘制灵活 |
-| 相机 | CameraX(Preview / ImageAnalysis) | Jetpack 官方库,生命周期安全,机型兼容性最佳 |
-| 目标检测模型 | YOLOv8n(自定义 4 类训练) | 精度/速度均衡,端侧部署方案成熟 |
-| 推理框架 | TensorFlow Lite 2.16+(GPU Delegate) | 官方支持 GPU 加速;备选 NCNN / MNN |
-| 配置存储 | DataStore Preferences | 阈值、开关等设置 |
-| 异步 | Kotlin Coroutines + Flow | 主线程安全,生命周期感知 |
-| 构建 | Gradle(Kotlin DSL)+ AGP 8.x | 现代构建配置 |
-
----
-
-## 5. 核心功能技术方案
-
-### 5.1 实时识别管线
-
-- CameraX 组合:`Preview`(取景)+ `ImageAnalysis`(识别);
-- `ImageAnalysis` 使用 `STRATEGY_KEEP_ONLY_LATEST` 背压策略,保证不积压帧;
-- 输出格式使用 `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+),免去 YUV→RGB 手动转换;
-- **帧节流**:连续模式每 2~3 帧检测一次;标准模式 300ms 一次;省电模式每秒一次(依据设置);
-- 推理在独立检测线程执行,单例互斥锁防止重叠推理;分析线程不阻塞主线程。
-
-### 5.2 检测叠加层
-
-- Compose Canvas 绘制检测框(矩形 + 类别标签 + 置信度),颜色按类别区分;
-- 检测框内同时标注**大致距离**(如"约 25m"),随检测框实时更新;
-- 距离标注与检测框同生共灭,展示逻辑一致(≥ 0.5s 出现、消失 2s 后移除);
-- 坐标映射链路:模型归一化坐标 → 旋转校正(sensor rotation)→ 预览视图坐标(含 FIT_CENTER 裁剪偏移修正);
-- 目标保持 ≥ 0.5s 才展示,避免单帧误检闪烁。
-
-### 5.3 距离标注(单目估计)
-
-- 采用单目针孔模型:`距离 ≈ 焦距px × 物种参考体型 / 检测框像素高度`;
-- 焦距 px 由相机内参换算(`LENS_INFO_AVAILABLE_FOCAL_LENGTHS` × `SENSOR_INFO_PHYSICAL_SIZE`),个别机型回退用视场角推算;
-- 物种参考体型内置表(见 6.1),每类一个平均体型值;
-- 展示为"约 X m";5~50m 范围误差预期 ≤ ±30%;
-- 纯算术运算,无额外模型与算力开销;V2 可引入地面平面法(设备俯仰角 + 相机高度)提升精度。
-
-### 5.4 检测提醒
-
-- 检测到目标时触发震动 / 提示音,用户可开关;
-- 防重复打扰:10s 内同类目标只提醒一次;
-- 目标消失后 2s 内不闪断显示,避免频繁提醒。
-
-### 5.5 低光增强(P2)
-
-- 简单直方图均衡 / 亮度增益提升低光帧可见度;
-- V2 可引入夜视增强网络思路。
-
----
-
-## 6. AI 模型方案(核心)
-
-### 6.1 目标类别定义
-
-| 类别 ID | 英文标签 | 中文名 | 涵盖范围 | 参考体型(距离估计用) |
-| --- | --- | --- | --- | --- |
-| 0 | pheasant | 野鸡 | 环颈雉等雉类 | ≈ 0.45m(身高) |
-| 1 | cover | 生境区域 | 草丛 / 灌木 / 水面等疑似生境(黄色虚线框预警,F08) | ≈ 0.5m(参考植被高度) |
-
-### 6.2 模型选型对比
-
-| 模型 | 输入尺寸 | 中端机单帧耗时(GPU) | 精度 | 说明 |
-| --- | --- | --- | --- | --- |
-| **YOLOv8n(推荐)** | 320~640 | 约 30~80ms | 高 | 支持自定义类别训练,精度/速度平衡 |
-| EfficientDet-Lite0/2 | 320 | 约 20~50ms | 中 | 仅 COCO 预置类别 |
-| YOLOv5s | 640 | 约 80~150ms | 高 | 体积与耗电偏大 |
-| SSD MobileNetV2 | 300 | 约 15~30ms | 低 | 小目标与远距离效果差 |
-
-**结论**:选用 **YOLOv8n**,使用预训练权重迁移学习自定义训练,导出 TFLite 端侧部署。
-
-### 6.3 数据集方案
-
-| 项 | 方案 |
-| --- | --- |
-| 数据量 | 1000~2000 张(首版可 500+ 起步,滚动补充) |
-| 多样性 | 覆盖不同季节、晨昏/正午/逆光、远近距离、姿态、遮挡、背景(草丛/农田/林地/雪地) |
-| 标注 | 人工标注(LabelImg 等工具),YOLO 格式(class, cx, cy, w, h) |
-| 生境标注 | 人工标注可疑度最高的藏身点(cover 类,黄色框,约占画面 2%~15%),综合植被密度 / 地形 / 光线判断 |
-| 数据增强 | Mosaic、MixUp、HSV 扰动、随机翻转、随机缩放裁剪 |
-| 数据划分 | train 80% / val 10% / test 10% |
-| 负样本 | 补充无目标场景图,控制误检 |
-
-### 6.4 训练方案
-
-- 框架:ultralytics YOLOv8,预训练权重 `yolov8n.pt` 迁移学习;
-- 超参:imgsz=640(训练)、epochs 100~200(早停)、batch 16~32(视 GPU 而定);
-- 评估指标:
- - mAP@0.5 ≥ **0.85**(达标线 0.80);
- - mAP@0.5:0.95 ≥ **0.55**;
- - 负样本误检率 ≤ **2%**;
- - 远距离小目标(高度 ≤ 20px)召回率 ≥ **60%**。
-
-### 6.5 模型转换与量化
-
-- 导出命令:`yolo export model=best.pt format=tflite imgsz=320`;
-- 量化策略:
- - 优先 **fp16**(配合 GPU Delegate,精度损失小);
- - 低端机 / CPU 场景使用 **int8** 量化(体积更小、CPU 更快);
-- 输入尺寸:320×320(连续检测)/ 416×416(远距离模式,V2)。
-
-### 6.6 推理优化
-
-- GPU Delegate 优先,初始化失败自动回退 CPU(NNAPI 可选);
-- 推理线程 4 线程,首次推理前执行预热(dummy run);
-- 输入输出 ByteBuffer / Bitmap 复用,避免热路径频繁分配。
-
-### 6.7 精度与速度目标
-
-| 指标 | 目标 |
-| --- | --- |
-| 检测阈值(默认) | confidence ≥ 0.40,IoU-NMS = 0.45 |
-| 单帧推理延迟 | ≤ 80ms(中端机,320 输入,GPU) |
-| 模型体积 | fp16 ≤ 8MB,int8 ≤ 4MB |
-| 识别类别数 | 2 类(野鸡 + 生境区域 cover) |
-| 距离标注 | "约 X m",5~50m 误差 ≤ ±30%,计算开销可忽略 |
-
----
-
-## 7. 性能指标与优化
-
-| 指标 | 目标值 | 主要优化手段 |
-| --- | --- | --- |
-| 启动到相机预览 | ≤ 1.5s | 启动即初始化 CameraProvider,懒加载非核心模块 |
-| 单帧检测延迟 | ≤ 80ms | GPU Delegate、320 输入、线程复用 |
-| 预览帧率 | ≥ 25fps | 帧节流、KEEP_ONLY_LATEST、避免主线程工作 |
-| 内存峰值 | ≤ 200MB | Bitmap/ByteBuffer 复用,无大对象常驻 |
-| 连续 1 小时耗电 | ≤ 15% | 检测帧节流、省电模式、后台自动释放相机 |
-| App 体积 | ≤ 40MB | ABI 拆分、模型量化、R8 混淆 |
-
----
-
-## 8. 数据与隐私
-
-- 无账号、无埋点、无广告 SDK,**不采集任何用户数据**;
-- 不保存照片、不记录位置,运行期无网络请求;
-- 模型与标签随 APK 打包,识别数据仅存在于内存中,随进程结束自动释放。
-
----
-
-## 9. 安全与合规
-
-- 产品定位为**野生动物观察、识别工具**,不提供任何猎捕、诱捕、伤害野生动物的功能或指导;
-- 遵守《中华人民共和国野生动物保护法》《陆生野生动物保护实施条例》等法律法规;
-- 野鸡(雉类)属"三有"保护动物,猎捕须依法取得许可,App 不鼓励、不协助非法猎捕;
-- App 内置观察伦理提示:保持安全距离、不惊扰动物、遵守保护区管理规定(首次启动展示,功能文档 F07)。
-
----
-
-## 10. 开发计划(里程碑)
-
-| 阶段 | 周期 | 交付内容 |
-| --- | --- | --- |
-| M0 准备 | 1 周 | 需求确认、数据集启动采集、Android 工程脚手架、模型基线 |
-| M1 MVP | 3~4 周 | 相机预览 + 实时检测 + 叠加层 + 检测提醒 + 设置 + 合规提示 |
-| M2 打磨发布 | 1~2 周 | 性能优化、真机矩阵测试、混淆加固、上架准备 |
-
-总计约 **5~7 周**(不含数据采集并行时间)。
-
----
-
-## 11. 风险与应对
-
-| 风险 | 影响 | 应对 |
-| --- | --- | --- |
-| 训练数据不足 / 类别相近 | 精度低、误检 | 持续滚动采集数据;增加难例挖掘;灰度发布迭代模型 |
-| 小目标(远处动物) | 漏检 | 提高输入分辨率;帧节流换取算力;提示用户靠近/变焦;V2 引入 SAHI/tiling 或专用小目标模型 |
-| 低光 / 夜间场景 | 漏检 | 低光增强预处理;V2 夜视模式 |
-| 单目距离估计精度有限 | 距离显示不准 | 标注为"约"并明示误差预期;体型表持续校准;V2 地面平面法 + 水下折射修正 |
-| 生境区域误报偏多 | 提醒频繁、体验差 | 独立低阈值 + 区分提醒方式 + 防重复机制;生境阈值可调;模型迭代降低误报 |
-| 中低端机型性能不足 | 帧率低、发热 | 动态分辨率、降频检测、GPU 回退策略、省电模式 |
-| CameraX 个别机型异常 | 黑屏/闪退 | 机型兼容测试矩阵、崩溃监控、失败回退(重试/默认配置) |
-| 合规风险(被用于非法猎捕) | 法律风险 | 产品定位为观察工具、内置合规提示、不提供猎捕辅助功能 |
-
----
-
-## 12. 验收标准
-
-1. 野鸡测试集 mAP@0.5 ≥ 0.85,负样本误检率 ≤ 2%;
-2. 中端机(如骁龙 7 系)单帧检测 ≤ 80ms,预览流畅无卡顿;
-3. 连续使用 1 小时耗电 ≤ 15%,无异常发热;
-4. 主流机型(小米 / 华为 / OPPO / vivo / 三星,各 ≥ 2 台)启动成功率 ≥ 99%,崩溃率 ≤ 0.5%;
-5. 飞行模式下实时识别全流程(预览、检测、提醒、设置)可用;
-6. 检测框正确显示类别、置信度与距离;5~50m 范围距离误差 ≤ ±30%;
-7. 通过应用商店合规审核(隐私政策、权限声明完整)。
diff --git a/docs/02-项目功能文档.md b/docs/02-项目功能文档.md
deleted file mode 100644
index 8b9eaa7..0000000
--- a/docs/02-项目功能文档.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# Observer(野视)· 项目功能文档
-
-| 项目 | 内容 |
-| --- | --- |
-| 文档版本 | v1.1 |
-| 编写日期 | 2026-08-17 |
-| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) |
-| 配套文档 | 《01-技术方案》《03-技术实现文档》 |
-
----
-
-## 1. 产品概述
-
-### 1.1 产品定位
-
-一款**纯 Android 原生**的野生动物实时识别 App。用户打开相机,即可从实时画面中识别野鸡,通过彩色检测框(含类别、置信度、距离标注)与震动 / 声音提醒辅助快速发现。**无拍照、无记录、无统计**,专注实时识别这一核心体验,**全程离线可用**。
-
-### 1.2 目标用户
-
-- 野生动物观察爱好者(观鸟 / 观兽);
-- 户外徒步、摄影人群;
-- 自然教育、科普工作者。
-
-### 1.3 价值主张
-
-- **即时发现**:打开相机即识别,检测到目标立即提醒,免去翻图鉴、猜物种;
-- **零门槛**:不依赖网络,不依赖外设,一部手机即可;
-- **纯净体验**:无留存功能、无账号、无广告,即开即用。
-
----
-
-## 2. 功能架构(功能树)
-
-```
-Observer
-├── 实时识别
-│ ├── 相机实时预览(F01)
-│ ├── 目标实时检测(F02)
-│ ├── 识别结果展示与距离标注(F03)
-│ ├── 检测提醒(F04)
-│ └── 低光增强(F06,P2)
-├── 设置
-│ ├── 识别参数(F05)
-│ └── 提醒设置(F04 子项)
-└── 合规提示(F07)
-```
-
----
-
-## 3. 功能需求清单
-
-优先级定义:**P0**=必须,**P1**=V1.0,**P2**=后续版本。
-
-| 编号 | 功能 | 功能描述 | 优先级 |
-| --- | --- | --- | --- |
-| F01 | 相机实时预览 | 全屏取景,默认后置,支持自动对焦、双指缩放、点击对焦、前后摄切换 | P0 |
-| F02 | 目标实时检测 | 对野鸡实时检测框选 | P0 |
-| F03 | 识别结果展示与距离标注 | 检测框 + 类别名 + 置信度百分比 + 大致距离(约 X m);野鸡红色实线框,生境区域黄色虚线框 | P0 |
-| F04 | 检测提醒 | 检测到目标时震动 / 提示音;支持开关与 10s 防重复 | P1 |
-| F05 | 设置 | 置信度阈值(0.2~0.7)、检测频率(连续 / 标准 / 省电)、提醒开关、低光增强开关 | P1 |
-| F06 | 低光增强 | 低光环境下自动亮度 / 对比度增强,提高识别率 | P2 |
-| F07 | 合规提示 | 首次启动展示《观察伦理与法律提示》,需用户确认 | P0 |
-| F08 | 生境区域预警 | 画面中未检测到目标时,识别草丛 / 灌木 / 水面等疑似生境区域,以黄色虚线框展示(含距离标注)并触发预警(独立阈值与提醒方式) | P1 |
-
----
-
-## 4. 核心业务流程
-
-### 4.1 首次启动与授权
-
-```
-启动 App → 合规提示页(F07,用户确认)
- → 请求相机权限
- ├─ 授权 → 进入相机主界面
- └─ 拒绝 → 引导页说明用途,可重新授权
-```
-
-### 4.2 实时识别
-
-```
-相机主界面(默认后置)
-→ 画面持续检测(连续 / 标准 / 省电频率)
-→ 检测到目标:显示彩色检测框 + 类别 + 置信度 + 距离
- ├─ 触发提醒(震动 / 声音,10s 防重复)
- └─ 目标停留 ≥ 0.5s 保持显示,消失后 2s 内不闪断
-→ 未检测到目标时:识别疑似生境区域(草丛 / 灌木 / 水面)
- ├─ 黄色虚线框展示,标注"疑似区域"与距离
- └─ 触发预警(提醒方式与动物检测区分)
-→ 用户可点击检测框查看识别详情(物种、置信度、时间)
-```
-
----
-
-## 5. 界面设计
-
-### 5.1 界面总览
-
-仅两个界面:**相机主界面**(默认全屏展示)+ **设置页**(右上角齿轮入口)。
-
-### 5.2 相机主界面(核心界面)
-
-```
-┌─────────────────────────────────┐
-│ [低光] [频率] [前/后摄] │ ← 顶部工具
-│ 相机预览画面(全屏) │
-│ ┌─────────────────────────────┐ │
-│ │ ┌───────┐ │ │
-│ │ │ 野鸡 86% │ │ │ ← 检测框(按类别着色)
-│ │ │ 约 25m │ │ │
-│ │ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ │ │ ← 疑似生境区域(黄色虚线,预警)
-│ │ 疑似区域 · 约 20m │ │
-│ │ └───────┘ │ │
-│ └─────────────────────────────┘ │
-│ [提醒开关] [设置] │ ← 底部操作
-└─────────────────────────────────┘
-```
-
-交互说明:
-
-- 顶部:检测频率切换(连续 / 标准 / 省电)、低光增强开关(P2)、前后摄像头切换;
-- 中部:全屏预览,检测框 + 类别 + 置信度实时叠加;
-- 底部:提醒开关(震动 / 声音)、设置入口;
-- 支持双指缩放、点击对焦;
-- 检测到目标时边框高亮 + 震动 / 声音提醒(10s 防重复);
-- 检测框内显示类别、置信度与大致距离(约 X m),随目标实时更新;
-- 未检测到目标时,疑似生境区域以黄色虚线框展示(含距离标注)并预警(提醒方式与动物检测区分)。
-
-### 5.3 识别结果卡片(点击检测框弹出)
-
-| 元素 | 说明 |
-| --- | --- |
-| 物种名称 | 中文名 + 英文标签(如:野鸡 pheasant) |
-| 置信度 | 百分比进度条 |
-| 时间 | 检测时刻 |
-| 距离 | 约 25m(单目估算,误差 ±30%) |
-
-仅展示信息,无保存操作。
-
-疑似生境区域(黄色虚线框)点击后展示:区域类别、置信度、距离(约 X m,按参考植被高度估算,误差较大)。
-
-### 5.4 设置页
-
-| 分组 | 设置项 | 默认值 |
-| --- | --- | --- |
-| 识别 | 置信度阈值 | 0.40 |
-| 识别 | 检测频率 | 连续 |
-| 识别 | 低光增强 | 关(P2) |
-| 识别 | 距离标注 | 开 |
-| 识别 | 生境区域阈值 | 0.35 |
-| 提醒 | 震动提醒 / 提示音 / 防重复时长 | 开 / 开 / 10s |
-| 提醒 | 生境区域预警 | 开 |
-| 关于 | 版本信息 / 隐私说明 | — |
-
----
-
-## 6. 权限设计
-
-| 权限 | 用途 | 时机 | 备注 |
-| --- | --- | --- | --- |
-| CAMERA | 实时预览与识别 | 首次启动 | 必须 |
-
-原则:**最小权限、按需申请**。仅申请相机权限;拒绝后提供说明引导页(可跳转系统设置重新授权)。不申请定位、存储等任何其他权限。
-
----
-
-## 7. 非功能需求
-
-| 类别 | 要求 |
-| --- | --- |
-| 兼容性 | Android 7.0+(minSdk 24),竖屏为主,支持暗色模式 |
-| 性能 | 见《技术方案》第 7 章(延迟 ≤ 80ms、启动 ≤ 1.5s、内存 ≤ 200MB) |
-| 离线 | 飞行模式下实时识别全流程完整可用 |
-| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99%,异常自动降级(GPU 回退 CPU) |
-| 耗电 | 连续使用 1 小时 ≤ 15%;省电模式 ≤ 8% |
-| 隐私 | 无广告 SDK、无埋点、不保存任何数据,识别数据仅存内存 |
-| 无障碍 | 关键操作支持 TalkBack 描述;检测结果支持语音播报(V2) |
-
----
-
-## 8. 版本规划
-
-| 版本 | 范围 | 说明 |
-| --- | --- | --- |
-| MVP / V1.0 | F01~F05、F07、F08 | 相机 + 实时识别 + 生境预警 + 提醒 + 设置,即完整核心体验 |
-| V2.0 | +F06 及增强 | 低光增强、更多物种、夜视模式、模型远程更新、语音播报、距离精度增强(地面平面法) |
-
----
-
-## 9. 合规与安全说明
-
-- App 定位为**野生动物观察、识别工具**,不提供猎捕、诱捕、伤害野生动物的功能或指导;
-- 首次启动展示合规提示:遵守《中华人民共和国野生动物保护法》,野鸡属"三有"保护动物,猎捕须依法许可;观察时保持距离、不惊扰动物、遵守保护区规定;
-- 上架需提供隐私政策,声明仅使用相机权限、不采集与存储任何用户数据。
diff --git a/docs/03-技术实现文档.md b/docs/03-技术实现文档.md
deleted file mode 100644
index f81ceab..0000000
--- a/docs/03-技术实现文档.md
+++ /dev/null
@@ -1,512 +0,0 @@
-# Observer(野视)· 技术实现文档
-
-| 项目 | 内容 |
-| --- | --- |
-| 文档版本 | v1.1 |
-| 编写日期 | 2026-08-17 |
-| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照、记录、相册识别、定位相关实现) |
-| 配套文档 | 《01-技术方案》《02-项目功能文档》 |
-
----
-
-## 1. 项目结构
-
-```
-app/
-├── src/main/
-│ ├── java/com/example/observer/
-│ │ ├── MainActivity.kt // 单 Activity 入口
-│ │ ├── camera/
-│ │ │ ├── CameraController.kt // CameraX 生命周期绑定与用例组合
-│ │ │ └── FrameAnalyzer.kt // ImageAnalysis 帧分析器(节流 + 调度)
-│ │ ├── detection/
-│ │ │ ├── Detector.kt // 检测器抽象接口
-│ │ │ ├── TFLiteDetector.kt // TFLite 实现(YOLOv8n)
-│ │ │ ├── DetectionResult.kt // 检测结果数据类
-│ │ │ ├── Nms.kt // NMS 后处理
-│ │ │ └── CoordinateMapper.kt // 模型坐标 → 视图坐标映射
-│ │ ├── distance/
-│ │ │ └── DistanceEstimator.kt // 单目距离估计(针孔模型)
-│ │ ├── overlay/
-│ │ │ └── DetectionOverlay.kt // Compose 叠加层(Canvas 绘制检测框)
-│ │ ├── reminder/
-│ │ │ └── Reminder.kt // 震动 / 提示音提醒(含防重复)
-│ │ ├── ui/
-│ │ │ ├── camera/ CameraScreen.kt / CameraViewModel.kt
-│ │ │ └── settings/ SettingsScreen.kt / SettingsViewModel.kt
-│ │ └── util/
-│ │ └── BitmapUtils.kt // 缩放 / 旋转 / 复用
-│ ├── assets/
-│ │ ├── model.tflite // YOLOv8n 四类模型
-│ │ └── labels.txt // 类别标签(按 ID 顺序)
-│ └── res/
-└── build.gradle.kts
-```
-
----
-
-## 2. 技术栈与版本
-
-| 组件 | 版本(以最新稳定为准) | 用途 |
-| --- | --- | --- |
-| Kotlin | 2.x | 开发语言 |
-| AGP | 8.x | Android Gradle 插件 |
-| Jetpack Compose | BOM 2024.x+(Material 3) | UI |
-| CameraX | 1.4.x | Preview / ImageAnalysis |
-| TensorFlow Lite | 2.16.x | 端侧推理 |
-| tensorflow-lite-gpu | 2.16.x | GPU 加速 |
-| DataStore | 1.1.x | 设置存储 |
-
-依赖(build.gradle.kts 关键片段):
-
-```kotlin
-dependencies {
- implementation("androidx.camera:camera-core:1.4.1")
- implementation("androidx.camera:camera-camera2:1.4.1")
- implementation("androidx.camera:camera-lifecycle:1.4.1")
- implementation("androidx.camera:camera-view:1.4.1")
-
- implementation("org.tensorflow:tensorflow-lite:2.16.1")
- implementation("org.tensorflow:tensorflow-lite-gpu:2.16.1")
-
- implementation("androidx.datastore:datastore-preferences:1.1.1")
-}
-```
-
----
-
-## 3. 模块设计
-
-### 3.1 camera 模块 — CameraController.kt
-
-职责:创建并绑定 CameraX 用例(Preview + ImageAnalysis),统一生命周期。
-
-```kotlin
-class CameraController(
- private val lifecycleOwner: LifecycleOwner,
- private val analysisExecutor: Executor,
-) {
- private lateinit var cameraProvider: ProcessCameraProvider
- private lateinit var imageAnalysis: ImageAnalysis
-
- fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer) {
- val future = ProcessCameraProvider.getInstance(previewView.context)
- future.addListener({
- cameraProvider = future.get()
-
- val preview = Preview.Builder().build().also {
- it.surfaceProvider = previewView.surfaceProvider
- }
-
- imageAnalysis = ImageAnalysis.Builder()
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
- .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
- .build()
- imageAnalysis.setAnalyzer(analysisExecutor, analyzer)
-
- cameraProvider.bindToLifecycle(
- lifecycleOwner,
- CameraSelector.DEFAULT_BACK_CAMERA,
- preview, imageAnalysis,
- )
- }, ContextCompat.getMainExecutor(previewView.context))
- }
-}
-```
-
-要点:
-
-- `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+)直接得到 RGBA 图像,免 YUV 转换;
-- `STRATEGY_KEEP_ONLY_LATEST`:分析器忙时丢弃旧帧,不积压;
-- 相机权限检查与请求在进入本模块前完成;
-- 前后摄切换:重新以对应 `CameraSelector` 执行 `bindToLifecycle`(解绑旧用例)。
-
-### 3.2 camera 模块 — FrameAnalyzer.kt
-
-职责:帧节流、旋转获取、调度检测、结果回调。
-
-```kotlin
-class FrameAnalyzer(
- private val detector: Detector,
- private val onResult: (List, Int) -> Unit, // 结果 + 旋转角
-) : ImageAnalysis.Analyzer {
-
- private var lastDetectMs = 0L
-
- override fun analyze(imageProxy: ImageProxy) {
- val now = SystemClock.elapsedRealtime()
- val interval = frameIntervalMs() // 依据设置:连续/标准/省电
- if (now - lastDetectMs < interval) {
- imageProxy.close(); return
- }
- lastDetectMs = now
-
- val bitmap = imageProxy.toBitmap() // RGBA_8888 直接转 Bitmap
- val rotation = imageProxy.imageInfo.rotationDegrees
- try {
- val results = detector.detect(bitmap)
- onResult(results, rotation)
- } finally {
- imageProxy.close() // 必须关闭,否则阻塞流
- }
- }
-}
-```
-
-### 3.3 detection 模块 — Detector 接口
-
-```kotlin
-interface Detector {
- /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */
- fun detect(bitmap: Bitmap): List
-}
-```
-
-### 3.4 detection 模块 — TFLiteDetector.kt
-
-```kotlin
-class TFLiteDetector(
- context: Context,
- private val inputSize: Int = 320,
- private val confThreshold: Float = 0.40f, // 动物类阈值
- private val habitatThreshold: Float = 0.35f, // 生境区域阈值
- private val iouThreshold: Float = 0.45f,
- gpuEnabled: Boolean = true,
-) : Detector {
-
- private val labels: List =
- context.assets.open("labels.txt").bufferedReader().readLines()
-
- private val interpreter: Interpreter = Interpreter(
- loadModelFile(context, "model.tflite"),
- Interpreter.Options().apply {
- setNumThreads(4)
- if (gpuEnabled) {
- try { addDelegate(GpuDelegate()) } catch (_: Exception) { /* 回退 CPU */ }
- }
- },
- )
-
- private val inputBuffer: ByteBuffer = ByteBuffer.allocateDirect(
- 1 * inputSize * inputSize * 3 * 4 // float32
- ).order(ByteOrder.nativeOrder())
-
- private val outputBuffer: ByteBuffer = ByteBuffer.allocateDirect(
- OUTPUT_ELEMENTS * 4
- ).order(ByteOrder.nativeOrder())
-
- override fun detect(bitmap: Bitmap): List {
- preprocess(bitmap, inputBuffer) // 缩放 + RGB 归一化 → 输入缓冲
- interpreter.run(inputBuffer, outputBuffer) // 单张推理
- return postprocess(outputBuffer, bitmap.width, bitmap.height)
- }
-}
-```
-
-输入 / 输出规格(YOLOv8n 四类,320 输入):
-
-- 输入:`[1, 320, 320, 3]`,RGB,float32 归一化 0~1;
-- 输出:`[1, 9, 8400]`(8400 = 各尺度 anchor 数,9 = 4 个框坐标 cx/cy/w/h + 5 类得分),展平为 `9 * 8400` 个 float。
-
-### 3.5 detection 模块 — 后处理(解码 + NMS)
-
-```kotlin
-private fun postprocess(raw: FloatArray, imgW: Int, imgH: Int): List {
- val numAnchor = raw.size / 9
- val boxes = mutableListOf()
-
- for (a in 0 until numAnchor) {
- val cx = raw[a]; val cy = raw[9 + a]
- val w = raw[18 + a]; val h = raw[27 + a]
- var bestCls = 0; var bestScore = 0f
- for (c in 0 until 5) {
- val s = raw[36 + c * numAnchor + a] // 类别得分按 anchor 平铺
- if (s > bestScore) { bestScore = s; bestCls = c }
- }
- if (bestScore < confThreshold) continue
- boxes += DetectionResult(
- label = labels[bestCls],
- score = bestScore,
- // 归一化坐标(裁剪到 [0,1])
- left = (cx - w / 2).coerceIn(0f, 1f),
- top = (cy - h / 2).coerceIn(0f, 1f),
- right = (cx + w / 2).coerceIn(0f, 1f),
- bottom = (cy + h / 2).coerceIn(0f, 1f),
- )
- }
- return nms(boxes, iouThreshold)
-}
-
-fun nms(boxes: List, iouThreshold: Float): List {
- val sorted = boxes.sortedByDescending { it.score }
- val kept = mutableListOf()
- for (b in sorted) {
- if (kept.none { iou(b, it) > iouThreshold }) kept += b
- }
- return kept
-}
-```
-
-注意:以上取数索引为示意,**必须与模型导出时的输出布局(yolov8 tflite 为 9×8400,按列平铺)核对一致**,建议训练导出后用 Python 脚本先对单图做一致性校验再接入 App。
-
-### 3.6 distance 模块 — DistanceEstimator.kt
-
-```kotlin
-class DistanceEstimator(context: Context) {
-
- private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
- private var focalPxCache = -1f
-
- // 物种参考体型(米),用于针孔模型估算
- private val speciesSizeM = mapOf(
- "pheasant" to 0.45f, // 身高
- )
-
- /** 距离 = 焦距px × 参考体型 / 框高px(在分析图像分辨率下计算) */
- fun estimate(label: String, boxHeightNorm: Float, imageHeightPx: Int): Float? {
- val realH = speciesSizeM[label] ?: return null
- val boxH = boxHeightNorm * imageHeightPx
- if (boxH < 8f) return null // 过小目标不估算
- val focalPx = focalPx(imageHeightPx)
- if (focalPx <= 0) return null
- return round(focalPx * realH / boxH)
- }
-
- /** focal_px = focal_mm × (imageHeightPx / sensorHeightMm);视场角作回退 */
- private fun focalPx(imageHeightPx: Int): Float {
- if (focalPxCache > 0) return focalPxCache
- val c = cameraManager.getCameraCharacteristics(cameraManager.cameraIdList.first())
- val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull()
- val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
- val fov = c.get(CameraCharacteristics.LENS_INFO_HORIZONTAL_VIEW_ANGLE)
- focalPxCache = when {
- focalMm != null && sensor != null -> focalMm * imageHeightPx / sensor.height
- fov != null -> (imageHeightPx / 2f) / tan(fov / 2f)
- else -> -1f
- }
- return focalPxCache
- }
-}
-```
-
-说明:
-
-- 前后摄切换时需按当前相机重新获取焦距(缓存按相机 ID 区分);
-- 距离估算仅供参考,实际距离可能因环境因素有所偏差。
-
-### 3.7 detection 模块 — CoordinateMapper.kt
-
-模型输出为归一化坐标(相对分析图像,竖屏方向),需转换到预览视图坐标:
-
-```kotlin
-class CoordinateMapper(private val view: PreviewView) {
-
- /** 归一化坐标 → 预览视图像素坐标(考虑传感器旋转与 FIT_CENTER 裁剪) */
- fun mapToView(norm: DetectionResult, rotation: Int): RectF {
- // 1) 旋转校正:把"图像方向"归一化坐标转到"竖屏视图方向"
- val (x0, y0, x1, y1) = rotate(norm, rotation)
- // 2) 处理 FIT_CENTER 的裁剪偏移与缩放
- val viewW = view.width; val viewH = view.height
- val scale = min(viewW / bitmapW, viewH / bitmapH) // bitmap 尺寸
- val offsetX = (viewW - bitmapW * scale) / 2f
- val offsetY = (viewH - bitmapH * scale) / 2f
- return RectF(
- x0 * bitmapW * scale + offsetX,
- y0 * bitmapH * scale + offsetY,
- x1 * bitmapW * scale + offsetX,
- y1 * bitmapH * scale + offsetY,
- )
- }
-}
-```
-
-### 3.8 overlay 模块 — DetectionOverlay.kt
-
-Compose Canvas 叠加层:
-
-```kotlin
-@Composable
-fun DetectionOverlay(results: List, rotation: Int, modifier: Modifier) {
- val mapper = remember { CoordinateMapper(view) }
- Canvas(modifier = modifier) {
- results.forEach { r ->
- val rect = mapper.mapToView(r, rotation)
- drawRect(
- color = colorOf(r.label),
- topLeft = Offset(rect.left, rect.top),
- size = Size(rect.width(), rect.height()),
- style = Stroke(6.dp.toPx()),
- )
- // 距离由 ViewModel 计算后写入 DetectionResult.distance(Float?)
- val dist = r.distance?.let { " · 约${it}m" } ?: ""
- drawText("${r.label} ${(r.score * 100).toInt()}%$dist")
- }
- }
-}
-```
-
-防闪烁:ViewModel 中对结果做"目标停留 ≥ 0.5s 才显示、消失 2s 后移除"的平滑处理。
-
-### 3.9 reminder 模块 — Reminder.kt
-
-```kotlin
-class Reminder(private val context: Context) {
- private val vibrator = context.getSystemService(Vibrator::class.java)
- private var lastAlertAt = 0L
- private var lastAlertLabel: String? = null
-
- /** 同类目标 10s 内只提醒一次 */
- fun onDetected(label: String) {
- val now = SystemClock.elapsedRealtime()
- if (label == lastAlertLabel && now - lastAlertAt < 10_000) return
- lastAlertAt = now
- lastAlertLabel = label
- vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE))
- }
-}
-```
-
-### 3.10 设置存储 — DataStore
-
-使用 DataStore Preferences 保存:`conf_threshold`(默认 0.40)、`detect_mode`(连续/标准/省电)、`vibrate_enabled`、`sound_enabled`、`low_light_enhance`(P2)、`show_distance`(默认开)。`SettingsViewModel` 以 Flow 暴露,`FrameAnalyzer` 与 `TFLiteDetector` 读取最新值。
-
----
-
-## 4. 关键流程实现
-
-### 4.1 启动流程
-
-```
-MainActivity.onCreate
-→ 检查/请求 CAMERA 权限
-→ 初始化 TFLiteDetector(后台线程,含预热推理)
-→ CameraController.start(previewView, analyzer)
-→ ViewModel 订阅检测结果 → 叠加层渲染 + 提醒触发
-```
-
-预热:加载模型后执行一次空推理(全零输入),避免首帧卡顿。
-
-### 4.2 帧节流与并发控制
-
-- 检测间隔:连续 80~100ms / 标准 300ms / 省电 1000ms(由设置决定);
-- 推理在单线程 Executor(HandlerThread)串行执行,天然互斥;
-- 分析线程只做"取帧 → 判断节流 → 提交任务",不做推理,保证预览流畅。
-
-### 4.3 提醒、距离标注与防闪烁
-
-- 检测到目标(置信度 ≥ 阈值,生境区域用独立阈值)→ `Reminder.onDetected(label)`,10s 防重复,动物与生境预警提醒方式区分;
-- 叠加层展示逻辑:目标连续出现 ≥ 0.5s 才绘制;消失后 2s 内不清除,避免闪烁;
-- 低置信度结果(阈值以下)不展示、不提醒;
-- 距离标注:结果到达后调用 `DistanceEstimator` 估算"约 X m",随检测框渲染(FrameAnalyzer 回调携带分析图像高度);生境区域同样估算(按参考植被高度,误差较大);框高过小(< 8px)或无法获取焦距显示"--"。
-
----
-
-## 5. 模型集成细节
-
-| 项 | 说明 |
-| --- | --- |
-| 模型文件 | `app/src/main/assets/model.tflite`,随 APK 打包 |
-| 标签文件 | `assets/labels.txt`,每行一个类别,顺序与训练一致(pheasant) |
-| 量化 | 优先 fp16(GPU);int8 用于 CPU/低端机回退 |
-| 加载 | `Interpreter(loadAssetFile(...))`,进程内单例 |
-| 预热 | 初始化后执行一次 dummy run |
-| 校验 | 发布前用 Python 脚本(tflite-runtime)对测试集抽样验证模型输出布局与 App 解析一致 |
-
----
-
-## 6. 性能优化实践
-
-| 手段 | 说明 |
-| --- | --- |
-| GPU Delegate | 优先启用;`addDelegate` 抛异常或首帧异常时回退 CPU,并上报降级日志 |
-| 输入缓冲复用 | ByteBuffer 复用,避免每次检测重新分配 |
-| 帧节流 | 按模式降频,KEEP_ONLY_LATEST 不积压 |
-| 位图复用 | ImageProxy → Bitmap 走共享缓冲;叠加层仅绘制检测框,不复制整帧 |
-| 线程模型 | 检测线程单线程串行;UI 线程零推理 |
-| 低分辨率检测 | 320 输入检测;远距离模式可切 416(V2) |
-| 距离标注 | 纯算术运算开销可忽略;焦距 px 按相机缓存复用,避免重复查询 CameraCharacteristics |
-| 生命周期 | 后台自动 `unbind` 相机释放资源,避免耗电 |
-| 省电模式 | 降到 1 帧/秒 + 低分辨率 + 关闭提醒以外的动画 |
-
----
-
-## 7. 异常与降级策略
-
-| 异常场景 | 处理 |
-| --- | --- |
-| GPU 不可用 / 模型加载失败 | 回退 CPU 线程数调整;失败则提示"识别不可用"但不影响相机预览 |
-| 相机初始化失败(个别机型) | 重试一次 → 失败提示并引导检查权限/重启 |
-| 权限被拒 | 引导页说明用途,提供跳转系统设置 |
-| 低内存 | 主动释放缓存位图,降低检测分辨率 |
-| 推理超时(> 500ms) | 丢弃该帧结果,恢复下一帧,保证预览流畅 |
-| 无法读取焦距 / 视场角(个别机型) | 距离显示"--",识别不受影响 |
-
----
-
-## 8. 测试方案
-
-### 8.1 单元测试(JUnit)
-
-- `NmsTest`:NMS 正确性(重叠/多类别/边界框);
-- `CoordinateMapperTest`:四向旋转映射、FIT_CENTER 裁剪偏移;
-- `ThresholdTest`:置信度阈值过滤逻辑;
-- `ReminderTest`:10s 防重复提醒逻辑、动物与生境提醒方式区分;
-- `DistanceEstimatorTest`:焦距换算、距离公式、过小目标不估算、生境区域按植被高度估算。
-
-### 8.2 模型评估(Python 脚本,独立于 App)
-
-- 对测试集计算 mAP@0.5、mAP@0.5:0.95、各类别 AP、负样本误检率;
-- 输出混淆矩阵,分析检测精度。
-
-### 8.3 仪器测试(androidTest)
-
-- 模拟帧注入:将测试图片经 `ImageProxy` 注入分析器,断言结果(不依赖真机取景);
-- 权限拒绝 / 恢复场景;
-- 冷启动到预览的时间基准测试。
-
-### 8.4 真机测试矩阵
-
-| 机型档位 | 代表机型 | 验证项 |
-| --- | --- | --- |
-| 旗舰 | 骁龙 8 系 / 麒麟 9 系 | 全功能、GPU 路径、长时间发热 |
-| 中端 | 骁龙 7 系 / 天玑 8 系 | 延迟 ≤ 80ms、耗电 ≤ 15%/h |
-| 低端 | 骁龙 4 系 / 天玑 6 系 | CPU 回退路径、省电模式可用性 |
-
----
-
-## 9. 构建与发布
-
-```kotlin
-android {
- compileSdk = 35
- defaultConfig {
- applicationId = "com.example.observer"
- minSdk = 24
- targetSdk = 35
- ndk { abiFilters += listOf("arm64-v8a") } // 主发 arm64;如需兼容 32 位另发包
- }
- buildTypes {
- release {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
- }
- }
-}
-```
-
-- R8 规则:保留 TFLite 相关类(`-keep class org.tensorflow.** { *; }`),assets 模型不混淆;
-- 签名:正式签名 + Gradle 管理(或环境变量注入),不上传 keystore 到仓库;
-- 上架检查:隐私政策(仅相机权限、不采集与存储数据声明)、权限用途说明。
-
----
-
-## 10. 后续演进(V2 方向)
-
-- 更多物种类别(哺乳类、鸟类细分),支持模型远程更新;
-- 小目标优化:SAHI 切图推理 / 专用小目标模型 / 数字变焦辅助;
-- 夜视 / 红外增强模式;
-- 检测结果语音播报,提升无障碍体验;
-- 距离精度增强:基于设备俯仰角与相机高度的地面平面法、镜头畸变校正、水下折射修正;
-- 生境类别细分与精度提升(湿地、林缘等),生境预警策略优化;
-- 若未来需要留存能力(拍照、记录),可基于现有检测链路平滑扩展。
diff --git a/flutter_app/.gitignore b/flutter_app/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/flutter_app/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/flutter_app/.metadata b/flutter_app/.metadata
new file mode 100644
index 0000000..0fcff68
--- /dev/null
+++ b/flutter_app/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
+ base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
+ - platform: ios
+ create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
+ base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/flutter_app/README.md b/flutter_app/README.md
new file mode 100644
index 0000000..2561190
--- /dev/null
+++ b/flutter_app/README.md
@@ -0,0 +1,33 @@
+# observer
+
+野生动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见
+[`docs/PaymentApi.md`](docs/PaymentApi.md)。
+
+## iOS 真机部署(iPhone)
+
+### 构建与安装
+
+```bash
+# 真机必须传 Mac 局域网 IP:默认 API_BASE_URL 是 10.0.2.2(仅 Android 模拟器可用),
+# 不传则 iPhone 上所有网络请求(登录/授权/套餐)都会失败
+flutter build ios --release --dart-define=API_BASE_URL=http://:8080
+
+# 安装到真机(UDID 可用 `xcrun devicectl list devices` 查询)
+xcrun devicectl device install app --device build/ios/iphoneos/Runner.app
+
+# 启动并抓控制台日志(--terminate-existing 先杀掉旧实例)
+xcrun devicectl device process launch --console --terminate-existing \
+ --device com.observer.app
+```
+
+### 注意事项(踩过的坑)
+
+- **debug 构建不能在真机上从桌面图标启动**:iOS 14+ 会提示
+ "In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
+ debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
+ 从图标启动只对 release 构建有效。
+- **模型输入是 NHWC**:`assets/model.tflite` 做过字节级手术(开头 TRANSPOSE→RESHAPE,
+ 输入 [1,320,320,3]),改动记录见 git 历史,重导模型需同步处理,否则 iOS 报
+ "Node number 0 (TRANSPOSE) failed to prepare"。
+- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service
+ (`flutter run` 输出里的 DevTools 地址),或直接真机验证。
diff --git a/flutter_app/analysis_options.yaml b/flutter_app/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/flutter_app/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/flutter_app/android/.gitignore b/flutter_app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/flutter_app/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/flutter_app/android/app/build.gradle.kts b/flutter_app/android/app/build.gradle.kts
new file mode 100644
index 0000000..008a796
--- /dev/null
+++ b/flutter_app/android/app/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.example.observer"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.example.observer"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+// tflite_flutter 依赖的 tensorflow-lite / tensorflow-lite-gpu / tensorflow-lite-api 三个 AAR
+// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错;
+// 本项目仅用 CPU 推理,GPU delegate 未使用,排除 gpu 及其传递依赖的 api 即可。
+configurations.all {
+ exclude(group = "org.tensorflow", module = "tensorflow-lite-gpu")
+ exclude(group = "org.tensorflow", module = "tensorflow-lite-api")
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/flutter_app/android/app/src/debug/AndroidManifest.xml b/flutter_app/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/flutter_app/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0ed652d
--- /dev/null
+++ b/flutter_app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt
new file mode 100644
index 0000000..36d1984
--- /dev/null
+++ b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.observer
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/flutter_app/android/app/src/main/res/drawable/launch_background.xml b/flutter_app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/flutter_app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/flutter_app/android/app/src/main/res/values-night/styles.xml b/flutter_app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/flutter_app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/flutter_app/android/app/src/main/res/values/styles.xml b/flutter_app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/flutter_app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/flutter_app/android/app/src/profile/AndroidManifest.xml b/flutter_app/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/flutter_app/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/flutter_app/android/build.gradle.kts b/flutter_app/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/flutter_app/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/flutter_app/android/gradle.properties b/flutter_app/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/flutter_app/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false
diff --git a/gradle/wrapper/gradle-wrapper.properties b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties
similarity index 66%
rename from gradle/wrapper/gradle-wrapper.properties
rename to flutter_app/android/gradle/wrapper/gradle-wrapper.properties
index 1ef00a1..2d428bf 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,9 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
-networkTimeout=10000
-retries=0
-retryBackOffMs=500
-validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
diff --git a/flutter_app/android/settings.gradle.kts b/flutter_app/android/settings.gradle.kts
new file mode 100644
index 0000000..c21f0c5
--- /dev/null
+++ b/flutter_app/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.0.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.3.20" apply false
+}
+
+include(":app")
diff --git a/flutter_app/assets/beep.wav b/flutter_app/assets/beep.wav
new file mode 100644
index 0000000..8a7cb08
Binary files /dev/null and b/flutter_app/assets/beep.wav differ
diff --git a/app/src/main/assets/labels.txt b/flutter_app/assets/labels.txt
similarity index 52%
rename from app/src/main/assets/labels.txt
rename to flutter_app/assets/labels.txt
index 36cfae2..b64a776 100644
--- a/app/src/main/assets/labels.txt
+++ b/flutter_app/assets/labels.txt
@@ -1,2 +1,2 @@
pheasant
-cover
+suspect
diff --git a/flutter_app/docs/PaymentApi.md b/flutter_app/docs/PaymentApi.md
new file mode 100644
index 0000000..a7a79a3
--- /dev/null
+++ b/flutter_app/docs/PaymentApi.md
@@ -0,0 +1,103 @@
+# 后端 API 契约(账号 + 支付/授权)
+
+客户端(Flutter)与后端之间的 REST 契约。账号体系:手机号 + 密码注册登录,登录返回自签名 token,后续接口携带 `Authorization: Bearer `;**识别入口(搜索按钮)必须强制服务端校验授权**,不走本地缓存。
+
+- Base URL: `AppConfig.apiBaseUrl`(占位 `https://YOUR_BACKEND.example.com`)
+- 响应统一格式: `{"code": 0, "message": "ok", "data": {...}}`,`code != 0` 视为失败;登录失效返回 `code 61`,客户端应回登录页
+- 授权语义: 自然日(当天 24:00 失效 / 7 天 / 30 天),以服务端为准
+- 账号标识: 手机号(服务端 license 表即账号表,手机号为主键;无独立用户表)
+
+## 1. 注册
+
+`POST /api/v1/auth/register`(公开,无需登录)
+
+```json
+{"phone": "13800138000", "password": "pass123456"}
+```
+
+响应 `data` 为空对象。重复注册报错;已被运营手动授权(发卡占位行)的手机号可注册补密码,授权保留。
+
+## 2. 登录
+
+`POST /api/v1/auth/login`(公开,无需登录)
+
+```json
+{"phone": "13800138000", "password": "pass123456"}
+```
+
+响应 `data`:
+
+```json
+{"token": ""}
+```
+
+- token 无状态,有效期 `auth.tokenTtl`(默认 30 天),客户端存 secure storage
+- 后续所有接口携带 `Authorization: Bearer `
+
+## 3. 创建订单
+
+`POST /api/v1/orders`(需登录)
+
+```json
+{"planId": "day|week|month", "channel": "wechat|alipay"}
+```
+
+响应 `data`:
+
+```json
+{
+ "orderId": "O20260822001",
+ "payParams": {
+ "partnerId": "1900xxxxx", "prepayId": "wx...", "nonceStr": "...",
+ "timeStamp": "1728000000", "sign": "...", "packageValue": "Sign=WXPay"
+ }
+}
+```
+
+- 手机号由 token 识别,请求体不含 deviceId
+- `channel == wechat` 时 `payParams` 为微信 APP 支付下单参数
+- `channel == alipay` 时 `payParams` 为 `{"orderStr": "alipay_sdk=..."}`
+
+## 4. 支付结果确认
+
+`POST /api/v1/orders/{orderId}/confirm`(需登录)
+
+```json
+{}
+```
+
+客户端拉起 SDK 支付成功后调用(幂等)。服务端以微信/支付宝异步回调为准落授权;confirm 仅用于加速刷新。响应 `data: {"status": "paid|created|closed"}`。
+
+## 5. 查询授权
+
+`GET /api/v1/license`(需登录)
+
+响应 `data`:
+
+```json
+{"active": true, "expiresAt": "2026-08-29T23:59:59+08:00"}
+```
+
+- `active: false` 或 `expiresAt` 已过期 → 客户端展示付费墙/充值入口
+- **识别入口(搜索按钮)必须调本接口做强制校验**:网络失败视为不可用并提示,禁止用本地缓存放行
+- 主界面到期时间展示可用本地缓存(服务端为准,刷新时覆盖)
+
+## 6. 套餐
+
+`GET /api/v1/plans`(需登录)拉取价格方案,**客户端不硬编码价格**(进充值页时拉取,价格以服务端为准)。
+
+响应 `data`:
+
+```json
+{
+ "list": [
+ {"planId": "day", "days": 1, "priceCents": 1000},
+ {"planId": "week", "days": 7, "priceCents": 5600},
+ {"planId": "month", "days": 30, "priceCents": 18000}
+ ]
+}
+```
+
+- `priceCents` 为整数分,客户端展示 ÷100 转元
+- 展示名由客户端按 `days` 派生「N天」,接口无 label 字段
+- 后端套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置重启生效)
diff --git a/flutter_app/ios/.gitignore b/flutter_app/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/flutter_app/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/flutter_app/ios/Flutter/AppFrameworkInfo.plist b/flutter_app/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/flutter_app/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/flutter_app/ios/Flutter/Debug.xcconfig b/flutter_app/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..ec97fc6
--- /dev/null
+++ b/flutter_app/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/flutter_app/ios/Flutter/Release.xcconfig b/flutter_app/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..c4855bf
--- /dev/null
+++ b/flutter_app/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/flutter_app/ios/Podfile b/flutter_app/ios/Podfile
new file mode 100644
index 0000000..b7c1ed5
--- /dev/null
+++ b/flutter_app/ios/Podfile
@@ -0,0 +1,66 @@
+platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+
+ # 微信 SDK podspec 对模拟器保守排除 arm64(其 xcframework 实际含 arm64 slice),
+ # 不清除会导致 M 系 Mac 上模拟器构建被压成 x86_64 而无法安装运行。
+ wechat = installer.pods_project.targets.find { |t| t.name == 'WechatOpenSDK-XCFramework' }
+ wechat&.build_configurations&.each do |config|
+ config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
+ end
+
+ # WechatOpenSDK-XCFramework 的 Headers 不会自动进入依赖方搜索路径
+ # (CocoaPods 对 vendored XCFramework 的已知行为),fluwx 以引号引入
+ # WXApi.h 需要显式补充头文件搜索路径。
+ wechat_headers = Dir.glob(
+ File.join(Pod::Config.instance.project_root, 'Pods', 'WechatOpenSDK-XCFramework',
+ 'WechatOpenSDK.xcframework', '*', 'WechatOpenSDK.framework', 'Headers')
+ )
+ unless wechat_headers.empty?
+ fluwx = installer.pods_project.targets.find { |t| t.name == 'fluwx' }
+ fluwx&.build_configurations&.each do |config|
+ config.build_settings['HEADER_SEARCH_PATHS'] = [
+ '$(inherited)',
+ *wechat_headers.map { |p| "\"#{p}\"" },
+ ].join(' ')
+ end
+ end
+end
diff --git a/flutter_app/ios/Podfile.lock b/flutter_app/ios/Podfile.lock
new file mode 100644
index 0000000..645765e
--- /dev/null
+++ b/flutter_app/ios/Podfile.lock
@@ -0,0 +1,83 @@
+PODS:
+ - Flutter (1.0.0)
+ - flutter_secure_storage (6.0.0):
+ - Flutter
+ - fluwx (0.0.1):
+ - Flutter
+ - fluwx/pay (= 0.0.1)
+ - fluwx/pay (0.0.1):
+ - Flutter
+ - WechatOpenSDK-XCFramework (~> 2.0.4)
+ - TensorFlowLiteC (2.12.0):
+ - TensorFlowLiteC/Core (= 2.12.0)
+ - TensorFlowLiteC/Core (2.12.0)
+ - TensorFlowLiteC/CoreML (2.12.0):
+ - TensorFlowLiteC/Core
+ - TensorFlowLiteC/Metal (2.12.0):
+ - TensorFlowLiteC/Core
+ - TensorFlowLiteSwift (2.12.0):
+ - TensorFlowLiteSwift/Core (= 2.12.0)
+ - TensorFlowLiteSwift/Core (2.12.0):
+ - TensorFlowLiteC (= 2.12.0)
+ - TensorFlowLiteSwift/CoreML (2.12.0):
+ - TensorFlowLiteC/CoreML (= 2.12.0)
+ - TensorFlowLiteSwift/Core (= 2.12.0)
+ - TensorFlowLiteSwift/Metal (2.12.0):
+ - TensorFlowLiteC/Metal (= 2.12.0)
+ - TensorFlowLiteSwift/Core (= 2.12.0)
+ - tflite_flutter (0.0.1):
+ - Flutter
+ - TensorFlowLiteSwift (= 2.12.0)
+ - TensorFlowLiteSwift/CoreML (= 2.12.0)
+ - TensorFlowLiteSwift/Metal (= 2.12.0)
+ - tobias (0.0.1):
+ - Flutter
+ - tobias/normal (= 0.0.1)
+ - tobias/normal (0.0.1):
+ - Flutter
+ - vibration (1.7.5):
+ - Flutter
+ - WechatOpenSDK-XCFramework (2.0.7)
+
+DEPENDENCIES:
+ - Flutter (from `Flutter`)
+ - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+ - fluwx (from `.symlinks/plugins/fluwx/ios`)
+ - tflite_flutter (from `.symlinks/plugins/tflite_flutter/ios`)
+ - tobias (from `.symlinks/plugins/tobias/ios`)
+ - vibration (from `.symlinks/plugins/vibration/ios`)
+
+SPEC REPOS:
+ trunk:
+ - TensorFlowLiteC
+ - TensorFlowLiteSwift
+ - WechatOpenSDK-XCFramework
+
+EXTERNAL SOURCES:
+ Flutter:
+ :path: Flutter
+ flutter_secure_storage:
+ :path: ".symlinks/plugins/flutter_secure_storage/ios"
+ fluwx:
+ :path: ".symlinks/plugins/fluwx/ios"
+ tflite_flutter:
+ :path: ".symlinks/plugins/tflite_flutter/ios"
+ tobias:
+ :path: ".symlinks/plugins/tobias/ios"
+ vibration:
+ :path: ".symlinks/plugins/vibration/ios"
+
+SPEC CHECKSUMS:
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
+ fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b
+ TensorFlowLiteC: 20785a69299185a379ba9852b6625f00afd7984a
+ TensorFlowLiteSwift: 3a4928286e9e35bdd3e17970f48e53c80d25e793
+ tflite_flutter: 64b192e11352fe36943ab6656e1d49207f1a5595
+ tobias: 7bc370eaccba2e7c7c345902a4a47dc5916cf8bb
+ vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
+ WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47
+
+PODFILE CHECKSUM: bee538157bfc80e3e10d48a376da3677058aaad6
+
+COCOAPODS: 1.17.0
diff --git a/flutter_app/ios/Runner.xcodeproj/project.pbxproj b/flutter_app/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..5985cea
--- /dev/null
+++ b/flutter_app/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,782 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 847056A2B331EBEF7FE4658D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44D6E292382D4D067EB56539 /* Pods_Runner.framework */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ 9926B0B1CD9F66E289877411 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 11C083119EC1D6C49118DBAB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 352A4BA150122CC2D593B1FF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 44D6E292382D4D067EB56539 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ 6CCFCD2B5C2A6DB29ED6FEE9 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 95A9E03BE9A266B55717C203 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 8A8301CDECC0F90E622D6F74 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 9926B0B1CD9F66E289877411 /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ 847056A2B331EBEF7FE4658D /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 684998EFA363788852AAAD42 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 44D6E292382D4D067EB56539 /* Pods_Runner.framework */,
+ 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ F8827B4F9A04615E3B2278F0 /* Pods */,
+ 684998EFA363788852AAAD42 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ 6CCFCD2B5C2A6DB29ED6FEE9 /* Runner.entitlements */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ F8827B4F9A04615E3B2278F0 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 352A4BA150122CC2D593B1FF /* Pods-Runner.debug.xcconfig */,
+ 11C083119EC1D6C49118DBAB /* Pods-Runner.release.xcconfig */,
+ 95A9E03BE9A266B55717C203 /* Pods-Runner.profile.xcconfig */,
+ B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */,
+ 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */,
+ 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 435F2587C2B98BB6FE35CA42 /* [CP] Check Pods Manifest.lock */,
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ 8A8301CDECC0F90E622D6F74 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 1A05B1452EF4ACCDC4D1537B /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ 8C9F4CABE92F7C7C5DE4A971 /* [CP] Embed Pods Frameworks */,
+ 8E27EE045B33D54A69365374 /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 1A05B1452EF4ACCDC4D1537B /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 435F2587C2B98BB6FE35CA42 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 8C9F4CABE92F7C7C5DE4A971 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 8E27EE045B33D54A69365374 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = QRN5J857S2;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.observer.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = QRN5J857S2;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.observer.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = QRN5J857S2;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.observer.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..c3fedb2
--- /dev/null
+++ b/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/flutter_app/ios/Runner/AppDelegate.swift b/flutter_app/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..c30b367
--- /dev/null
+++ b/flutter_app/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..7353c41
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..6ed2d93
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cd7b00
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..fe73094
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..321773c
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..502f463
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e9f5fea
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..8953cba
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
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 0000000..0467bf1
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
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 0000000..9da19ea
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
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 0000000..9da19ea
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
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 0000000..9da19ea
Binary files /dev/null and b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
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 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 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 高预览下的估算焦距 px(5.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;
+}
diff --git a/flutter_app/lib/camera/frame_analyzer.dart b/flutter_app/lib/camera/frame_analyzer.dart
new file mode 100644
index 0000000..2608afa
--- /dev/null
+++ b/flutter_app/lib/camera/frame_analyzer.dart
@@ -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)。
+/// 推理在后台 isolate(DetectorWorker)执行,主 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 results,
+ List motion,
+ List 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();
+}
diff --git a/flutter_app/lib/camera/motion_detector.dart b/flutter_app/lib/camera/motion_detector.dart
new file mode 100644
index 0000000..e01c965
--- /dev/null
+++ b/flutter_app/lib/camera/motion_detector.dart
@@ -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? _prevGray;
+
+ MotionDetector({this.maxWidth = 128, this.maxHeight = 128});
+
+ /// 后台 isolate 用原始数据接口(不依赖 CameraImage)。
+ List 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.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;
+ }
+}
diff --git a/flutter_app/lib/config/app_config.dart b/flutter_app/lib/config/app_config.dart
new file mode 100644
index 0000000..7160cf1
--- /dev/null
+++ b/flutter_app/lib/config/app_config.dart
@@ -0,0 +1,22 @@
+/// 全局配置占位:接入真实支付前需替换以下值。
+class AppConfig {
+ /// 后端服务器地址(订单创建/授权查询/套餐价格)
+ /// 默认 Android 模拟器 10.0.2.2;iOS 模拟器构建时传
+ /// --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';
+}
diff --git a/flutter_app/lib/container.dart b/flutter_app/lib/container.dart
new file mode 100644
index 0000000..57d5a3a
--- /dev/null
+++ b/flutter_app/lib/container.dart
@@ -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),
+ },
+ );
+ }
+}
diff --git a/flutter_app/lib/detection/background_model.dart b/flutter_app/lib/detection/background_model.dart
new file mode 100644
index 0000000..1f58648
--- /dev/null
+++ b/flutter_app/lib/detection/background_model.dart
@@ -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 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 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;
+ }
+}
diff --git a/flutter_app/lib/detection/coordinate_mapper.dart b/flutter_app/lib/detection/coordinate_mapper.dart
new file mode 100644
index 0000000..2c3772a
--- /dev/null
+++ b/flutter_app/lib/detection/coordinate_mapper.dart
@@ -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,
+ );
+ }
+}
diff --git a/flutter_app/lib/detection/detection_result.dart b/flutter_app/lib/detection/detection_result.dart
new file mode 100644
index 0000000..8c5c5bb
--- /dev/null
+++ b/flutter_app/lib/detection/detection_result.dart
@@ -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;
+}
diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart
new file mode 100644
index 0000000..720c6ab
--- /dev/null
+++ b/flutter_app/lib/detection/detector_worker.dart
@@ -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();
+ final _ready = Completer();
+
+ SendPort? _port;
+
+ /// 在途帧数(主 isolate 侧计数,用于丢帧)
+ int _inFlight = 0;
+ bool _dead = false;
+
+ /// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 / 处理耗时 ms
+ void Function(List, List, List,
+ 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 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 _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());
+ 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();
+ final strides = (frame[1] as List).cast();
+ 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']);
+ }
+ }
+}
diff --git a/flutter_app/lib/detection/motion_aggregator.dart b/flutter_app/lib/detection/motion_aggregator.dart
new file mode 100644
index 0000000..a427a01
--- /dev/null
+++ b/flutter_app/lib/detection/motion_aggregator.dart
@@ -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 aggregate(List diff, int width, int height) {
+ final bw = width ~/ blockGrid;
+ final bh = height ~/ blockGrid;
+ if (bw == 0 || bh == 0) return const [];
+
+ final active = List.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 = [];
+ final visited = List.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 = [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 neighbors(int i) {
+ final bx = i % blockGrid;
+ final by = i ~/ blockGrid;
+ final list = [];
+ 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 diffMask(List gray, List prev,
+ [int threshold = diffThreshold]) {
+ final diff = List.filled(gray.length, 0);
+ for (var i = 0; i < gray.length; i++) {
+ diff[i] = (gray[i] - prev[i]).abs() > threshold ? 1 : 0;
+ }
+ return diff;
+ }
+}
diff --git a/flutter_app/lib/detection/nms.dart b/flutter_app/lib/detection/nms.dart
new file mode 100644
index 0000000..d5e33dc
--- /dev/null
+++ b/flutter_app/lib/detection/nms.dart
@@ -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 nms(List boxes, double iouThreshold) {
+ final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score));
+ final kept = [];
+ for (final b in sorted) {
+ if (!kept.any((k) => iou(b, k) > iouThreshold)) kept.add(b);
+ }
+ return kept;
+}
diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart
new file mode 100644
index 0000000..bf42f00
--- /dev/null
+++ b/flutter_app/lib/detection/tflite_detector.dart
@@ -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 _labels;
+ final int _numClasses;
+ final int _numAnchors;
+
+ final Float32List _input =
+ Float32List(1 * inputSize * inputSize * 3);
+
+ /// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织,
+ /// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。
+ final List>> _output;
+
+ TfliteDetector._(this._interpreter, this._labels, this._numClasses,
+ this._numAnchors, this._output);
+
+ /// 模型缺失或加载失败返回 null(App 降级为仅预览)。
+ /// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
+ static Future fromBuffer(
+ Uint8List bytes, List 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 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.filled(numAnchors, 0),
+ ),
+ );
+ return TfliteDetector._(
+ interpreter, labels, numClasses, numAnchors, output);
+ }
+
+ /// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。
+ /// 输出坐标统一反算为原图归一化空间(与 MotionDetector 一致),
+ /// 否则 CENTER_CROP 裁剪偏移会让检测框系统性偏移。
+ List detectRaw({
+ required List planes,
+ required List strides,
+ required int width,
+ required int height,
+ required bool isBgra,
+ }) {
+ preprocess(
+ planes: planes,
+ strides: strides,
+ width: width,
+ height: height,
+ isBgra: isBgra);
+ // 传原始字节视图而非 Float32List:tflite_flutter 会对非 ByteBuffer/Uint8List
+ // 输入调用 resizeInputTensor(1 维 [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 planes,
+ required List 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~1(NCHW),双线性采样。
+ /// 兼容 NV12(iOS 双平面,UV 交错)与 I420(Android 三平面)。
+ void _preprocessYuv(
+ List planes, List 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~235,Cb/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);
+
+ // NCHW:r/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 postprocess() {
+ final out = _output[0];
+ final boxes = [];
+ 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();
+}
diff --git a/flutter_app/lib/detection/visual_prior.dart b/flutter_app/lib/detection/visual_prior.dart
new file mode 100644
index 0000000..66b83a1
--- /dev/null
+++ b/flutter_app/lib/detection/visual_prior.dart
@@ -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 filter(
+ List results, {
+ required List planes,
+ required List strides,
+ required int width,
+ required int height,
+ required bool isBgra,
+ }) {
+ if (results.isEmpty || width <= 0 || height <= 0) return results;
+ final kept = [];
+ 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 planes,
+ List 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;
+ }
+
+ /// 读取单像素 RGB(0~255)。
+ /// BGRA 单平面:每像素 4 字节 [b,g,r,a];
+ /// YUV:y 平面 + 4:2:0 半分辨率 U/V(NV12 交错或 I420 分离)。
+ static (double, double, double) _pixel(List planes,
+ List 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);
+ }
+}
diff --git a/flutter_app/lib/home/home_screen.dart b/flutter_app/lib/home/home_screen.dart
new file mode 100644
index 0000000..0191890
--- /dev/null
+++ b/flutter_app/lib/home/home_screen.dart
@@ -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 createState() => _HomeScreenState();
+}
+
+class _HomeScreenState extends State {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ context.read().refresh();
+ });
+ }
+
+ Future _onSearch() async {
+ final vm = context.read();
+ 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 _onRecharge() async {
+ await Navigator.of(context).pushNamed('/paywall');
+ if (mounted) context.read().refresh();
+ }
+
+ Future _toLogin() async {
+ final container = context.read();
+ await container.clear();
+ if (!mounted) return;
+ Navigator.of(context).pushReplacementNamed('/login');
+ }
+
+ void _showBlocked(String? error) {
+ showDialog(
+ 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();
+ 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),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter_app/lib/home/home_view_model.dart b/flutter_app/lib/home/home_view_model.dart
new file mode 100644
index 0000000..7ed4e76
--- /dev/null
+++ b/flutter_app/lib/home/home_view_model.dart
@@ -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 refresh() async {
+ _loading = true;
+ _error = null;
+ notifyListeners();
+ try {
+ _license = await licenseService.check();
+ } catch (_) {
+ // check 内部已兜底,这里仅防御
+ } finally {
+ _loading = false;
+ notifyListeners();
+ }
+ }
+
+ /// 搜索按钮:强制服务端校验授权,active 才允许进入相机。
+ /// 网络失败/会话失效一律不放行;返回值表示是否可进入。
+ Future 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();
+ }
+}
diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart
new file mode 100644
index 0000000..1ec44d2
--- /dev/null
+++ b/flutter_app/lib/main.dart
@@ -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 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()));
+}
diff --git a/flutter_app/lib/payment/license_service.dart b/flutter_app/lib/payment/license_service.dart
new file mode 100644
index 0000000..da53f93
--- /dev/null
+++ b/flutter_app/lib/payment/license_service.dart
@@ -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 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 verifyServer() async {
+ if (await sessionStore.readToken() == null) {
+ throw const SessionExpiredException();
+ }
+ return orderApi.fetchLicense();
+ }
+
+ /// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰)
+ Future refresh() async {
+ final phone = await sessionStore.readPhone();
+ if (phone != null) await _clearCache(phone);
+ return orderApi.fetchLicense();
+ }
+
+ Future _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 _writeCache(String phone, DateTime expiresAt) =>
+ _storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String());
+
+ Future _clearCache(String phone) => _storage.delete(key: _cacheKey(phone));
+
+ static String _cacheKey(String phone) => 'license_expires_at:$phone';
+}
diff --git a/flutter_app/lib/payment/models.dart b/flutter_app/lib/payment/models.dart
new file mode 100644
index 0000000..5b9738e
--- /dev/null
+++ b/flutter_app/lib/payment/models.dart
@@ -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 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 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 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});
+}
diff --git a/flutter_app/lib/payment/order_api.dart b/flutter_app/lib/payment/order_api.dart
new file mode 100644
index 0000000..567f4fc
--- /dev/null
+++ b/flutter_app/lib/payment/order_api.dart
@@ -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 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 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 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() ?? {};
+ return Order(
+ orderId: json['orderId'] as String,
+ wechatParams: params,
+ alipayOrderStr: params['orderStr'] as String?,
+ );
+ }
+
+ /// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权
+ Future confirmOrder(String orderId) async {
+ final res =
+ await _post('/api/v1/orders/$orderId/confirm', jsonEncode({}));
+ _decode(res);
+ }
+
+ /// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码)
+ Future> 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))
+ .toList();
+ }
+
+ /// 查询授权状态(服务端为准)
+ Future 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 _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 {},
+ },
+ body: body,
+ )
+ .timeout(const Duration(seconds: 15));
+ } catch (e) {
+ if (e is OrderApiException) rethrow;
+ throw OrderApiException('网络请求失败: $e');
+ }
+ }
+
+ Future