Compare commits
No commits in common. "animal-rating-android-studio" and "livingai-cleanarch-kmp" have entirely different histories.
animal-rat
...
livingai-c
|
|
@ -4,17 +4,6 @@
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-11-24T18:35:29.331278100Z">
|
|
||||||
<Target type="DEFAULT_BOOT">
|
|
||||||
<handle>
|
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=10BF45100J001X5" />
|
|
||||||
</handle>
|
|
||||||
</Target>
|
|
||||||
</DropdownSelection>
|
|
||||||
<DialogSelection />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="release">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MarkdownSettings">
|
|
||||||
<option name="previewPanelProviderInfo">
|
|
||||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,4 @@
|
||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
|
|
@ -2,16 +2,17 @@ plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlinx.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.animalrating"
|
namespace = "com.example.livingai"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36)
|
version = release(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.animalrating"
|
applicationId = "com.example.livingai"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
|
|
@ -27,7 +28,6 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -39,25 +39,46 @@ android {
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
mlModelBinding = true
|
||||||
|
}
|
||||||
|
aaptOptions {
|
||||||
|
noCompress += "tflite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val cameraxVersion = "1.5.1"
|
implementation(libs.androidx.paging.common)
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.window)
|
||||||
|
val cameraxVersion = "1.5.0-alpha03"
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
implementation("androidx.cardview:cardview:1.0.0")
|
implementation("androidx.cardview:cardview:1.0.0")
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
//Splash Api
|
||||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
implementation("androidx.camera:camera-core:${cameraxVersion}")
|
||||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
|
||||||
implementation("androidx.camera:camera-mlkit-vision:$cameraxVersion")
|
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
|
||||||
|
implementation("androidx.camera:camera-view:${cameraxVersion}")
|
||||||
// ML Kit Object Detection
|
implementation("androidx.camera:camera-video:${cameraxVersion}")
|
||||||
|
implementation("androidx.camera:camera-mlkit-vision:${cameraxVersion}")
|
||||||
implementation("com.google.mlkit:object-detection:17.0.2")
|
implementation("com.google.mlkit:object-detection:17.0.2")
|
||||||
// ML Kit Subject Segmentation (Google Play Services version)
|
|
||||||
implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1")
|
implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1")
|
||||||
|
|
||||||
|
// Tensorflow Lite
|
||||||
|
implementation(libs.tensorflow.lite.support)
|
||||||
|
implementation(libs.tensorflow.lite)
|
||||||
|
implementation(libs.tensorflow.lite.gpu)
|
||||||
|
implementation(libs.tensorflow.lite.task.vision)
|
||||||
|
|
||||||
|
//Koin
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.androidx.compose)
|
||||||
|
|
||||||
|
//Navigation (Nav3 / Type-Safe Navigation)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
@ -66,6 +87,23 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
// Icons
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.7.5")
|
||||||
|
|
||||||
|
// OpenCSV
|
||||||
|
implementation("com.opencsv:opencsv:5.7.1")
|
||||||
|
|
||||||
|
// Coil
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-video:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
|
||||||
|
// Paging
|
||||||
|
implementation("androidx.paging:paging-runtime:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.animalrating
|
package com.example.livingai
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
||||||
fun useAppContext() {
|
fun useAppContext() {
|
||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
assertEquals("com.example.animalrating", appContext.packageName)
|
assertEquals("com.example.livingai", appContext.packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
|
@ -2,13 +2,21 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-feature android:name="android.hardware.camera" />
|
<uses-feature android:name="android.hardware.camera" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Permissions for Scoped Storage on Android 13+ -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".LivingAIApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|
@ -16,46 +24,23 @@
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.AnimalRating"
|
android:theme="@style/LivingAI.Starting.Theme">
|
||||||
android:requestLegacyExternalStorage="true">
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="obj" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".HomeActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.AnimalRating">
|
android:theme="@style/LivingAI.Starting.Theme">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".CowSelectionActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AnimalRating" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".CameraProcessor"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AnimalRating" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".GalleryActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AnimalRating" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".FullScreenImageActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AnimalRating" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".RatingActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AnimalRating" />
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,81 @@
|
||||||
|
Unknown
|
||||||
|
person
|
||||||
|
bicycle
|
||||||
|
car
|
||||||
|
motorcycle
|
||||||
|
airplane
|
||||||
|
bus
|
||||||
|
train
|
||||||
|
truck
|
||||||
|
boat
|
||||||
|
traffic light
|
||||||
|
fire hydrant
|
||||||
|
stop sign
|
||||||
|
parking meter
|
||||||
|
bench
|
||||||
|
bird
|
||||||
|
cat
|
||||||
|
dog
|
||||||
|
horse
|
||||||
|
sheep
|
||||||
|
cow
|
||||||
|
elephant
|
||||||
|
bear
|
||||||
|
zebra
|
||||||
|
giraffe
|
||||||
|
backpack
|
||||||
|
umbrella
|
||||||
|
handbag
|
||||||
|
tie
|
||||||
|
suitcase
|
||||||
|
frisbee
|
||||||
|
skis
|
||||||
|
snowboard
|
||||||
|
sports ball
|
||||||
|
kite
|
||||||
|
baseball bat
|
||||||
|
baseball glove
|
||||||
|
skateboard
|
||||||
|
surfboard
|
||||||
|
tennis racket
|
||||||
|
bottle
|
||||||
|
wine glass
|
||||||
|
cup
|
||||||
|
fork
|
||||||
|
knife
|
||||||
|
spoon
|
||||||
|
bowl
|
||||||
|
banana
|
||||||
|
apple
|
||||||
|
sandwich
|
||||||
|
orange
|
||||||
|
broccoli
|
||||||
|
carrot
|
||||||
|
hot dog
|
||||||
|
pizza
|
||||||
|
donut
|
||||||
|
cake
|
||||||
|
chair
|
||||||
|
couch
|
||||||
|
potted plant
|
||||||
|
bed
|
||||||
|
dining table
|
||||||
|
toilet
|
||||||
|
tv
|
||||||
|
laptop
|
||||||
|
mouse
|
||||||
|
remote
|
||||||
|
keyboard
|
||||||
|
cell phone
|
||||||
|
microwave
|
||||||
|
oven
|
||||||
|
toaster
|
||||||
|
sink
|
||||||
|
refrigerator
|
||||||
|
book
|
||||||
|
clock
|
||||||
|
vase
|
||||||
|
scissors
|
||||||
|
teddy bear
|
||||||
|
hair drier
|
||||||
|
toothbrush
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ActivityInfo
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Log
|
|
||||||
import android.util.Size
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.camera.core.ExperimentalGetImage
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
|
||||||
import androidx.camera.core.ImageCapture
|
|
||||||
import androidx.camera.core.ImageCaptureException
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.camera.view.PreviewView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.example.animalrating.ml.CowAnalyzer
|
|
||||||
import com.example.animalrating.ui.SilhouetteOverlay
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
class CameraProcessor : AppCompatActivity(), CowAnalyzer.CowListener {
|
|
||||||
|
|
||||||
private lateinit var previewView: PreviewView
|
|
||||||
private lateinit var overlay: SilhouetteOverlay
|
|
||||||
private lateinit var segmentationOverlay: ImageView
|
|
||||||
private lateinit var savedMaskOverlay: ImageView
|
|
||||||
private var imageCapture: ImageCapture? = null
|
|
||||||
private lateinit var frameProcessor: FrameProcessor
|
|
||||||
|
|
||||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
|
||||||
|
|
||||||
private var cowName: String? = null
|
|
||||||
private var orientation: String? = null
|
|
||||||
private var currentMask: Bitmap? = null
|
|
||||||
private var savedMaskBitmap: Bitmap? = null
|
|
||||||
private var isPhotoTaken = false
|
|
||||||
private var matchThreshold = 75
|
|
||||||
private var algorithm = HomeActivity.ALGORITHM_HAMMING
|
|
||||||
private var isAutoCapture = true
|
|
||||||
private var isMaskDisplayEnabled = false
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_main)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
|
|
||||||
cowName = intent.getStringExtra("COW_NAME")
|
|
||||||
orientation = intent.getStringExtra("ORIENTATION")
|
|
||||||
|
|
||||||
// Load settings
|
|
||||||
val prefs = getSharedPreferences("AnimalRatingPrefs", MODE_PRIVATE)
|
|
||||||
matchThreshold = prefs.getInt("THRESHOLD", 75)
|
|
||||||
algorithm = prefs.getString("ALGORITHM", HomeActivity.ALGORITHM_HAMMING) ?: HomeActivity.ALGORITHM_HAMMING
|
|
||||||
isAutoCapture = prefs.getBoolean(HomeActivity.PREF_AUTO_CAPTURE, true)
|
|
||||||
isMaskDisplayEnabled = prefs.getBoolean(HomeActivity.PREF_MASK_DISPLAY, false)
|
|
||||||
|
|
||||||
// Set orientation based on selected view
|
|
||||||
if (orientation == "front" || orientation == "back") {
|
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
} else {
|
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
||||||
}
|
|
||||||
|
|
||||||
previewView = findViewById(R.id.cameraPreview)
|
|
||||||
overlay = findViewById(R.id.silhouetteOverlay)
|
|
||||||
segmentationOverlay = findViewById(R.id.segmentationOverlay)
|
|
||||||
savedMaskOverlay = findViewById(R.id.savedMaskOverlay)
|
|
||||||
|
|
||||||
findViewById<MaterialButton>(R.id.btnExit).setOnClickListener {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
val btnShutter = findViewById<MaterialButton>(R.id.btnShutter)
|
|
||||||
val btnToggle = findViewById<MaterialButton>(R.id.btnToggleCaptureMode)
|
|
||||||
|
|
||||||
btnShutter.setOnClickListener {
|
|
||||||
takePhoto()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCaptureModeUI()
|
|
||||||
|
|
||||||
btnToggle.setOnClickListener {
|
|
||||||
isAutoCapture = !isAutoCapture
|
|
||||||
// Save preference for persistence if desired, or just toggle for session
|
|
||||||
prefs.edit().putBoolean(HomeActivity.PREF_AUTO_CAPTURE, isAutoCapture).apply()
|
|
||||||
updateCaptureModeUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
frameProcessor = FrameProcessor()
|
|
||||||
|
|
||||||
val silhouetteId = intent.getIntExtra("SILHOUETTE_ID", 0)
|
|
||||||
overlay.setSilhouette(silhouetteId)
|
|
||||||
|
|
||||||
// Need to wait for layout to get width/height for scaling mask correctly
|
|
||||||
savedMaskOverlay.post {
|
|
||||||
loadSavedMask()
|
|
||||||
}
|
|
||||||
|
|
||||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateCaptureModeUI() {
|
|
||||||
val btnShutter = findViewById<MaterialButton>(R.id.btnShutter)
|
|
||||||
val btnToggle = findViewById<MaterialButton>(R.id.btnToggleCaptureMode)
|
|
||||||
|
|
||||||
if (isAutoCapture) {
|
|
||||||
btnShutter.visibility = View.GONE
|
|
||||||
// Auto Mode: Eye Icon, Dark Background
|
|
||||||
btnToggle.setIconResource(android.R.drawable.ic_menu_view)
|
|
||||||
btnToggle.setIconTintResource(android.R.color.white)
|
|
||||||
btnToggle.setBackgroundColor(Color.parseColor("#6D4C41"))
|
|
||||||
btnToggle.alpha = 1.0f
|
|
||||||
} else {
|
|
||||||
btnShutter.visibility = View.VISIBLE
|
|
||||||
// Manual Mode: Eye Icon (Grey/Transparent) - Requested to keep Eye icon
|
|
||||||
btnToggle.setIconResource(android.R.drawable.ic_menu_view)
|
|
||||||
btnToggle.setIconTintResource(android.R.color.darker_gray)
|
|
||||||
btnToggle.setBackgroundColor(Color.TRANSPARENT)
|
|
||||||
btnToggle.alpha = 0.7f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun takePhoto() {
|
|
||||||
if (isPhotoTaken) return
|
|
||||||
isPhotoTaken = true
|
|
||||||
|
|
||||||
val imageCapture = imageCapture ?: return
|
|
||||||
|
|
||||||
val name = cowName ?: "unknown"
|
|
||||||
val side = orientation ?: "unknown"
|
|
||||||
val silhouetteId = intent.getIntExtra("SILHOUETTE_ID", 0)
|
|
||||||
|
|
||||||
// Find current count for this cow and orientation
|
|
||||||
val cowFolder = StorageUtils.getCowImageFolder(name)
|
|
||||||
|
|
||||||
val existingFiles = cowFolder.listFiles { _, fname ->
|
|
||||||
fname.startsWith("${name}_${side}_") && fname.endsWith(".jpg")
|
|
||||||
}
|
|
||||||
val count = (existingFiles?.size ?: 0) + 1
|
|
||||||
|
|
||||||
if (!cowFolder.exists()) {
|
|
||||||
cowFolder.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
val filename = "${name}_${side}_${count}.jpg"
|
|
||||||
val file = File(cowFolder, filename)
|
|
||||||
|
|
||||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
|
|
||||||
|
|
||||||
imageCapture.takePicture(
|
|
||||||
outputOptions,
|
|
||||||
ContextCompat.getMainExecutor(this),
|
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
|
||||||
override fun onError(exc: ImageCaptureException) {
|
|
||||||
Log.e("CameraProcessor", "Photo capture failed: ${exc.message}", exc)
|
|
||||||
Toast.makeText(baseContext, StringProvider.getString("toast_capture_failed"), Toast.LENGTH_SHORT).show()
|
|
||||||
isPhotoTaken = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
||||||
// Correct rotation based on current orientation
|
|
||||||
try {
|
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
||||||
val matrix = Matrix()
|
|
||||||
|
|
||||||
val rotation = if (orientation == "front" || orientation == "back") 90f else 0f
|
|
||||||
|
|
||||||
if (rotation != 0f) {
|
|
||||||
matrix.postRotate(rotation)
|
|
||||||
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
FileOutputStream(file).use { out ->
|
|
||||||
rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val retakePath = intent.getStringExtra("RETAKE_IMAGE_PATH")
|
|
||||||
if (!retakePath.isNullOrEmpty()) {
|
|
||||||
val oldFile = File(retakePath)
|
|
||||||
if (oldFile.exists()) {
|
|
||||||
oldFile.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val msg = "${StringProvider.getString("toast_saved_as")} $filename"
|
|
||||||
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
|
|
||||||
|
|
||||||
// Navigate to FullScreenImageActivity
|
|
||||||
val intent = Intent(this@CameraProcessor, FullScreenImageActivity::class.java)
|
|
||||||
intent.putExtra("IMAGE_PATH", file.absolutePath)
|
|
||||||
intent.putExtra("ALLOW_RETAKE", true)
|
|
||||||
intent.putExtra("COW_NAME", cowName)
|
|
||||||
intent.putExtra("ORIENTATION", orientation)
|
|
||||||
intent.putExtra("SILHOUETTE_ID", silhouetteId)
|
|
||||||
startActivity(intent)
|
|
||||||
finish()
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("CameraProcessor", "Error saving image", e)
|
|
||||||
Toast.makeText(baseContext, StringProvider.getString("toast_error_saving_image"), Toast.LENGTH_SHORT).show()
|
|
||||||
isPhotoTaken = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveToGallery(file: File) {
|
|
||||||
// Removed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadSavedMask() {
|
|
||||||
val side = orientation ?: "unknown"
|
|
||||||
val filename = "${side}_mask.png"
|
|
||||||
val file = File(filesDir, filename)
|
|
||||||
|
|
||||||
if (file.exists()) {
|
|
||||||
try {
|
|
||||||
val savedBitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
||||||
|
|
||||||
if (savedBitmap != null) {
|
|
||||||
// Apply green color filter for visualization
|
|
||||||
if (isMaskDisplayEnabled) {
|
|
||||||
val greenMask = applyGreenColor(savedBitmap)
|
|
||||||
|
|
||||||
// Calculate scale to match FIT_CENTER logic of SilhouetteOverlay
|
|
||||||
val viewW = savedMaskOverlay.width.toFloat()
|
|
||||||
val viewH = savedMaskOverlay.height.toFloat()
|
|
||||||
|
|
||||||
if (viewW > 0 && viewH > 0) {
|
|
||||||
val bmpW = greenMask.width.toFloat()
|
|
||||||
val bmpH = greenMask.height.toFloat()
|
|
||||||
|
|
||||||
val scale = kotlin.math.min(viewW / bmpW, viewH / bmpH)
|
|
||||||
val scaledW = (bmpW * scale).toInt()
|
|
||||||
val scaledH = (bmpH * scale).toInt()
|
|
||||||
|
|
||||||
if (scaledW > 0 && scaledH > 0) {
|
|
||||||
val scaledBitmap = Bitmap.createScaledBitmap(greenMask, scaledW, scaledH, true)
|
|
||||||
savedMaskOverlay.setImageBitmap(scaledBitmap)
|
|
||||||
savedMaskOverlay.scaleType = ImageView.ScaleType.FIT_CENTER // Ensure it centers
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback if view size not ready yet, though post() should handle it
|
|
||||||
savedMaskOverlay.setImageBitmap(greenMask)
|
|
||||||
}
|
|
||||||
|
|
||||||
savedMaskOverlay.alpha = 0.5f
|
|
||||||
} else {
|
|
||||||
savedMaskOverlay.setImageDrawable(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare mask for analysis (640x480 target)
|
|
||||||
// We should ideally match the visible part of the preview
|
|
||||||
// For simplicity, keeping original scaling logic for analysis as it might depend on full frame
|
|
||||||
// However, if the overlay is FIT_CENTER, the analysis should likely respect that aspect ratio too
|
|
||||||
// But for now, let's just fix the visual overlay as requested.
|
|
||||||
|
|
||||||
val isPortrait = (side == "front" || side == "back")
|
|
||||||
val width = if (isPortrait) 480 else 640
|
|
||||||
val height = if (isPortrait) 640 else 480
|
|
||||||
|
|
||||||
savedMaskBitmap = Bitmap.createScaledBitmap(savedBitmap, width, height, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("CameraProcessor", "Error loading saved mask", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyGreenColor(original: Bitmap): Bitmap {
|
|
||||||
val width = original.width
|
|
||||||
val height = original.height
|
|
||||||
val pixels = IntArray(width * height)
|
|
||||||
original.getPixels(pixels, 0, width, 0, 0, width, height)
|
|
||||||
|
|
||||||
for (i in pixels.indices) {
|
|
||||||
val alpha = (pixels[i] shr 24) and 0xff
|
|
||||||
if (alpha > 10) {
|
|
||||||
// Set to Green with original alpha
|
|
||||||
pixels[i] = Color.argb(alpha, 0, 255, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val requestPermissionLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) startCamera()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCamera() {
|
|
||||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
|
||||||
|
|
||||||
providerFuture.addListener({
|
|
||||||
val cameraProvider = providerFuture.get()
|
|
||||||
|
|
||||||
val preview = androidx.camera.core.Preview.Builder().build()
|
|
||||||
preview.surfaceProvider = previewView.surfaceProvider
|
|
||||||
|
|
||||||
imageCapture = ImageCapture.Builder()
|
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val analyzer = ImageAnalysis.Builder()
|
|
||||||
.setTargetResolution(Size(640, 480))
|
|
||||||
.setBackpressureStrategy(
|
|
||||||
ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
.also {
|
|
||||||
it.setAnalyzer(cameraExecutor, CowAnalyzer(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraProvider.unbindAll()
|
|
||||||
cameraProvider.bindToLifecycle(
|
|
||||||
this,
|
|
||||||
androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA,
|
|
||||||
preview,
|
|
||||||
imageCapture,
|
|
||||||
analyzer
|
|
||||||
)
|
|
||||||
|
|
||||||
}, ContextCompat.getMainExecutor(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalGetImage
|
|
||||||
override fun onFrame(imageProxy: ImageProxy) {
|
|
||||||
if (isPhotoTaken) {
|
|
||||||
imageProxy.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPortrait = (orientation == "front" || orientation == "back")
|
|
||||||
|
|
||||||
frameProcessor.processFrame(imageProxy, savedMaskBitmap, isPortrait, matchThreshold, algorithm)
|
|
||||||
.addOnSuccessListener { result ->
|
|
||||||
runOnUiThread {
|
|
||||||
if (result.mask != null) {
|
|
||||||
currentMask = result.mask
|
|
||||||
if (isMaskDisplayEnabled) {
|
|
||||||
segmentationOverlay.setImageBitmap(result.mask)
|
|
||||||
} else {
|
|
||||||
segmentationOverlay.setImageDrawable(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isAutoCapture && result.isMatch) {
|
|
||||||
takePhoto()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.addOnFailureListener { e ->
|
|
||||||
Log.e("CameraProcessor", "Frame processing error", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,519 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.AutoCompleteTextView
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.RadioButton
|
|
||||||
import android.widget.RadioGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.FileWriter
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class CowSelectionActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private var currentCowName: String? = null
|
|
||||||
private lateinit var imagesContainer: LinearLayout
|
|
||||||
private val storagePermissionCode = 101
|
|
||||||
private val orientationViews = mutableMapOf<String, View>()
|
|
||||||
private val initialImagePaths = mutableSetOf<String>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_cow_selection)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
setupUIStrings()
|
|
||||||
|
|
||||||
val toolbar = findViewById<androidx.appcompat.widget.Toolbar>(R.id.toolbar)
|
|
||||||
setSupportActionBar(toolbar)
|
|
||||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
|
||||||
|
|
||||||
toolbar.setNavigationOnClickListener {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeDefaultMasks()
|
|
||||||
setupDropdowns()
|
|
||||||
|
|
||||||
imagesContainer = findViewById(R.id.currentCowImagesContainer)
|
|
||||||
|
|
||||||
currentCowName = savedInstanceState?.getString("COW_NAME") ?: intent.getStringExtra("COW_NAME")
|
|
||||||
if (currentCowName == null) {
|
|
||||||
generateNewCowName()
|
|
||||||
}
|
|
||||||
|
|
||||||
loadInitialImages()
|
|
||||||
|
|
||||||
if (intent.hasExtra("COW_NAME")) {
|
|
||||||
loadCowDetails(currentCowName!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
orientationViews["left"] = findViewById(R.id.btnLeft)
|
|
||||||
orientationViews["right"] = findViewById(R.id.btnRight)
|
|
||||||
orientationViews["angle"] = findViewById(R.id.btnTop)
|
|
||||||
orientationViews["front"] = findViewById(R.id.btnFront)
|
|
||||||
orientationViews["back"] = findViewById(R.id.btnBack)
|
|
||||||
orientationViews["left_angle"] = findViewById(R.id.btnLeftAngle)
|
|
||||||
orientationViews["right_angle"] = findViewById(R.id.btnRightAngle)
|
|
||||||
|
|
||||||
findViewById<Button>(R.id.btnNewCow).setOnClickListener {
|
|
||||||
if (checkStoragePermissions()) {
|
|
||||||
saveProfile()
|
|
||||||
} else {
|
|
||||||
requestStoragePermissions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<Button>(R.id.btnCancel).setOnClickListener {
|
|
||||||
deleteSessionImages()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadInitialImages() {
|
|
||||||
val name = currentCowName ?: return
|
|
||||||
val cowFolder = StorageUtils.getCowImageFolder(name)
|
|
||||||
if (cowFolder.exists()) {
|
|
||||||
cowFolder.listFiles()?.forEach { file ->
|
|
||||||
if (file.isFile) {
|
|
||||||
initialImagePaths.add(file.absolutePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteSessionImages() {
|
|
||||||
val name = currentCowName ?: return
|
|
||||||
val cowFolder = StorageUtils.getCowImageFolder(name)
|
|
||||||
if (cowFolder.exists()) {
|
|
||||||
cowFolder.listFiles()?.forEach { file ->
|
|
||||||
if (file.isFile && !initialImagePaths.contains(file.absolutePath)) {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val remaining = cowFolder.listFiles()
|
|
||||||
if (remaining == null || remaining.isEmpty()) {
|
|
||||||
cowFolder.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupUIStrings() {
|
|
||||||
findViewById<TextView>(R.id.tvToolbarTitle).text = StringProvider.getString("title_cow_selection")
|
|
||||||
findViewById<TextView>(R.id.tvAddCowDetails).text = StringProvider.getString("title_add_cow_details")
|
|
||||||
|
|
||||||
// Using hint_ keys for the new labels as they contain the appropriate text (e.g. "Species", "Breed")
|
|
||||||
findViewById<TextView>(R.id.tvLabelSpecies).text = StringProvider.getString("hint_species")
|
|
||||||
findViewById<TextView>(R.id.tvLabelBreed).text = StringProvider.getString("hint_breed")
|
|
||||||
findViewById<TextView>(R.id.tvLabelAge).text = StringProvider.getString("hint_age")
|
|
||||||
findViewById<TextView>(R.id.tvLabelMilk).text = StringProvider.getString("hint_milk_yield")
|
|
||||||
findViewById<TextView>(R.id.tvLabelCalving).text = StringProvider.getString("hint_calving_number")
|
|
||||||
findViewById<TextView>(R.id.tvLabelDescription).text = StringProvider.getString("hint_description")
|
|
||||||
|
|
||||||
findViewById<TextInputLayout>(R.id.tilSpecies).hint = null
|
|
||||||
findViewById<TextInputLayout>(R.id.tilBreed).hint = null
|
|
||||||
findViewById<TextInputLayout>(R.id.tilAge).hint = null
|
|
||||||
findViewById<TextInputLayout>(R.id.tilMilk).hint = null
|
|
||||||
findViewById<TextInputLayout>(R.id.tilCalving).hint = null
|
|
||||||
findViewById<TextInputLayout>(R.id.tilDescription).hint = null
|
|
||||||
|
|
||||||
findViewById<TextView>(R.id.tvReproductiveStatus).text = StringProvider.getString("label_reproductive_status")
|
|
||||||
findViewById<RadioButton>(R.id.rbPregnant).text = StringProvider.getString("radio_pregnant")
|
|
||||||
findViewById<RadioButton>(R.id.rbCalved).text = StringProvider.getString("radio_calved")
|
|
||||||
findViewById<RadioButton>(R.id.rbNone).text = StringProvider.getString("radio_none")
|
|
||||||
|
|
||||||
findViewById<TextView>(R.id.tvUploadPhotos).text = StringProvider.getString("label_upload_photos")
|
|
||||||
|
|
||||||
findViewById<Button>(R.id.btnNewCow).text = StringProvider.getString("btn_save_profile")
|
|
||||||
findViewById<Button>(R.id.btnCancel).text = StringProvider.getString("btn_cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkStoragePermissions(): Boolean {
|
|
||||||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
|
||||||
android.os.Environment.isExternalStorageManager()
|
|
||||||
} else {
|
|
||||||
val write = ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
val read = ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
||||||
write == PackageManager.PERMISSION_GRANTED && read == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestStoragePermissions() {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
|
||||||
try {
|
|
||||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
|
||||||
val uri = android.net.Uri.fromParts("package", packageName, null)
|
|
||||||
intent.data = uri
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
this,
|
|
||||||
arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE),
|
|
||||||
storagePermissionCode
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveProfile() {
|
|
||||||
val speciesDisplay = findViewById<AutoCompleteTextView>(R.id.spinnerSpecies).text.toString()
|
|
||||||
val breedDisplay = findViewById<AutoCompleteTextView>(R.id.spinnerBreed).text.toString()
|
|
||||||
|
|
||||||
val speciesKey = StringProvider.getKeyForValue(speciesDisplay)
|
|
||||||
val species = if (speciesKey != null) StringProvider.getStringEnglish(speciesKey) else speciesDisplay
|
|
||||||
|
|
||||||
val breedKey = StringProvider.getKeyForValue(breedDisplay)
|
|
||||||
val breed = if (breedKey != null) StringProvider.getStringEnglish(breedKey) else breedDisplay
|
|
||||||
|
|
||||||
val ageInput = findViewById<TextInputEditText>(R.id.etAge).text.toString()
|
|
||||||
val milkInput = findViewById<TextInputLayout>(R.id.tilMilk).editText?.text.toString()
|
|
||||||
val calvingInput = findViewById<TextInputLayout>(R.id.tilCalving).editText?.text.toString()
|
|
||||||
val descriptionInput = findViewById<TextInputLayout>(R.id.tilDescription).editText?.text.toString()
|
|
||||||
|
|
||||||
val rgReproductive = findViewById<RadioGroup>(R.id.rgReproductiveStatus)
|
|
||||||
val reproductiveStatusKey = when(rgReproductive?.checkedRadioButtonId) {
|
|
||||||
R.id.rbPregnant -> "radio_pregnant"
|
|
||||||
R.id.rbCalved -> "radio_calved"
|
|
||||||
R.id.rbNone -> "radio_none"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val reproductiveStatus = if (reproductiveStatusKey != null) StringProvider.getStringEnglish(reproductiveStatusKey) else ""
|
|
||||||
|
|
||||||
val csvHeader = "CowID,Species,Breed,Age,MilkYield,CalvingNumber,ReproductiveStatus,Description\n"
|
|
||||||
val csvRow = "$currentCowName,$species,$breed,$ageInput,$milkInput,$calvingInput,$reproductiveStatus,$descriptionInput\n"
|
|
||||||
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val csvFile = File(docsFolder, "cow_profiles.csv")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val fileExists = csvFile.exists()
|
|
||||||
val lines = if (fileExists) csvFile.readLines().toMutableList() else mutableListOf()
|
|
||||||
|
|
||||||
if (!fileExists) {
|
|
||||||
lines.add(csvHeader.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
val existingIndex = lines.indexOfFirst { it.startsWith("$currentCowName,") }
|
|
||||||
|
|
||||||
if (existingIndex != -1) {
|
|
||||||
lines[existingIndex] = csvRow.trim()
|
|
||||||
} else {
|
|
||||||
lines.add(csvRow.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
FileWriter(csvFile).use { writer ->
|
|
||||||
lines.forEach { line ->
|
|
||||||
writer.write(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, StringProvider.getString("toast_profile_saved"), Toast.LENGTH_SHORT).show()
|
|
||||||
finish()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Toast.makeText(this, StringProvider.getString("toast_error_saving_profile") + " ${e.message}", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCowDetails(cowId: String) {
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val csvFile = File(docsFolder, "cow_profiles.csv")
|
|
||||||
if (!csvFile.exists()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val lines = csvFile.readLines()
|
|
||||||
val record = lines.find { it.startsWith("$cowId,") }?.split(",") ?: return
|
|
||||||
|
|
||||||
if (record.size >= 8) {
|
|
||||||
val storedSpecies = record[1]
|
|
||||||
val speciesKey = StringProvider.getKeyForEnglishValue(storedSpecies)
|
|
||||||
val displaySpecies = if (speciesKey != null) StringProvider.getString(speciesKey) else storedSpecies
|
|
||||||
findViewById<AutoCompleteTextView>(R.id.spinnerSpecies).setText(displaySpecies, false)
|
|
||||||
|
|
||||||
val storedBreed = record[2]
|
|
||||||
val breedKey = StringProvider.getKeyForEnglishValue(storedBreed)
|
|
||||||
val displayBreed = if (breedKey != null) StringProvider.getString(breedKey) else storedBreed
|
|
||||||
findViewById<AutoCompleteTextView>(R.id.spinnerBreed).setText(displayBreed, false)
|
|
||||||
|
|
||||||
findViewById<TextInputEditText>(R.id.etAge).setText(record[3])
|
|
||||||
findViewById<TextInputLayout>(R.id.tilMilk).editText?.setText(record[4])
|
|
||||||
findViewById<TextInputLayout>(R.id.tilCalving).editText?.setText(record[5])
|
|
||||||
|
|
||||||
val storedStatus = record[6]
|
|
||||||
val statusKey = StringProvider.getKeyForEnglishValue(storedStatus)
|
|
||||||
|
|
||||||
when(statusKey) {
|
|
||||||
"radio_pregnant" -> findViewById<RadioButton>(R.id.rbPregnant).isChecked = true
|
|
||||||
"radio_calved" -> findViewById<RadioButton>(R.id.rbCalved).isChecked = true
|
|
||||||
"radio_none" -> findViewById<RadioButton>(R.id.rbNone).isChecked = true
|
|
||||||
else -> {
|
|
||||||
when (storedStatus) {
|
|
||||||
"Pregnant" -> findViewById<RadioButton>(R.id.rbPregnant).isChecked = true
|
|
||||||
"Calved" -> findViewById<RadioButton>(R.id.rbCalved).isChecked = true
|
|
||||||
"None" -> findViewById<RadioButton>(R.id.rbNone).isChecked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<TextInputLayout>(R.id.tilDescription).editText?.setText(record[7])
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupDropdowns() {
|
|
||||||
val species = listOf(
|
|
||||||
StringProvider.getString("species_cow"),
|
|
||||||
StringProvider.getString("species_buffalo")
|
|
||||||
)
|
|
||||||
val speciesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, species)
|
|
||||||
findViewById<AutoCompleteTextView>(R.id.spinnerSpecies).setAdapter(speciesAdapter)
|
|
||||||
|
|
||||||
val breeds = listOf(
|
|
||||||
StringProvider.getString("breed_holstein"),
|
|
||||||
StringProvider.getString("breed_jersey"),
|
|
||||||
StringProvider.getString("breed_sahiwal"),
|
|
||||||
StringProvider.getString("breed_gir"),
|
|
||||||
StringProvider.getString("breed_red_sindhi"),
|
|
||||||
StringProvider.getString("breed_murrah"),
|
|
||||||
StringProvider.getString("breed_surti")
|
|
||||||
)
|
|
||||||
val breedAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, breeds)
|
|
||||||
findViewById<AutoCompleteTextView>(R.id.spinnerBreed).setAdapter(breedAdapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
refreshCowImages()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeDefaultMasks() {
|
|
||||||
val orientationResources = mapOf(
|
|
||||||
"left" to R.drawable.left,
|
|
||||||
"right" to R.drawable.right,
|
|
||||||
"angle" to R.drawable.angle,
|
|
||||||
"front" to R.drawable.front,
|
|
||||||
"back" to R.drawable.back,
|
|
||||||
"leftangle" to R.drawable.leftangle,
|
|
||||||
"rightangle" to R.drawable.rightangle
|
|
||||||
)
|
|
||||||
|
|
||||||
orientationResources.forEach { (orientation, resId) ->
|
|
||||||
val filename = "${orientation}_mask.png"
|
|
||||||
val file = File(filesDir, filename)
|
|
||||||
if (!file.exists()) {
|
|
||||||
try {
|
|
||||||
val original = BitmapFactory.decodeResource(resources, resId)
|
|
||||||
if (original != null) {
|
|
||||||
val inverted = createInverseBitmask(original)
|
|
||||||
FileOutputStream(file).use { out ->
|
|
||||||
inverted.compress(Bitmap.CompressFormat.PNG, 100, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createInverseBitmask(src: Bitmap): Bitmap {
|
|
||||||
val width = src.width
|
|
||||||
val height = src.height
|
|
||||||
val pixels = IntArray(width * height)
|
|
||||||
src.getPixels(pixels, 0, width, 0, 0, width, height)
|
|
||||||
|
|
||||||
for (i in pixels.indices) {
|
|
||||||
val alpha = (pixels[i] shr 24) and 0xFF
|
|
||||||
if (alpha > 0) {
|
|
||||||
pixels[i] = Color.TRANSPARENT
|
|
||||||
} else {
|
|
||||||
pixels[i] = Color.BLACK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateNewCowName() {
|
|
||||||
val sdf = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault())
|
|
||||||
currentCowName = "cow_${sdf.format(Date())}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshCowImages() {
|
|
||||||
val name = currentCowName ?: return
|
|
||||||
|
|
||||||
val orientations = mapOf(
|
|
||||||
"left" to Pair(R.drawable.left, R.id.btnLeft),
|
|
||||||
"right" to Pair(R.drawable.right, R.id.btnRight),
|
|
||||||
"angle" to Pair(R.drawable.angle, R.id.btnTop),
|
|
||||||
"front" to Pair(R.drawable.front, R.id.btnFront),
|
|
||||||
"back" to Pair(R.drawable.back, R.id.btnBack),
|
|
||||||
"leftangle" to Pair(R.drawable.leftangle, R.id.btnLeftAngle),
|
|
||||||
"rightangle" to Pair(R.drawable.rightangle, R.id.btnRightAngle)
|
|
||||||
)
|
|
||||||
|
|
||||||
val cowImagesFolder = StorageUtils.getCowImageFolder(name)
|
|
||||||
|
|
||||||
orientations.forEach { (orientation, pair) ->
|
|
||||||
val (drawableId, viewId) = pair
|
|
||||||
|
|
||||||
val files = if (cowImagesFolder.exists()) {
|
|
||||||
cowImagesFolder.listFiles { _, fname -> fname.startsWith("${name}_${orientation}_") && fname.endsWith(".jpg") }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val latestFile = files?.maxByOrNull { it.lastModified() }
|
|
||||||
|
|
||||||
val container = findViewById<LinearLayout>(viewId)
|
|
||||||
container.removeAllViews()
|
|
||||||
|
|
||||||
val key = when(orientation) {
|
|
||||||
"front" -> "text_front_view"
|
|
||||||
"back" -> "text_rear_view"
|
|
||||||
"left" -> "text_left_side"
|
|
||||||
"right" -> "text_right_side"
|
|
||||||
"angle" -> "text_angle_view"
|
|
||||||
"leftangle" -> "text_left_angle"
|
|
||||||
"rightangle" -> "text_right_angle"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
val label = if (key.isNotEmpty()) StringProvider.getString(key) else orientation
|
|
||||||
|
|
||||||
if (latestFile != null && latestFile.exists()) {
|
|
||||||
val frameLayout = android.widget.FrameLayout(this)
|
|
||||||
frameLayout.layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
|
|
||||||
val imageView = ImageView(this)
|
|
||||||
imageView.layoutParams = android.widget.FrameLayout.LayoutParams(
|
|
||||||
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
|
||||||
val bitmap = BitmapFactory.decodeFile(latestFile.absolutePath)
|
|
||||||
imageView.setImageBitmap(bitmap)
|
|
||||||
|
|
||||||
val deleteBtn = ImageView(this)
|
|
||||||
val btnSize = (24 * resources.displayMetrics.density).toInt()
|
|
||||||
val btnParams = android.widget.FrameLayout.LayoutParams(btnSize, btnSize)
|
|
||||||
btnParams.gravity = android.view.Gravity.TOP or android.view.Gravity.END
|
|
||||||
val margin = (4 * resources.displayMetrics.density).toInt()
|
|
||||||
btnParams.setMargins(margin, margin, margin, margin)
|
|
||||||
deleteBtn.layoutParams = btnParams
|
|
||||||
deleteBtn.setImageResource(android.R.drawable.ic_menu_close_clear_cancel)
|
|
||||||
deleteBtn.setColorFilter(Color.RED)
|
|
||||||
deleteBtn.setBackgroundColor(Color.parseColor("#80FFFFFF"))
|
|
||||||
deleteBtn.isClickable = true
|
|
||||||
deleteBtn.setOnClickListener {
|
|
||||||
if (latestFile.delete()) {
|
|
||||||
val remainingFiles = cowImagesFolder.listFiles()
|
|
||||||
if (remainingFiles == null || remainingFiles.isEmpty()) {
|
|
||||||
cowImagesFolder.delete()
|
|
||||||
}
|
|
||||||
refreshCowImages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val labelView = TextView(this)
|
|
||||||
labelView.text = label
|
|
||||||
labelView.textSize = 12f
|
|
||||||
labelView.setTextColor(Color.WHITE)
|
|
||||||
labelView.setShadowLayer(3f, 0f, 0f, Color.BLACK)
|
|
||||||
val labelParams = android.widget.FrameLayout.LayoutParams(
|
|
||||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
labelParams.gravity = android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL
|
|
||||||
labelParams.bottomMargin = (4 * resources.displayMetrics.density).toInt()
|
|
||||||
labelView.layoutParams = labelParams
|
|
||||||
|
|
||||||
frameLayout.addView(imageView)
|
|
||||||
frameLayout.addView(labelView)
|
|
||||||
frameLayout.addView(deleteBtn)
|
|
||||||
|
|
||||||
container.addView(frameLayout)
|
|
||||||
|
|
||||||
imageView.setOnClickListener {
|
|
||||||
val intent = Intent(this, FullScreenImageActivity::class.java)
|
|
||||||
intent.putExtra("IMAGE_PATH", latestFile.absolutePath)
|
|
||||||
intent.putExtra("ALLOW_RETAKE", true)
|
|
||||||
intent.putExtra("COW_NAME", currentCowName)
|
|
||||||
intent.putExtra("ORIENTATION", orientation)
|
|
||||||
intent.putExtra("SILHOUETTE_ID", drawableId)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
container.setOnClickListener(null)
|
|
||||||
container.isClickable = false
|
|
||||||
|
|
||||||
} else {
|
|
||||||
val iconView = ImageView(this)
|
|
||||||
val params = LinearLayout.LayoutParams(
|
|
||||||
(24 * resources.displayMetrics.density).toInt(),
|
|
||||||
(24 * resources.displayMetrics.density).toInt()
|
|
||||||
)
|
|
||||||
params.gravity = android.view.Gravity.CENTER_HORIZONTAL
|
|
||||||
iconView.layoutParams = params
|
|
||||||
iconView.setImageResource(android.R.drawable.ic_menu_camera)
|
|
||||||
iconView.setColorFilter(Color.parseColor("#5D4037"))
|
|
||||||
|
|
||||||
val textView = TextView(this)
|
|
||||||
val textParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
textParams.gravity = android.view.Gravity.CENTER_HORIZONTAL
|
|
||||||
textParams.topMargin = (4 * resources.displayMetrics.density).toInt()
|
|
||||||
textView.layoutParams = textParams
|
|
||||||
|
|
||||||
textView.text = label
|
|
||||||
textView.textSize = 12f
|
|
||||||
textView.setTextColor(Color.parseColor("#5D4037"))
|
|
||||||
|
|
||||||
container.addView(iconView)
|
|
||||||
container.addView(textView)
|
|
||||||
|
|
||||||
container.setOnClickListener {
|
|
||||||
val intent = Intent(this, CameraProcessor::class.java)
|
|
||||||
intent.putExtra("SILHOUETTE_ID", drawableId)
|
|
||||||
intent.putExtra("COW_NAME", currentCowName)
|
|
||||||
intent.putExtra("ORIENTATION", orientation)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
container.isClickable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
outState.putString("COW_NAME", currentCowName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.camera.core.ExperimentalGetImage
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
import com.google.android.gms.tasks.Task
|
|
||||||
import com.google.android.gms.tasks.TaskCompletionSource
|
|
||||||
import com.google.mlkit.vision.common.InputImage
|
|
||||||
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
|
|
||||||
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
data class SegmentationResult(
|
|
||||||
val mask: Bitmap?,
|
|
||||||
val isMatch: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
class FrameProcessor {
|
|
||||||
|
|
||||||
private val isProcessing = AtomicBoolean(false)
|
|
||||||
private val processingExecutor = Executors.newSingleThreadExecutor()
|
|
||||||
|
|
||||||
private val options = SubjectSegmenterOptions.Builder()
|
|
||||||
.enableMultipleSubjects(
|
|
||||||
SubjectSegmenterOptions.SubjectResultOptions.Builder()
|
|
||||||
.enableConfidenceMask()
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
private val segmenter = SubjectSegmentation.getClient(options)
|
|
||||||
|
|
||||||
@ExperimentalGetImage
|
|
||||||
fun processFrame(
|
|
||||||
imageProxy: ImageProxy,
|
|
||||||
savedMask: Bitmap?,
|
|
||||||
isPortrait: Boolean,
|
|
||||||
thresholdPercent: Int = 75,
|
|
||||||
algorithm: String = HomeActivity.ALGORITHM_HAMMING
|
|
||||||
): Task<SegmentationResult> {
|
|
||||||
val taskCompletionSource = TaskCompletionSource<SegmentationResult>()
|
|
||||||
|
|
||||||
if (!isProcessing.compareAndSet(false, true)) {
|
|
||||||
imageProxy.close()
|
|
||||||
taskCompletionSource.setResult(SegmentationResult(null, false))
|
|
||||||
return taskCompletionSource.task
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaImage = imageProxy.image
|
|
||||||
if (mediaImage == null) {
|
|
||||||
isProcessing.set(false)
|
|
||||||
imageProxy.close()
|
|
||||||
taskCompletionSource.setResult(SegmentationResult(null, false))
|
|
||||||
return taskCompletionSource.task
|
|
||||||
}
|
|
||||||
|
|
||||||
val inputImage = InputImage.fromMediaImage(mediaImage, 0)
|
|
||||||
|
|
||||||
segmenter.process(inputImage)
|
|
||||||
.addOnSuccessListener(processingExecutor) { result ->
|
|
||||||
var bitmapMask: Bitmap? = null
|
|
||||||
val subject = result.subjects.firstOrNull()
|
|
||||||
val mask = subject?.confidenceMask
|
|
||||||
|
|
||||||
if (mask != null) {
|
|
||||||
val startX = subject.startX
|
|
||||||
val startY = subject.startY
|
|
||||||
val maskWidth = subject.width
|
|
||||||
val maskHeight = subject.height
|
|
||||||
|
|
||||||
val fullWidth = inputImage.width
|
|
||||||
val fullHeight = inputImage.height
|
|
||||||
|
|
||||||
if (mask.remaining() >= maskWidth * maskHeight) {
|
|
||||||
val colors = IntArray(fullWidth * fullHeight)
|
|
||||||
mask.rewind()
|
|
||||||
|
|
||||||
for (y in 0 until maskHeight) {
|
|
||||||
for (x in 0 until maskWidth) {
|
|
||||||
if (mask.get() > 0.5f) {
|
|
||||||
val destX = startX + x
|
|
||||||
val destY = startY + y
|
|
||||||
if (destX < fullWidth && destY < fullHeight) {
|
|
||||||
colors[destY * fullWidth + destX] = Color.argb(180, 255, 0, 255)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val rawBitmap = Bitmap.createBitmap(colors, fullWidth, fullHeight, Bitmap.Config.ARGB_8888)
|
|
||||||
|
|
||||||
// Rotate and Scale if needed
|
|
||||||
bitmapMask = if (isPortrait) {
|
|
||||||
try {
|
|
||||||
val matrix = Matrix()
|
|
||||||
// Rotate 90 degrees
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
|
|
||||||
// Assuming previous scaling logic might still be relevant if masks are consistently off
|
|
||||||
// But generally, we trust the geometry.
|
|
||||||
// If scaling up is happening unexpectedly, one could use:
|
|
||||||
// matrix.postScale(0.9f, 0.9f)
|
|
||||||
// For now, stick to rotation unless requested otherwise.
|
|
||||||
|
|
||||||
Bitmap.createBitmap(rawBitmap, 0, 0, rawBitmap.width, rawBitmap.height, matrix, true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("FrameProcessor", "Error rotating mask", e)
|
|
||||||
rawBitmap
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rawBitmap
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.e("FrameProcessor", "Mask buffer size mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate match
|
|
||||||
var isMatch = false
|
|
||||||
if (bitmapMask != null && savedMask != null) {
|
|
||||||
// Scale current mask to match saved mask dimensions (640x480) for comparison
|
|
||||||
val comparisonMask = if (bitmapMask.width != savedMask.width || bitmapMask.height != savedMask.height) {
|
|
||||||
try {
|
|
||||||
Bitmap.createScaledBitmap(bitmapMask, savedMask.width, savedMask.height, true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("FrameProcessor", "Error scaling mask for comparison", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bitmapMask
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comparisonMask != null) {
|
|
||||||
// Map display strings to internal algorithm keys/logic
|
|
||||||
val algoKey = when(algorithm) {
|
|
||||||
StringProvider.getString("algo_euclidean") -> HomeActivity.ALGORITHM_EUCLIDEAN
|
|
||||||
StringProvider.getString("algo_jaccard") -> HomeActivity.ALGORITHM_JACCARD
|
|
||||||
else -> {
|
|
||||||
if (algorithm == HomeActivity.ALGORITHM_EUCLIDEAN || algorithm == HomeActivity.ALGORITHM_JACCARD) algorithm
|
|
||||||
else HomeActivity.ALGORITHM_HAMMING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isMatch = when (algoKey) {
|
|
||||||
HomeActivity.ALGORITHM_EUCLIDEAN -> calculateEuclideanDistance(savedMask, comparisonMask, thresholdPercent)
|
|
||||||
HomeActivity.ALGORITHM_JACCARD -> calculateJaccardSimilarity(savedMask, comparisonMask, thresholdPercent)
|
|
||||||
else -> calculateHammingDistance(savedMask, comparisonMask, thresholdPercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
taskCompletionSource.setResult(SegmentationResult(bitmapMask, isMatch))
|
|
||||||
}
|
|
||||||
.addOnFailureListener { e ->
|
|
||||||
Log.e("FrameProcessor", "Subject Segmentation failed", e)
|
|
||||||
taskCompletionSource.setException(e)
|
|
||||||
}
|
|
||||||
.addOnCompleteListener { _ ->
|
|
||||||
isProcessing.set(false)
|
|
||||||
imageProxy.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return taskCompletionSource.task
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateHammingDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
|
|
||||||
if (mask1.width != mask2.width || mask1.height != mask2.height) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val width = mask1.width
|
|
||||||
val height = mask1.height
|
|
||||||
val pixels1 = IntArray(width * height)
|
|
||||||
val pixels2 = IntArray(width * height)
|
|
||||||
|
|
||||||
mask1.getPixels(pixels1, 0, width, 0, 0, width, height)
|
|
||||||
mask2.getPixels(pixels2, 0, width, 0, 0, width, height)
|
|
||||||
|
|
||||||
var distance = 0
|
|
||||||
for (i in pixels1.indices) {
|
|
||||||
val isSet1 = (pixels1[i] ushr 24) > 0
|
|
||||||
val isSet2 = (pixels2[i] ushr 24) > 0
|
|
||||||
|
|
||||||
if (isSet1 != isSet2) {
|
|
||||||
distance++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalPixels = width * height
|
|
||||||
val validThreshold = thresholdPercent.coerceIn(1, 100)
|
|
||||||
val allowedDistance = (totalPixels.toLong() * (100 - validThreshold)) / 100
|
|
||||||
|
|
||||||
return distance <= allowedDistance
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateEuclideanDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
|
|
||||||
if (mask1.width != mask2.width || mask1.height != mask2.height) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val width = mask1.width
|
|
||||||
val height = mask1.height
|
|
||||||
val pixels1 = IntArray(width * height)
|
|
||||||
val pixels2 = IntArray(width * height)
|
|
||||||
|
|
||||||
mask1.getPixels(pixels1, 0, width, 0, 0, width, height)
|
|
||||||
mask2.getPixels(pixels2, 0, width, 0, 0, width, height)
|
|
||||||
|
|
||||||
var sumSq = 0L
|
|
||||||
for (i in pixels1.indices) {
|
|
||||||
// Simple binary comparison for Euclidean distance on masks
|
|
||||||
// Treat existence of pixel as 255, non-existence as 0
|
|
||||||
val val1 = if ((pixels1[i] ushr 24) > 0) 255 else 0
|
|
||||||
val val2 = if ((pixels2[i] ushr 24) > 0) 255 else 0
|
|
||||||
|
|
||||||
val diff = val1 - val2
|
|
||||||
sumSq += diff * diff
|
|
||||||
}
|
|
||||||
|
|
||||||
val euclideanDistance = sqrt(sumSq.toDouble())
|
|
||||||
val maxDistance = sqrt((width * height).toDouble()) * 255.0
|
|
||||||
|
|
||||||
val validThreshold = thresholdPercent.coerceIn(1, 100)
|
|
||||||
val allowedDistance = maxDistance * (100 - validThreshold) / 100.0
|
|
||||||
|
|
||||||
return euclideanDistance <= allowedDistance
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateJaccardSimilarity(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
|
|
||||||
if (mask1.width != mask2.width || mask1.height != mask2.height) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val width = mask1.width
|
|
||||||
val height = mask1.height
|
|
||||||
val pixels1 = IntArray(width * height)
|
|
||||||
val pixels2 = IntArray(width * height)
|
|
||||||
|
|
||||||
mask1.getPixels(pixels1, 0, width, 0, 0, width, height)
|
|
||||||
mask2.getPixels(pixels2, 0, width, 0, 0, width, height)
|
|
||||||
|
|
||||||
var intersection = 0
|
|
||||||
var union = 0
|
|
||||||
|
|
||||||
for (i in pixels1.indices) {
|
|
||||||
// mask1 is Saved Mask (Likely Inverted in StorageUtils: Subject is Transparent/0, Background is Black/255)
|
|
||||||
// But check how it's loaded. In CameraProcessor: loadSavedMask() -> decodeFile().
|
|
||||||
// If we assume saved mask is inverted (subject transparent), then alpha < 128 is subject.
|
|
||||||
val alpha1 = (pixels1[i] ushr 24) and 0xFF
|
|
||||||
val isSubject1 = alpha1 < 128
|
|
||||||
|
|
||||||
// mask2 is Live Mask (Subject is Magenta/180 alpha)
|
|
||||||
val alpha2 = (pixels2[i] ushr 24) and 0xFF
|
|
||||||
val isSubject2 = alpha2 > 0
|
|
||||||
|
|
||||||
if (isSubject1 && isSubject2) {
|
|
||||||
intersection++
|
|
||||||
}
|
|
||||||
if (isSubject1 || isSubject2) {
|
|
||||||
union++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (union == 0) return false
|
|
||||||
|
|
||||||
val jaccardIndex = (intersection.toDouble() / union.toDouble()) * 100
|
|
||||||
return jaccardIndex >= thresholdPercent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class FullScreenImageActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_full_screen_image)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
|
|
||||||
val imagePath = intent.getStringExtra("IMAGE_PATH")
|
|
||||||
val allowRetake = intent.getBooleanExtra("ALLOW_RETAKE", false)
|
|
||||||
val cowName = intent.getStringExtra("COW_NAME")
|
|
||||||
val orientation = intent.getStringExtra("ORIENTATION")
|
|
||||||
val silhouetteId = intent.getIntExtra("SILHOUETTE_ID", 0)
|
|
||||||
|
|
||||||
if (imagePath != null) {
|
|
||||||
val file = File(imagePath)
|
|
||||||
if (file.exists()) {
|
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
||||||
findViewById<ImageView>(R.id.fullScreenImageView).setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val btnRetake = findViewById<Button>(R.id.btnRetake)
|
|
||||||
if (allowRetake && imagePath != null) {
|
|
||||||
btnRetake.visibility = View.VISIBLE
|
|
||||||
btnRetake.text = "Retake Photo"
|
|
||||||
|
|
||||||
btnRetake.setOnClickListener {
|
|
||||||
// Launch camera to retake. Pass the current image path so CameraProcessor can overwrite/delete it on success.
|
|
||||||
val intent = Intent(this, CameraProcessor::class.java)
|
|
||||||
intent.putExtra("SILHOUETTE_ID", silhouetteId)
|
|
||||||
intent.putExtra("COW_NAME", cowName)
|
|
||||||
intent.putExtra("ORIENTATION", orientation)
|
|
||||||
intent.putExtra("RETAKE_IMAGE_PATH", imagePath) // Pass image path to replace
|
|
||||||
startActivity(intent)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
btnRetake.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
val btnBack = findViewById<ImageButton>(R.id.btnBack)
|
|
||||||
btnBack.contentDescription = StringProvider.getString("content_desc_back")
|
|
||||||
btnBack.setOnClickListener {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<ImageView>(R.id.fullScreenImageView).contentDescription = StringProvider.getString("content_desc_full_screen_image")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileWriter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class GalleryActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var container: LinearLayout
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_gallery)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
|
|
||||||
val toolbar = findViewById<androidx.appcompat.widget.Toolbar>(R.id.toolbar)
|
|
||||||
setSupportActionBar(toolbar)
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
supportActionBar?.setDisplayShowHomeEnabled(true)
|
|
||||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
|
||||||
|
|
||||||
findViewById<TextView>(R.id.tvToolbarTitle)?.text = StringProvider.getString("title_gallery")
|
|
||||||
|
|
||||||
toolbar.setNavigationOnClickListener {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
container = findViewById(R.id.galleryContainer)
|
|
||||||
|
|
||||||
findViewById<FloatingActionButton>(R.id.fabAddCow).setOnClickListener {
|
|
||||||
val intent = Intent(this, CowSelectionActivity::class.java)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshGallery()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshGallery() {
|
|
||||||
container.removeAllViews()
|
|
||||||
|
|
||||||
val imagesBaseFolder = StorageUtils.getImagesBaseFolder()
|
|
||||||
val cowFolders = imagesBaseFolder.listFiles { file -> file.isDirectory } ?: emptyArray()
|
|
||||||
|
|
||||||
val cowNamesFromFolders = cowFolders.map { it.name }
|
|
||||||
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val csvFile = File(docsFolder, "cow_profiles.csv")
|
|
||||||
|
|
||||||
val cowDetails = if (csvFile.exists()) {
|
|
||||||
csvFile.readLines().associate { line ->
|
|
||||||
val parts = line.split(",")
|
|
||||||
if (parts.isNotEmpty()) parts[0] to parts else "" to emptyList()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
val allCowNames = (cowDetails.keys + cowNamesFromFolders).filter { it.isNotEmpty() && it != "CowID" }.distinct()
|
|
||||||
|
|
||||||
allCowNames.forEach { cowName ->
|
|
||||||
val details = cowDetails[cowName] ?: emptyList()
|
|
||||||
val cowImageFolder = StorageUtils.getCowImageFolder(cowName)
|
|
||||||
val cowFiles = cowImageFolder.listFiles { _, name -> name.endsWith(".jpg") }?.toList() ?: emptyList()
|
|
||||||
|
|
||||||
addCowSection(cowName, cowFiles, details)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addCowSection(cowName: String, cowFiles: List<File>, details: List<String>) {
|
|
||||||
// Main Card
|
|
||||||
val card = CardView(this).apply {
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
setMargins(0, 0, 0, 24)
|
|
||||||
}
|
|
||||||
radius = 16 * resources.displayMetrics.density
|
|
||||||
cardElevation = 2 * resources.displayMetrics.density
|
|
||||||
setCardBackgroundColor(android.graphics.Color.WHITE)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal Container (3:1 split)
|
|
||||||
val horizontalContainer = LinearLayout(this).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
weightSum = 4f
|
|
||||||
setPadding(24, 24, 24, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left Layout (Info) - Weight 3
|
|
||||||
val leftLayout = LinearLayout(this).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
3f
|
|
||||||
).apply {
|
|
||||||
marginEnd = 16
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nameView = TextView(this).apply {
|
|
||||||
text = if (details.isNotEmpty()) "${StringProvider.getString("text_cow_id")} $cowName" else cowName
|
|
||||||
textSize = 20f
|
|
||||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
|
||||||
setTextColor(android.graphics.Color.parseColor("#3E2723"))
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
bottomMargin = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
leftLayout.addView(nameView)
|
|
||||||
|
|
||||||
if (details.size >= 7) {
|
|
||||||
// Translate values for display
|
|
||||||
val storedSpecies = details.getOrElse(1) { "-" }
|
|
||||||
val speciesKey = StringProvider.getKeyForEnglishValue(storedSpecies)
|
|
||||||
val displaySpecies = if (speciesKey != null) StringProvider.getString(speciesKey) else storedSpecies
|
|
||||||
|
|
||||||
val storedBreed = details.getOrElse(2) { "-" }
|
|
||||||
val breedKey = StringProvider.getKeyForEnglishValue(storedBreed)
|
|
||||||
val displayBreed = if (breedKey != null) StringProvider.getString(breedKey) else storedBreed
|
|
||||||
|
|
||||||
val storedStatus = details.getOrElse(6) { "-" }
|
|
||||||
val statusKey = StringProvider.getKeyForEnglishValue(storedStatus)
|
|
||||||
val displayStatus = if (statusKey != null) StringProvider.getString(statusKey) else storedStatus
|
|
||||||
|
|
||||||
val infoText = StringBuilder()
|
|
||||||
infoText.append("${StringProvider.getString("label_species")} $displaySpecies ")
|
|
||||||
infoText.append("${StringProvider.getString("label_breed")} $displayBreed\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_age")} ${details.getOrElse(3) { "-" }} ${StringProvider.getString("unit_years")} ")
|
|
||||||
infoText.append("${StringProvider.getString("label_milk_yield")} ${details.getOrElse(4) { "-" }} ${StringProvider.getString("unit_liters")}\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_calving_no")} ${details.getOrElse(5) { "-" }} ")
|
|
||||||
infoText.append("${StringProvider.getString("label_status")} $displayStatus")
|
|
||||||
|
|
||||||
val detailsView = TextView(this).apply {
|
|
||||||
text = infoText.toString()
|
|
||||||
textSize = 14f
|
|
||||||
setTextColor(android.graphics.Color.parseColor("#5D4037"))
|
|
||||||
setLineSpacing(10f, 1f)
|
|
||||||
}
|
|
||||||
leftLayout.addView(detailsView)
|
|
||||||
} else {
|
|
||||||
val detailsView = TextView(this).apply {
|
|
||||||
text = "No details available"
|
|
||||||
textSize = 14f
|
|
||||||
setTextColor(android.graphics.Color.parseColor("#5D4037"))
|
|
||||||
}
|
|
||||||
leftLayout.addView(detailsView)
|
|
||||||
}
|
|
||||||
|
|
||||||
horizontalContainer.addView(leftLayout)
|
|
||||||
|
|
||||||
// Right Layout (Buttons) - Weight 1
|
|
||||||
val rightLayout = LinearLayout(this).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
(48 * resources.displayMetrics.density).toInt()
|
|
||||||
).apply {
|
|
||||||
bottomMargin = (8 * resources.displayMetrics.density).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
val editButton = MaterialButton(this).apply {
|
|
||||||
text = StringProvider.getString("btn_edit")
|
|
||||||
textSize = 12f
|
|
||||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
|
||||||
setTextColor(android.graphics.Color.parseColor("#5D4037"))
|
|
||||||
cornerRadius = (12 * resources.displayMetrics.density).toInt()
|
|
||||||
backgroundTintList = ColorStateList.valueOf(android.graphics.Color.parseColor("#EFEBE9"))
|
|
||||||
strokeWidth = (1 * resources.displayMetrics.density).toInt()
|
|
||||||
strokeColor = ColorStateList.valueOf(android.graphics.Color.parseColor("#5D4037"))
|
|
||||||
layoutParams = buttonParams
|
|
||||||
insetTop = 0
|
|
||||||
insetBottom = 0
|
|
||||||
minHeight = 0
|
|
||||||
minimumHeight = 0
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
val intent = Intent(this@GalleryActivity, CowSelectionActivity::class.java)
|
|
||||||
intent.putExtra("COW_NAME", cowName)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val rateButton = MaterialButton(this).apply {
|
|
||||||
text = "Rate" // Add string if needed, skipping as per instruction to be concise unless requested
|
|
||||||
textSize = 12f
|
|
||||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
|
||||||
setTextColor(android.graphics.Color.WHITE)
|
|
||||||
cornerRadius = (12 * resources.displayMetrics.density).toInt()
|
|
||||||
backgroundTintList = ColorStateList.valueOf(android.graphics.Color.parseColor("#6D4C41"))
|
|
||||||
layoutParams = buttonParams
|
|
||||||
insetTop = 0
|
|
||||||
insetBottom = 0
|
|
||||||
minHeight = 0
|
|
||||||
minimumHeight = 0
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
val intent = Intent(this@GalleryActivity, RatingActivity::class.java)
|
|
||||||
intent.putExtra("COW_NAME", cowName)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val deleteButton = MaterialButton(this).apply {
|
|
||||||
text = "Delete"
|
|
||||||
textSize = 12f
|
|
||||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
|
||||||
setTextColor(android.graphics.Color.WHITE)
|
|
||||||
cornerRadius = (12 * resources.displayMetrics.density).toInt()
|
|
||||||
backgroundTintList = ColorStateList.valueOf(android.graphics.Color.RED)
|
|
||||||
layoutParams = buttonParams
|
|
||||||
insetTop = 0
|
|
||||||
insetBottom = 0
|
|
||||||
minHeight = 0
|
|
||||||
minimumHeight = 0
|
|
||||||
|
|
||||||
setOnClickListener {
|
|
||||||
deleteCow(cowName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rightLayout.addView(editButton)
|
|
||||||
rightLayout.addView(rateButton)
|
|
||||||
rightLayout.addView(deleteButton)
|
|
||||||
horizontalContainer.addView(rightLayout)
|
|
||||||
card.addView(horizontalContainer)
|
|
||||||
|
|
||||||
container.addView(card)
|
|
||||||
|
|
||||||
// Orientation Images
|
|
||||||
if (cowFiles.isNotEmpty()) {
|
|
||||||
// Instead of separating by orientation, put all images in a grid
|
|
||||||
val gridLayout = android.widget.GridLayout(this).apply {
|
|
||||||
columnCount = 3
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
setMargins(16, 0, 16, 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort images or keep them in order. The user asked for 3 in each row.
|
|
||||||
cowFiles.forEach { file ->
|
|
||||||
val thumbnailView = layoutInflater.inflate(R.layout.item_image_thumbnail, gridLayout, false)
|
|
||||||
|
|
||||||
val imageView = thumbnailView.findViewById<ImageView>(R.id.ivThumbnail)
|
|
||||||
val labelView = thumbnailView.findViewById<TextView>(R.id.tvOrientationLabel)
|
|
||||||
val deleteButtonSmall = thumbnailView.findViewById<android.view.View>(R.id.btnDelete)
|
|
||||||
|
|
||||||
// Optionally set layout params for thumbnailView to ensure 3 per row
|
|
||||||
val displayMetrics = resources.displayMetrics
|
|
||||||
val screenWidth = displayMetrics.widthPixels
|
|
||||||
|
|
||||||
val itemWidth = (screenWidth - (48 * displayMetrics.density).toInt()) / 3
|
|
||||||
thumbnailView.layoutParams = android.widget.GridLayout.LayoutParams().apply {
|
|
||||||
width = itemWidth
|
|
||||||
height = itemWidth // Square
|
|
||||||
setMargins(4, 4, 4, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract orientation from filename
|
|
||||||
val parts = file.name.split("_")
|
|
||||||
var orientation = ""
|
|
||||||
if (parts.size >= 3) {
|
|
||||||
// format: cow_..._orientation_timestamp.jpg
|
|
||||||
// or cow_timestamp_orientation_...
|
|
||||||
// Let's guess. In CowSelectionActivity: ${name}_${orientation}_...
|
|
||||||
// name = cow_...
|
|
||||||
// so filename starts with name
|
|
||||||
|
|
||||||
// Let's just try to find a known orientation in the filename
|
|
||||||
val orientations = listOf("left", "right", "angle", "front", "back", "leftangle", "rightangle")
|
|
||||||
orientation = orientations.find { file.name.contains("_${it}_") } ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val key = when(orientation) {
|
|
||||||
"front" -> "text_front_view"
|
|
||||||
"back" -> "text_rear_view"
|
|
||||||
"left" -> "text_left_side"
|
|
||||||
"right" -> "text_right_side"
|
|
||||||
"angle" -> "text_angle_view"
|
|
||||||
"leftangle" -> "text_left_angle"
|
|
||||||
"rightangle" -> "text_right_angle"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
val label = if (key.isNotEmpty()) StringProvider.getString(key) else orientation
|
|
||||||
|
|
||||||
labelView.text = label
|
|
||||||
|
|
||||||
imageView.setImageBitmap(BitmapFactory.decodeFile(file.absolutePath))
|
|
||||||
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
|
||||||
|
|
||||||
imageView.setOnClickListener {
|
|
||||||
val intent = Intent(this@GalleryActivity, FullScreenImageActivity::class.java)
|
|
||||||
intent.putExtra("IMAGE_PATH", file.absolutePath)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteButtonSmall.setOnClickListener {
|
|
||||||
if (file.delete()) {
|
|
||||||
val parentDir = file.parentFile
|
|
||||||
if (parentDir != null && parentDir.exists()) {
|
|
||||||
val remaining = parentDir.listFiles()
|
|
||||||
if (remaining == null || remaining.isEmpty()) {
|
|
||||||
parentDir.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toast.makeText(this@GalleryActivity, StringProvider.getString("toast_image_deleted"), Toast.LENGTH_SHORT).show()
|
|
||||||
refreshGallery()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(this@GalleryActivity, StringProvider.getString("toast_error_deleting_image"), Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gridLayout.addView(thumbnailView)
|
|
||||||
}
|
|
||||||
container.addView(gridLayout)
|
|
||||||
|
|
||||||
val separator = android.view.View(this).apply {
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
2
|
|
||||||
).apply {
|
|
||||||
setMargins(0, 16, 0, 16)
|
|
||||||
}
|
|
||||||
setBackgroundColor(android.graphics.Color.LTGRAY)
|
|
||||||
}
|
|
||||||
container.addView(separator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteCow(cowName: String) {
|
|
||||||
// Delete Images
|
|
||||||
val imageFolder = StorageUtils.getCowImageFolder(cowName)
|
|
||||||
if (imageFolder.exists()) {
|
|
||||||
imageFolder.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
|
|
||||||
// Delete Profile
|
|
||||||
val profileFile = File(docsFolder, "cow_profiles.csv")
|
|
||||||
if (profileFile.exists()) {
|
|
||||||
val lines = profileFile.readLines()
|
|
||||||
val newLines = lines.filter { !it.startsWith("$cowName,") }
|
|
||||||
FileWriter(profileFile).use { writer ->
|
|
||||||
newLines.forEach { writer.write(it + "\n") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete Ratings
|
|
||||||
val ratingsFile = File(docsFolder, "cow_ratings.csv")
|
|
||||||
if (ratingsFile.exists()) {
|
|
||||||
val lines = ratingsFile.readLines()
|
|
||||||
val newLines = lines.filter { !it.startsWith("$cowName,") }
|
|
||||||
FileWriter(ratingsFile).use { writer ->
|
|
||||||
newLines.forEach { writer.write(it + "\n") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, "Cow profile deleted", Toast.LENGTH_SHORT).show()
|
|
||||||
refreshGallery()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
refreshGallery()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.AdapterView
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.PopupMenu
|
|
||||||
import android.widget.SeekBar
|
|
||||||
import android.widget.Spinner
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.GravityCompat
|
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
|
||||||
|
|
||||||
class HomeActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ALGORITHM_HAMMING = "Hamming Distance"
|
|
||||||
const val ALGORITHM_EUCLIDEAN = "Euclidean Distance"
|
|
||||||
const val ALGORITHM_JACCARD = "Jaccard Similarity"
|
|
||||||
private const val PERMISSION_REQUEST_CODE = 101
|
|
||||||
private const val PREF_COW_ILLUSTRATION_INDEX = "COW_ILLUSTRATION_INDEX"
|
|
||||||
const val PREF_AUTO_CAPTURE = "AUTO_CAPTURE_ENABLED"
|
|
||||||
const val PREF_DEBUG_ENABLE = "AUTO_DEBUG_ENABLED"
|
|
||||||
const val PREF_MASK_DISPLAY = "MASK_DISPLAY_ENABLED"
|
|
||||||
var IS_RELEASE_BUILD = true // Set to true for release
|
|
||||||
}
|
|
||||||
|
|
||||||
private val internalAlgorithms = listOf(ALGORITHM_HAMMING, ALGORITHM_EUCLIDEAN, ALGORITHM_JACCARD)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_home)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
|
|
||||||
setupUI()
|
|
||||||
checkAndRequestPermissions()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkAndRequestPermissions() {
|
|
||||||
val permissions = mutableListOf<String>()
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
permissions.add(android.Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
// Android 11+: Request All Files Access for managing external storage
|
|
||||||
if (!android.os.Environment.isExternalStorageManager()) {
|
|
||||||
try {
|
|
||||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
|
||||||
val uri = android.net.Uri.fromParts("package", packageName, null)
|
|
||||||
intent.data = uri
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Android 10 and below
|
|
||||||
if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
permissions.add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
}
|
|
||||||
if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
permissions.add(android.Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permissions.isNotEmpty()) {
|
|
||||||
ActivityCompat.requestPermissions(this, permissions.toTypedArray(), PERMISSION_REQUEST_CODE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupUI() {
|
|
||||||
val prefs = getSharedPreferences("AnimalRatingPrefs", MODE_PRIVATE)
|
|
||||||
|
|
||||||
// Menu Button Logic
|
|
||||||
val drawerLayout = findViewById<DrawerLayout>(R.id.drawer_layout)
|
|
||||||
val btnMenu = findViewById<ImageButton>(R.id.btnMenu)
|
|
||||||
btnMenu?.setOnClickListener {
|
|
||||||
drawerLayout.openDrawer(GravityCompat.START)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language Spinner
|
|
||||||
val languageSpinner = findViewById<Spinner>(R.id.spinnerLanguage)
|
|
||||||
val languages = StringProvider.getLanguages()
|
|
||||||
val languageAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, languages)
|
|
||||||
languageAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
|
||||||
languageSpinner.adapter = languageAdapter
|
|
||||||
|
|
||||||
val savedLang = prefs.getString("LANGUAGE", "English")
|
|
||||||
languageSpinner.setSelection(languages.indexOf(savedLang))
|
|
||||||
|
|
||||||
languageSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
|
||||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
|
||||||
val selectedLanguage = languages[position]
|
|
||||||
val currentLang = prefs.getString("LANGUAGE", "English")
|
|
||||||
|
|
||||||
// Only update and recreate if language actually changed
|
|
||||||
if (selectedLanguage != currentLang) {
|
|
||||||
saveSettings() // Save UI state so it's not lost
|
|
||||||
StringProvider.setLanguage(selectedLanguage, this@HomeActivity)
|
|
||||||
// Post recreate to avoid WindowLeaked (allows spinner popup to close)
|
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
|
||||||
recreate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set text from StringProvider
|
|
||||||
findViewById<TextView>(R.id.tvTitle).text = StringProvider.getString("app_name")
|
|
||||||
findViewById<TextView>(R.id.tvSubtitle).text = StringProvider.getString("subtitle_home")
|
|
||||||
findViewById<MaterialButton>(R.id.btnViewGallery).text = StringProvider.getString("btn_view_gallery")
|
|
||||||
findViewById<MaterialButton>(R.id.btnSelectCow).text = StringProvider.getString("btn_select_cow")
|
|
||||||
findViewById<TextView>(R.id.tvAlgorithmLabel).text = StringProvider.getString("label_algorithm")
|
|
||||||
findViewById<TextView>(R.id.tvThresholdLabel).text = StringProvider.getString("label_match_threshold")
|
|
||||||
|
|
||||||
// Cow Illustration and Logic
|
|
||||||
val ivCowIllustration = findViewById<ImageView>(R.id.ivCowIllustration)
|
|
||||||
val savedIndex = prefs.getInt(PREF_COW_ILLUSTRATION_INDEX, 0)
|
|
||||||
setCowIllustration(ivCowIllustration, savedIndex)
|
|
||||||
|
|
||||||
ivCowIllustration.setOnClickListener { view ->
|
|
||||||
showIllustrationPopup(view, ivCowIllustration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation buttons
|
|
||||||
findViewById<MaterialButton>(R.id.btnViewGallery).setOnClickListener {
|
|
||||||
startActivity(Intent(this, GalleryActivity::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<MaterialButton>(R.id.btnSelectCow).setOnClickListener {
|
|
||||||
// if (!IS_RELEASE_BUILD) { // This logic seemed wrong in previous thought, we want to start activity regardless?
|
|
||||||
// Ah, user said "dont show the settings panel". It doesn't mean disable "Add Cow".
|
|
||||||
// But previously I wrote logic to save settings before start.
|
|
||||||
if (!IS_RELEASE_BUILD) {
|
|
||||||
saveSettings()
|
|
||||||
}
|
|
||||||
startActivity(Intent(this, CowSelectionActivity::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto Capture Toggle
|
|
||||||
val switchAutoCapture = findViewById<SwitchMaterial>(R.id.switchAutoCapture)
|
|
||||||
switchAutoCapture.text = StringProvider.getString("label_auto_capture")
|
|
||||||
val autoCaptureEnabled = prefs.getBoolean(PREF_AUTO_CAPTURE, false)
|
|
||||||
switchAutoCapture.isChecked = autoCaptureEnabled
|
|
||||||
|
|
||||||
val states = arrayOf(
|
|
||||||
intArrayOf(android.R.attr.state_checked),
|
|
||||||
intArrayOf(-android.R.attr.state_checked)
|
|
||||||
)
|
|
||||||
val thumbColors = intArrayOf(
|
|
||||||
Color.parseColor("#6D4C41"), // Dark Brown for On
|
|
||||||
Color.GRAY // Grey for Off
|
|
||||||
)
|
|
||||||
val trackColors = intArrayOf(
|
|
||||||
Color.parseColor("#8D6E63"), // Lighter Brown for On track
|
|
||||||
Color.LTGRAY // Light Grey for Off track
|
|
||||||
)
|
|
||||||
|
|
||||||
switchAutoCapture.thumbTintList = ColorStateList(states, thumbColors)
|
|
||||||
switchAutoCapture.trackTintList = ColorStateList(states, trackColors)
|
|
||||||
|
|
||||||
switchAutoCapture.setOnCheckedChangeListener { _, isChecked ->
|
|
||||||
prefs.edit().putBoolean(PREF_AUTO_CAPTURE, isChecked).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
val switchEnableDebug = findViewById<SwitchMaterial>(R.id.switchEnableDebug)
|
|
||||||
switchEnableDebug.text = StringProvider.getString("label_enable_debug")
|
|
||||||
val debugEnabled = prefs.getBoolean(PREF_DEBUG_ENABLE, false)
|
|
||||||
switchEnableDebug.isChecked = debugEnabled
|
|
||||||
IS_RELEASE_BUILD = !debugEnabled
|
|
||||||
|
|
||||||
switchEnableDebug.thumbTintList = ColorStateList(states, thumbColors)
|
|
||||||
switchEnableDebug.trackTintList = ColorStateList(states, trackColors)
|
|
||||||
|
|
||||||
switchEnableDebug.setOnCheckedChangeListener { _, isChecked ->
|
|
||||||
prefs.edit().putBoolean(PREF_DEBUG_ENABLE, isChecked).apply()
|
|
||||||
IS_RELEASE_BUILD = !isChecked
|
|
||||||
if (IS_RELEASE_BUILD) {
|
|
||||||
findViewById<CardView>(R.id.viewSettings).visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
saveSettings()
|
|
||||||
findViewById<CardView>(R.id.viewSettings).visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val headerSettings = findViewById<TextView>(R.id.tvHeaderSettings)
|
|
||||||
if (headerSettings != null) headerSettings.text = StringProvider.getString("header_settings")
|
|
||||||
|
|
||||||
// Algorithm Spinner
|
|
||||||
val spinner = findViewById<Spinner>(R.id.spinnerAlgorithm)
|
|
||||||
|
|
||||||
val displayAlgorithms = listOf(
|
|
||||||
StringProvider.getString("algo_hamming"),
|
|
||||||
StringProvider.getString("algo_euclidean"),
|
|
||||||
StringProvider.getString("algo_jaccard")
|
|
||||||
)
|
|
||||||
|
|
||||||
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayAlgorithms)
|
|
||||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
|
||||||
spinner.adapter = adapter
|
|
||||||
|
|
||||||
// Set default selection from preferences or intent
|
|
||||||
val savedAlg = prefs.getString("ALGORITHM", ALGORITHM_HAMMING)
|
|
||||||
val index = internalAlgorithms.indexOf(savedAlg)
|
|
||||||
spinner.setSelection(if (index >= 0) index else 0)
|
|
||||||
|
|
||||||
// Threshold SeekBar
|
|
||||||
val seekBar = findViewById<SeekBar>(R.id.seekBarThreshold)
|
|
||||||
val tvThreshold = findViewById<TextView>(R.id.tvThresholdValue)
|
|
||||||
|
|
||||||
val savedThreshold = prefs.getInt("THRESHOLD", 75)
|
|
||||||
seekBar.progress = savedThreshold
|
|
||||||
tvThreshold.text = "$savedThreshold%"
|
|
||||||
|
|
||||||
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
|
||||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
|
||||||
tvThreshold.text = "$progress%"
|
|
||||||
}
|
|
||||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
|
||||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mask Display Toggle
|
|
||||||
val switchMaskDisplay = findViewById<SwitchMaterial>(R.id.switchMaskDisplay)
|
|
||||||
switchMaskDisplay.text = StringProvider.getString("label_mask_display")
|
|
||||||
val maskDisplayEnabled = prefs.getBoolean(PREF_MASK_DISPLAY, false)
|
|
||||||
switchMaskDisplay.isChecked = maskDisplayEnabled
|
|
||||||
|
|
||||||
switchMaskDisplay.thumbTintList = ColorStateList(states, thumbColors)
|
|
||||||
switchMaskDisplay.trackTintList = ColorStateList(states, trackColors)
|
|
||||||
|
|
||||||
switchMaskDisplay.setOnCheckedChangeListener { _, isChecked ->
|
|
||||||
prefs.edit().putBoolean(PREF_MASK_DISPLAY, isChecked).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial visibility based on IS_RELEASE_BUILD
|
|
||||||
if (IS_RELEASE_BUILD) {
|
|
||||||
val settingsView = findViewById<CardView>(R.id.viewSettings)
|
|
||||||
settingsView.visibility = View.GONE
|
|
||||||
// Force defaults if needed, but logic above already sets defaults from prefs or code constants.
|
|
||||||
// If user wants to FORCE defaults every time release build runs:
|
|
||||||
/*
|
|
||||||
prefs.edit().putString("ALGORITHM", ALGORITHM_JACCARD).apply()
|
|
||||||
prefs.edit().putInt("THRESHOLD", 88).apply()
|
|
||||||
prefs.edit().putBoolean(PREF_MASK_DISPLAY, false).apply()
|
|
||||||
*/
|
|
||||||
// Leaving as is based on previous logic.
|
|
||||||
} else {
|
|
||||||
findViewById<CardView>(R.id.viewSettings).visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showIllustrationPopup(anchor: View, imageView: ImageView) {
|
|
||||||
val popup = PopupMenu(this, anchor)
|
|
||||||
for (i in 0..4) {
|
|
||||||
popup.menu.add(0, i, i, "Illustration $i")
|
|
||||||
}
|
|
||||||
|
|
||||||
popup.setOnMenuItemClickListener { item ->
|
|
||||||
val index = item.itemId
|
|
||||||
setCowIllustration(imageView, index)
|
|
||||||
|
|
||||||
// Save preference
|
|
||||||
getSharedPreferences("AnimalRatingPrefs", MODE_PRIVATE).edit()
|
|
||||||
.putInt(PREF_COW_ILLUSTRATION_INDEX, index)
|
|
||||||
.apply()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
popup.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setCowIllustration(imageView: ImageView, index: Int) {
|
|
||||||
val resName = "cow_illustration_$index"
|
|
||||||
val resId = resources.getIdentifier(resName, "drawable", packageName)
|
|
||||||
|
|
||||||
if (resId != 0) {
|
|
||||||
imageView.setImageResource(resId)
|
|
||||||
} else {
|
|
||||||
if (index == 0) {
|
|
||||||
val defaultId = resources.getIdentifier("cow_illustration", "drawable", packageName)
|
|
||||||
if (defaultId != 0) imageView.setImageResource(defaultId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveSettings() {
|
|
||||||
val spinner = findViewById<Spinner>(R.id.spinnerAlgorithm)
|
|
||||||
val seekBar = findViewById<SeekBar>(R.id.seekBarThreshold)
|
|
||||||
val showSegmentationMask = findViewById<SwitchMaterial>(R.id.switchMaskDisplay)
|
|
||||||
|
|
||||||
if (spinner != null && seekBar != null && showSegmentationMask != null) {
|
|
||||||
val selectedIndex = spinner.selectedItemPosition
|
|
||||||
val selectedAlgorithm = if (selectedIndex >= 0 && selectedIndex < internalAlgorithms.size) {
|
|
||||||
internalAlgorithms[selectedIndex]
|
|
||||||
} else {
|
|
||||||
ALGORITHM_HAMMING
|
|
||||||
}
|
|
||||||
|
|
||||||
val threshold = seekBar.progress
|
|
||||||
|
|
||||||
// Save to preferences
|
|
||||||
val prefs = getSharedPreferences("AnimalRatingPrefs", MODE_PRIVATE)
|
|
||||||
val showMask = prefs.getBoolean(PREF_MASK_DISPLAY, false)
|
|
||||||
prefs.edit().apply {
|
|
||||||
putString("ALGORITHM", selectedAlgorithm)
|
|
||||||
putInt("THRESHOLD", threshold)
|
|
||||||
putBoolean(PREF_MASK_DISPLAY, showMask)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveSettingsAndStart() {
|
|
||||||
if(!IS_RELEASE_BUILD)
|
|
||||||
saveSettings()
|
|
||||||
startActivity(Intent(this, CowSelectionActivity::class.java))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.GradientDrawable
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileWriter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class RatingActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var currentCowName: String
|
|
||||||
private lateinit var ratingsContainer: LinearLayout
|
|
||||||
|
|
||||||
// Feature list (internal keys)
|
|
||||||
private val features = listOf(
|
|
||||||
"Stature", "Chest width", "Body depth", "Angularity",
|
|
||||||
"Rump angle", "Rump width", "Rear legs set", "Rear legs rear view",
|
|
||||||
"Foot angle", "Fore udder attachment", "Rear udder height",
|
|
||||||
"Central ligament", "Udder depth", "Front teat position",
|
|
||||||
"Teat length", "Rear teat position", "Locomotion",
|
|
||||||
"Body condition score", "Hock development", "Bone structure",
|
|
||||||
"Rear udder width", "Teat thickness", "Muscularity"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val ratingsMap = mutableMapOf<String, Int>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_rating)
|
|
||||||
|
|
||||||
StringProvider.initialize(this)
|
|
||||||
|
|
||||||
currentCowName = intent.getStringExtra("COW_NAME") ?: run {
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val toolbar = findViewById<androidx.appcompat.widget.Toolbar>(R.id.toolbar)
|
|
||||||
setSupportActionBar(toolbar)
|
|
||||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
|
||||||
toolbar.setNavigationOnClickListener { finish() }
|
|
||||||
findViewById<TextView>(R.id.tvToolbarTitle)?.text = StringProvider.getString("title_rate_cow")
|
|
||||||
|
|
||||||
setupUIStrings()
|
|
||||||
loadCowDetails()
|
|
||||||
loadCowImages()
|
|
||||||
setupRatingSection()
|
|
||||||
loadExistingRatings()
|
|
||||||
|
|
||||||
findViewById<MaterialButton>(R.id.btnSaveRating).setOnClickListener {
|
|
||||||
saveRatings()
|
|
||||||
}
|
|
||||||
|
|
||||||
findViewById<MaterialButton>(R.id.btnCancelRating).setOnClickListener {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupUIStrings() {
|
|
||||||
findViewById<TextView>(R.id.tvHeaderPhotos)?.text = StringProvider.getString("header_photos")
|
|
||||||
findViewById<TextView>(R.id.tvHeaderCowDetails)?.text = StringProvider.getString("header_cow_details")
|
|
||||||
findViewById<TextView>(R.id.tvHeaderFeatureRatings)?.text = StringProvider.getString("header_feature_ratings")
|
|
||||||
findViewById<TextInputLayout>(R.id.tilComments)?.hint = StringProvider.getString("hint_comments")
|
|
||||||
findViewById<MaterialButton>(R.id.btnSaveRating)?.text = StringProvider.getString("btn_save_rating")
|
|
||||||
findViewById<MaterialButton>(R.id.btnCancelRating)?.text = StringProvider.getString("btn_cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCowDetails() {
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val csvFile = File(docsFolder, "cow_profiles.csv")
|
|
||||||
if (!csvFile.exists()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val lines = csvFile.readLines()
|
|
||||||
val record = lines.find { it.startsWith("$currentCowName,") }?.split(",") ?: return
|
|
||||||
|
|
||||||
if (record.size >= 8) {
|
|
||||||
// Translate values for display
|
|
||||||
val storedSpecies = record[1]
|
|
||||||
val speciesKey = StringProvider.getKeyForEnglishValue(storedSpecies)
|
|
||||||
val displaySpecies = if (speciesKey != null) StringProvider.getString(speciesKey) else storedSpecies
|
|
||||||
|
|
||||||
val storedBreed = record[2]
|
|
||||||
val breedKey = StringProvider.getKeyForEnglishValue(storedBreed)
|
|
||||||
val displayBreed = if (breedKey != null) StringProvider.getString(breedKey) else storedBreed
|
|
||||||
|
|
||||||
val storedStatus = record[6]
|
|
||||||
val statusKey = StringProvider.getKeyForEnglishValue(storedStatus)
|
|
||||||
val displayStatus = if (statusKey != null) StringProvider.getString(statusKey) else storedStatus
|
|
||||||
|
|
||||||
val infoText = StringBuilder()
|
|
||||||
infoText.append("${StringProvider.getString("label_species")} $displaySpecies\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_breed")} $displayBreed\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_age")} ${record[3]} ${StringProvider.getString("unit_years")}\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_milk_yield")} ${record[4]} ${StringProvider.getString("unit_liters")}\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_calving_no")} ${record[5]}\n")
|
|
||||||
infoText.append("${StringProvider.getString("label_status")} $displayStatus\n")
|
|
||||||
infoText.append("${StringProvider.getString("hint_description")}: ${record[7]}")
|
|
||||||
|
|
||||||
findViewById<TextView>(R.id.tvCowDetails).text = infoText.toString()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCowImages() {
|
|
||||||
val container = findViewById<LinearLayout>(R.id.ratingImagesContainer) ?: return
|
|
||||||
container.removeAllViews()
|
|
||||||
|
|
||||||
val cowImagesFolder = StorageUtils.getCowImageFolder(currentCowName)
|
|
||||||
val files = cowImagesFolder.listFiles { _, name -> name.startsWith("${currentCowName}_") && name.endsWith(".jpg") } ?: return
|
|
||||||
|
|
||||||
val horizontalScroll = android.widget.HorizontalScrollView(this)
|
|
||||||
horizontalScroll.layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
horizontalScroll.isFillViewport = false
|
|
||||||
|
|
||||||
val imagesLayout = LinearLayout(this)
|
|
||||||
imagesLayout.orientation = LinearLayout.HORIZONTAL
|
|
||||||
|
|
||||||
files.forEach { file ->
|
|
||||||
val imageView = ImageView(this)
|
|
||||||
val size = (100 * resources.displayMetrics.density).toInt()
|
|
||||||
val params = LinearLayout.LayoutParams(size, size)
|
|
||||||
params.setMargins(0, 0, 16, 0)
|
|
||||||
imageView.layoutParams = params
|
|
||||||
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
|
||||||
|
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
||||||
imageView.setImageBitmap(bitmap)
|
|
||||||
|
|
||||||
imageView.setOnClickListener {
|
|
||||||
val intent = Intent(this, FullScreenImageActivity::class.java)
|
|
||||||
intent.putExtra("IMAGE_PATH", file.absolutePath)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
imagesLayout.addView(imageView)
|
|
||||||
}
|
|
||||||
|
|
||||||
horizontalScroll.addView(imagesLayout)
|
|
||||||
container.addView(horizontalScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupRatingSection() {
|
|
||||||
ratingsContainer = findViewById(R.id.ratingsContainer)
|
|
||||||
ratingsContainer.removeAllViews()
|
|
||||||
|
|
||||||
features.forEach { feature ->
|
|
||||||
val featureView = layoutInflater.inflate(R.layout.item_feature_rating, ratingsContainer, false)
|
|
||||||
|
|
||||||
// Localize feature name
|
|
||||||
val key = "feature_" + feature.lowercase(Locale.ROOT).replace(" ", "_")
|
|
||||||
val displayName = StringProvider.getString(key)
|
|
||||||
val finalName = if (displayName.isNotEmpty()) displayName else feature
|
|
||||||
|
|
||||||
featureView.findViewById<TextView>(R.id.tvFeatureName).text = finalName
|
|
||||||
|
|
||||||
val buttonContainer = featureView.findViewById<LinearLayout>(R.id.buttonContainer)
|
|
||||||
val segmentViews = mutableListOf<TextView>()
|
|
||||||
|
|
||||||
// Create 9 segments
|
|
||||||
for (i in 1..9) {
|
|
||||||
val tv = TextView(this)
|
|
||||||
val params = LinearLayout.LayoutParams(
|
|
||||||
0,
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
1f
|
|
||||||
)
|
|
||||||
tv.layoutParams = params
|
|
||||||
tv.text = i.toString()
|
|
||||||
tv.gravity = Gravity.CENTER
|
|
||||||
tv.setTextColor(ContextCompat.getColor(this, R.color.black))
|
|
||||||
tv.textSize = 14f
|
|
||||||
tv.setBackgroundColor(Color.TRANSPARENT)
|
|
||||||
|
|
||||||
tv.setOnClickListener {
|
|
||||||
val currentRating = ratingsMap[feature] ?: 0
|
|
||||||
if (currentRating == i) {
|
|
||||||
// Clicked already selected -> Clear selection
|
|
||||||
ratingsMap[feature] = 0
|
|
||||||
updateSegmentSelection(segmentViews, 0)
|
|
||||||
} else {
|
|
||||||
// Select new rating
|
|
||||||
ratingsMap[feature] = i
|
|
||||||
updateSegmentSelection(segmentViews, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segmentViews.add(tv)
|
|
||||||
buttonContainer.addView(tv)
|
|
||||||
}
|
|
||||||
|
|
||||||
featureView.tag = segmentViews
|
|
||||||
ratingsContainer.addView(featureView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateSegmentSelection(segments: List<TextView>, selectedRating: Int) {
|
|
||||||
segments.forEachIndexed { index, tv ->
|
|
||||||
val rating = index + 1
|
|
||||||
if (rating == selectedRating) {
|
|
||||||
tv.setTextColor(Color.WHITE)
|
|
||||||
|
|
||||||
val radius = (8 * resources.displayMetrics.density)
|
|
||||||
val drawable = GradientDrawable()
|
|
||||||
drawable.setColor(Color.parseColor("#6D4C41"))
|
|
||||||
|
|
||||||
if (rating == 1) {
|
|
||||||
drawable.cornerRadii = floatArrayOf(radius, radius, 0f, 0f, 0f, 0f, radius, radius)
|
|
||||||
} else if (rating == 9) {
|
|
||||||
drawable.cornerRadii = floatArrayOf(0f, 0f, radius, radius, radius, radius, 0f, 0f)
|
|
||||||
} else {
|
|
||||||
drawable.cornerRadius = 0f
|
|
||||||
}
|
|
||||||
tv.background = drawable
|
|
||||||
} else {
|
|
||||||
tv.setTextColor(Color.parseColor("#5D4037"))
|
|
||||||
tv.background = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadExistingRatings() {
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val ratingsFile = File(docsFolder, "cow_ratings.csv")
|
|
||||||
if (!ratingsFile.exists()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val lines = ratingsFile.readLines()
|
|
||||||
// Format: CowID,Comments,Feature1,Feature2,...
|
|
||||||
val record = lines.find { it.startsWith("$currentCowName,") }?.split(",") ?: return
|
|
||||||
|
|
||||||
if (record.size >= 2) {
|
|
||||||
// Index 0: ID
|
|
||||||
// Index 1: Comments
|
|
||||||
val comments = record[1].replace(";", ",")
|
|
||||||
findViewById<TextInputLayout>(R.id.tilComments).editText?.setText(comments)
|
|
||||||
|
|
||||||
// Ratings start from index 2
|
|
||||||
features.forEachIndexed { index, feature ->
|
|
||||||
val ratingStr = record.getOrNull(index + 2)
|
|
||||||
val rating = ratingStr?.toIntOrNull() ?: 0
|
|
||||||
|
|
||||||
if (rating > 0) {
|
|
||||||
ratingsMap[feature] = rating
|
|
||||||
|
|
||||||
// Find view and update by index
|
|
||||||
val featureView = ratingsContainer.getChildAt(index)
|
|
||||||
if (featureView != null) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val segments = featureView.tag as? List<TextView>
|
|
||||||
segments?.let { updateSegmentSelection(it, rating) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveRatings() {
|
|
||||||
val commentsInput = findViewById<TextInputLayout>(R.id.tilComments).editText?.text.toString()
|
|
||||||
val comments = commentsInput.replace(",", ";")
|
|
||||||
|
|
||||||
val docsFolder = StorageUtils.getDocumentsFolder()
|
|
||||||
val ratingsFile = File(docsFolder, "cow_ratings.csv")
|
|
||||||
|
|
||||||
// Dynamically build header
|
|
||||||
val headerBuilder = StringBuilder("CowID,Comments")
|
|
||||||
features.forEach { headerBuilder.append(",$it") }
|
|
||||||
val header = headerBuilder.toString()
|
|
||||||
|
|
||||||
// Build row
|
|
||||||
val rowBuilder = StringBuilder()
|
|
||||||
rowBuilder.append("$currentCowName,$comments")
|
|
||||||
features.forEach { feature ->
|
|
||||||
val rating = ratingsMap[feature] ?: 0 // Defaults to 0 if not selected
|
|
||||||
rowBuilder.append(",$rating")
|
|
||||||
}
|
|
||||||
val newRow = rowBuilder.toString()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val lines = if (ratingsFile.exists()) ratingsFile.readLines().toMutableList() else mutableListOf()
|
|
||||||
|
|
||||||
if (lines.isEmpty()) {
|
|
||||||
lines.add(header)
|
|
||||||
} else if (lines[0] != header) {
|
|
||||||
// Header mismatch handling
|
|
||||||
}
|
|
||||||
|
|
||||||
val existingIndex = lines.indexOfFirst { it.startsWith("$currentCowName,") }
|
|
||||||
|
|
||||||
if (existingIndex != -1) {
|
|
||||||
lines[existingIndex] = newRow
|
|
||||||
} else {
|
|
||||||
lines.add(newRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
FileWriter(ratingsFile).use { writer ->
|
|
||||||
lines.forEach { line ->
|
|
||||||
writer.write(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, StringProvider.getString("toast_ratings_saved"), Toast.LENGTH_SHORT).show()
|
|
||||||
finish()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, StringProvider.getString("toast_error_saving_ratings"), Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.os.Environment
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
object StorageUtils {
|
|
||||||
|
|
||||||
private const val ROOT_FOLDER_NAME = "com.AnimalRating"
|
|
||||||
|
|
||||||
private fun getBaseFolder(): File {
|
|
||||||
// Changed to Android/media/com.AnimalRating as requested
|
|
||||||
val folder = File(Environment.getExternalStorageDirectory(), "Android/media/$ROOT_FOLDER_NAME")
|
|
||||||
if (!folder.exists()) {
|
|
||||||
folder.mkdirs()
|
|
||||||
}
|
|
||||||
return folder
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDocumentsFolder(): File {
|
|
||||||
val folder = File(getBaseFolder(), "Documents")
|
|
||||||
if (!folder.exists()) {
|
|
||||||
folder.mkdirs()
|
|
||||||
}
|
|
||||||
return folder
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getImagesBaseFolder(): File {
|
|
||||||
val folder = File(getBaseFolder(), "Images")
|
|
||||||
if (!folder.exists()) {
|
|
||||||
folder.mkdirs()
|
|
||||||
}
|
|
||||||
return folder
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCowImageFolder(cowId: String): File {
|
|
||||||
return File(getImagesBaseFolder(), cowId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getVideosFolder(): File {
|
|
||||||
val folder = File(getBaseFolder(), "Videos")
|
|
||||||
if (!folder.exists()) {
|
|
||||||
folder.mkdirs()
|
|
||||||
}
|
|
||||||
return folder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
package com.example.animalrating
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
object StringProvider {
|
|
||||||
|
|
||||||
private var stringData: JSONObject? = null
|
|
||||||
private var currentLanguage = "English"
|
|
||||||
private const val DEFAULT_LANGUAGE = "English"
|
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
|
||||||
if (stringData == null) {
|
|
||||||
try {
|
|
||||||
val inputStream: InputStream = context.resources.openRawResource(R.raw.strings)
|
|
||||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
|
||||||
stringData = JSONObject(jsonString)
|
|
||||||
|
|
||||||
// Load saved language
|
|
||||||
val prefs = context.getSharedPreferences("AnimalRatingPrefs", Context.MODE_PRIVATE)
|
|
||||||
currentLanguage = prefs.getString("LANGUAGE", DEFAULT_LANGUAGE) ?: DEFAULT_LANGUAGE
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
// Handle error, maybe load default strings or log
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLanguage(language: String, context: Context) {
|
|
||||||
currentLanguage = language
|
|
||||||
// Save selected language
|
|
||||||
val prefs = context.getSharedPreferences("AnimalRatingPrefs", Context.MODE_PRIVATE)
|
|
||||||
prefs.edit().putString("LANGUAGE", language).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLanguages(): List<String> {
|
|
||||||
return stringData?.keys()?.asSequence()?.toList() ?: listOf(DEFAULT_LANGUAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getString(key: String): String {
|
|
||||||
return getStringForLanguage(key, currentLanguage)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getStringEnglish(key: String): String {
|
|
||||||
return getStringForLanguage(key, DEFAULT_LANGUAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStringForLanguage(key: String, language: String): String {
|
|
||||||
return try {
|
|
||||||
stringData?.getJSONObject(language)?.getString(key) ?: ""
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback to English if key not found in current language
|
|
||||||
try {
|
|
||||||
if (language != DEFAULT_LANGUAGE) {
|
|
||||||
stringData?.getJSONObject(DEFAULT_LANGUAGE)?.getString(key) ?: ""
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
} catch (e2: Exception) {
|
|
||||||
"" // Return empty if not found anywhere
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKeyForValue(value: String): String? {
|
|
||||||
// Helper to find the key for a given localized value in current language
|
|
||||||
// This is expensive, but useful if we need to reverse lookup
|
|
||||||
val langData = stringData?.optJSONObject(currentLanguage) ?: return null
|
|
||||||
val keys = langData.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
if (langData.getString(key) == value) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also check English just in case it was stored as English
|
|
||||||
val englishData = stringData?.optJSONObject(DEFAULT_LANGUAGE) ?: return null
|
|
||||||
val engKeys = englishData.keys()
|
|
||||||
while (engKeys.hasNext()) {
|
|
||||||
val key = engKeys.next()
|
|
||||||
if (englishData.getString(key) == value) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getKeyForEnglishValue(value: String): String? {
|
|
||||||
val englishData = stringData?.optJSONObject(DEFAULT_LANGUAGE) ?: return null
|
|
||||||
val engKeys = englishData.keys()
|
|
||||||
while (engKeys.hasNext()) {
|
|
||||||
val key = engKeys.next()
|
|
||||||
if (englishData.getString(key) == value) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package com.example.animalrating.ml
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
|
|
||||||
class CowAnalyzer(
|
|
||||||
private val listener: CowListener
|
|
||||||
) : ImageAnalysis.Analyzer {
|
|
||||||
|
|
||||||
interface CowListener {
|
|
||||||
fun onFrame(imageProxy: ImageProxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
|
||||||
listener.onFrame(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
package com.example.animalrating.ml
|
|
||||||
|
|
||||||
import android.graphics.*
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert ImageProxy YUV_420_888 to Bitmap
|
|
||||||
*/
|
|
||||||
fun ImageProxy.toBitmap(): Bitmap {
|
|
||||||
val yBuffer = planes[0].buffer // Y
|
|
||||||
val uBuffer = planes[1].buffer // U
|
|
||||||
val vBuffer = planes[2].buffer // V
|
|
||||||
|
|
||||||
val ySize = yBuffer.remaining()
|
|
||||||
val uSize = uBuffer.remaining()
|
|
||||||
val vSize = vBuffer.remaining()
|
|
||||||
|
|
||||||
val nv21 = ByteArray(ySize + uSize + vSize)
|
|
||||||
|
|
||||||
// U and V are swapped, so we place V first then U
|
|
||||||
yBuffer.get(nv21, 0, ySize)
|
|
||||||
vBuffer.get(nv21, ySize, vSize)
|
|
||||||
uBuffer.get(nv21, ySize + vSize, uSize)
|
|
||||||
|
|
||||||
val yuvImage = YuvImage(
|
|
||||||
nv21,
|
|
||||||
ImageFormat.NV21,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
yuvImage.compressToJpeg(
|
|
||||||
Rect(0, 0, width, height),
|
|
||||||
90,
|
|
||||||
out
|
|
||||||
)
|
|
||||||
val jpegBytes = out.toByteArray()
|
|
||||||
|
|
||||||
return BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package com.example.animalrating.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.*
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.View
|
|
||||||
|
|
||||||
class MaskOverlay(context: Context, attrs: AttributeSet?) : View(context, attrs) {
|
|
||||||
|
|
||||||
private var maskBitmap: Bitmap? = null
|
|
||||||
private var matrix: Matrix = Matrix()
|
|
||||||
|
|
||||||
fun updateMask(bitmap: Bitmap?, transform: Matrix?) {
|
|
||||||
maskBitmap = bitmap
|
|
||||||
matrix = transform ?: Matrix()
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
super.onDraw(canvas)
|
|
||||||
val bmp = maskBitmap ?: return
|
|
||||||
canvas.drawBitmap(bmp, matrix, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package com.example.animalrating.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.*
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
|
||||||
|
|
||||||
class SilhouetteOverlay(context: Context, attrs: AttributeSet?) : View(context, attrs) {
|
|
||||||
|
|
||||||
private val paint = Paint().apply {
|
|
||||||
color = Color.GREEN
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
strokeWidth = 5f
|
|
||||||
}
|
|
||||||
|
|
||||||
private val silhouettePaint = Paint().apply {
|
|
||||||
alpha = 128 // 50% opacity
|
|
||||||
}
|
|
||||||
|
|
||||||
private var silhouette: Bitmap? = null
|
|
||||||
|
|
||||||
fun setSilhouette(drawableId: Int) {
|
|
||||||
try {
|
|
||||||
if (drawableId != 0) {
|
|
||||||
silhouette = BitmapFactory.decodeResource(resources, drawableId)
|
|
||||||
} else {
|
|
||||||
silhouette = null
|
|
||||||
}
|
|
||||||
invalidate()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("SilhouetteOverlay", "Error loading silhouette", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
super.onDraw(canvas)
|
|
||||||
|
|
||||||
silhouette?.let { bmp ->
|
|
||||||
|
|
||||||
val viewW = width.toFloat()
|
|
||||||
val viewH = height.toFloat()
|
|
||||||
val bmpW = bmp.width.toFloat()
|
|
||||||
val bmpH = bmp.height.toFloat()
|
|
||||||
|
|
||||||
// Calculate scale to fit (FIT_CENTER)
|
|
||||||
val scale = kotlin.math.min(viewW / bmpW, viewH / bmpH)
|
|
||||||
|
|
||||||
val scaledW = bmpW * scale
|
|
||||||
val scaledH = bmpH * scale
|
|
||||||
|
|
||||||
val left = (viewW - scaledW) / 2f
|
|
||||||
val top = (viewH - scaledH) / 2f
|
|
||||||
|
|
||||||
val destRect = RectF(left, top, left + scaledW, top + scaledH)
|
|
||||||
val srcRect = Rect(0, 0, bmp.width, bmp.height)
|
|
||||||
|
|
||||||
canvas.drawBitmap(bmp, srcRect, destRect, silhouettePaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package com.example.animalrating.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
|
||||||
val Pink40 = Color(0xFF7D5260)
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package com.example.animalrating.ui.theme
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = Purple80,
|
|
||||||
secondary = PurpleGrey80,
|
|
||||||
tertiary = Pink80
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
|
||||||
primary = Purple40,
|
|
||||||
secondary = PurpleGrey40,
|
|
||||||
tertiary = Pink40
|
|
||||||
|
|
||||||
/* Other default colors to override
|
|
||||||
background = Color(0xFFFFFBFE),
|
|
||||||
surface = Color(0xFFFFFBFE),
|
|
||||||
onPrimary = Color.White,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
onBackground = Color(0xFF1C1B1F),
|
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AnimalRatingTheme(
|
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
|
||||||
// Dynamic color is available on Android 12+
|
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
darkTheme -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = Typography,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package com.example.animalrating.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
/* Other default text styles to override
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
labelSmall = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.example.livingai
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.example.livingai.di.appModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class LivingAIApplication: Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidLogger()
|
||||||
|
androidContext(this@LivingAIApplication)
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package com.example.livingai
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||||
|
import com.example.livingai.pages.home.HomeViewModel
|
||||||
|
import com.example.livingai.pages.navigation.NavGraph
|
||||||
|
import com.example.livingai.ui.theme.LivingAITheme
|
||||||
|
import com.example.livingai.utils.LocaleHelper
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val viewModel by viewModel<HomeViewModel>()
|
||||||
|
private val appDataUseCases: AppDataUseCases by inject()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
installSplashScreen().apply {
|
||||||
|
setKeepOnScreenCondition {
|
||||||
|
viewModel.splashCondition.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val settings by appDataUseCases.getSettings().collectAsState(initial = null)
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val localizedContext = settings?.let {
|
||||||
|
LocaleHelper.applyLocale(context, it.language)
|
||||||
|
} ?: context
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContext provides localizedContext,
|
||||||
|
LocalActivityResultRegistryOwner provides this
|
||||||
|
) {
|
||||||
|
LivingAITheme {
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.auto(
|
||||||
|
lightScrim = Color.Transparent.toArgb(),
|
||||||
|
darkScrim = Color.Transparent.toArgb()
|
||||||
|
),
|
||||||
|
navigationBarStyle = SystemBarStyle.auto(
|
||||||
|
lightScrim = Color.Transparent.toArgb(),
|
||||||
|
darkScrim = Color.Transparent.toArgb()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
|
||||||
|
val startDestination = viewModel.startDestination.value
|
||||||
|
NavGraph(startDestination = startDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
package com.example.livingai.data.camera
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.livingai.R
|
||||||
|
import com.example.livingai.domain.camera.*
|
||||||
|
import com.example.livingai.domain.model.camera.*
|
||||||
|
import com.example.livingai.utils.SignedMask
|
||||||
|
import com.example.livingai.utils.SilhouetteManager
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import org.tensorflow.lite.support.common.FileUtil
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* ORIENTATION CHECKER */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
class DefaultOrientationChecker : OrientationChecker {
|
||||||
|
override suspend fun analyze(input: PipelineInput): Instruction {
|
||||||
|
|
||||||
|
val isPortraitRequired =
|
||||||
|
input.orientation.lowercase() == "front" ||
|
||||||
|
input.orientation.lowercase() == "back"
|
||||||
|
|
||||||
|
val isPortrait = input.deviceOrientation == 90 || input.deviceOrientation == 270
|
||||||
|
val isLandscape = input.deviceOrientation == 0 || input.deviceOrientation == 180
|
||||||
|
|
||||||
|
val valid = if (isPortraitRequired) isPortrait else isLandscape
|
||||||
|
|
||||||
|
return Instruction(
|
||||||
|
message = if (valid) "Orientation Correct"
|
||||||
|
else if (isPortraitRequired) "Turn to portrait mode"
|
||||||
|
else "Turn to landscape mode",
|
||||||
|
animationResId = if (valid) null else R.drawable.ic_launcher_foreground,
|
||||||
|
isValid = valid,
|
||||||
|
result = OrientationResult(
|
||||||
|
input.deviceOrientation,
|
||||||
|
if (isPortraitRequired) CameraOrientation.PORTRAIT else CameraOrientation.LANDSCAPE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* TILT CHECKER */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
class DefaultTiltChecker : TiltChecker {
|
||||||
|
|
||||||
|
override suspend fun analyze(input: PipelineInput): Instruction {
|
||||||
|
|
||||||
|
val tolerance = 20f
|
||||||
|
|
||||||
|
val (targetPitch, targetRoll) = Pair(-90f, 0f)
|
||||||
|
|
||||||
|
Log.d("TiltCheckerMessage", "targetPitch: ${input.devicePitch}, targetRoll: ${input.deviceRoll}, targetAz: ${input.deviceAzimuth}, tP: $targetPitch, tR: $targetRoll")
|
||||||
|
val pitchError = abs(input.devicePitch - targetPitch)
|
||||||
|
val rollError = abs(input.deviceRoll - targetRoll)
|
||||||
|
|
||||||
|
val isLevel = pitchError <= tolerance && rollError <= tolerance && input.deviceAzimuth > 0
|
||||||
|
val isRollError = rollError > tolerance
|
||||||
|
val isPitchError = pitchError > tolerance
|
||||||
|
|
||||||
|
val message = if (isLevel) {
|
||||||
|
"Device is level"
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
input.deviceAzimuth < 0 -> "Tilt phone forward"
|
||||||
|
input.deviceAzimuth >= 0 && !isRollError && isPitchError -> "Tilt phone backward"
|
||||||
|
input.deviceRoll > 0 -> "Rotate phone left"
|
||||||
|
input.deviceRoll <= 0 -> "Rotate phone right"
|
||||||
|
else -> "Device is level"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Instruction(
|
||||||
|
message = message,
|
||||||
|
isValid = isLevel,
|
||||||
|
result = TiltResult(
|
||||||
|
roll = input.deviceRoll,
|
||||||
|
pitch = input.devicePitch,
|
||||||
|
isLevel = isLevel
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* TFLITE OBJECT DETECTOR (PRIMARY + REFERENCE OBJECTS) */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
class TFLiteObjectDetector(context: Context) : ObjectDetector {
|
||||||
|
|
||||||
|
private var interpreter: Interpreter? = null
|
||||||
|
private var labels: List<String> = emptyList()
|
||||||
|
private var inputW = 0
|
||||||
|
private var inputH = 0
|
||||||
|
private var maxDetections = 25
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
interpreter = Interpreter(
|
||||||
|
FileUtil.loadMappedFile(context, "efficientdet-lite0.tflite")
|
||||||
|
)
|
||||||
|
labels = FileUtil.loadLabels(context, "labels.txt")
|
||||||
|
|
||||||
|
val inputShape = interpreter!!.getInputTensor(0).shape()
|
||||||
|
inputW = inputShape[1]
|
||||||
|
inputH = inputShape[2]
|
||||||
|
|
||||||
|
maxDetections = interpreter!!.getOutputTensor(0).shape()[1]
|
||||||
|
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e("Detector", "Failed to load model", e)
|
||||||
|
interpreter = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun analyze(input: PipelineInput): Instruction {
|
||||||
|
|
||||||
|
val image = input.image
|
||||||
|
?: return Instruction("Waiting for camera", isValid = false)
|
||||||
|
|
||||||
|
val resized = Bitmap.createScaledBitmap(image, inputW, inputH, true)
|
||||||
|
val buffer = bitmapToBuffer(resized)
|
||||||
|
|
||||||
|
val locations = Array(1) { Array(maxDetections) { FloatArray(4) } }
|
||||||
|
val classes = Array(1) { FloatArray(maxDetections) }
|
||||||
|
val scores = Array(1) { FloatArray(maxDetections) }
|
||||||
|
val count = FloatArray(1)
|
||||||
|
|
||||||
|
interpreter?.runForMultipleInputsOutputs(
|
||||||
|
arrayOf(buffer),
|
||||||
|
mapOf(0 to locations, 1 to classes, 2 to scores, 3 to count)
|
||||||
|
)
|
||||||
|
|
||||||
|
val detections = mutableListOf<Detection>()
|
||||||
|
|
||||||
|
for (i in 0 until count[0].toInt()) {
|
||||||
|
if (scores[0][i] < 0.5f) continue
|
||||||
|
|
||||||
|
val label = labels.getOrElse(classes[0][i].toInt()) { "Unknown" }
|
||||||
|
val b = locations[0][i]
|
||||||
|
|
||||||
|
detections += Detection(
|
||||||
|
label,
|
||||||
|
scores[0][i],
|
||||||
|
RectF(
|
||||||
|
b[1] * image.width,
|
||||||
|
b[0] * image.height,
|
||||||
|
b[3] * image.width,
|
||||||
|
b[2] * image.height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val primary = detections
|
||||||
|
.filter { it.label.equals(input.targetAnimal, true) }
|
||||||
|
.maxByOrNull { it.confidence }
|
||||||
|
|
||||||
|
val refs = detections
|
||||||
|
.filter { it !== primary }
|
||||||
|
.mapIndexed { i, d ->
|
||||||
|
ReferenceObject(
|
||||||
|
id = "ref_$i",
|
||||||
|
label = d.label,
|
||||||
|
bounds = d.bounds,
|
||||||
|
relativeHeight = d.bounds.height() / image.height,
|
||||||
|
relativeWidth = d.bounds.width() / image.width,
|
||||||
|
distance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Instruction(
|
||||||
|
message = if (primary != null) "Cow detected" else "Cow not detected",
|
||||||
|
isValid = primary != null,
|
||||||
|
result = DetectionResult(
|
||||||
|
isAnimalDetected = primary != null,
|
||||||
|
animalBounds = primary?.bounds,
|
||||||
|
referenceObjects = refs,
|
||||||
|
label = primary?.label,
|
||||||
|
confidence = primary?.confidence ?: 0f,
|
||||||
|
segmentationMask = null // Initialize with null as detection step doesn't do segmentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bitmapToBuffer(bitmap: Bitmap): ByteBuffer {
|
||||||
|
val buffer = ByteBuffer.allocateDirect(inputW * inputH * 3)
|
||||||
|
buffer.order(ByteOrder.nativeOrder())
|
||||||
|
val pixels = IntArray(inputW * inputH)
|
||||||
|
bitmap.getPixels(pixels, 0, inputW, 0, 0, inputW, inputH)
|
||||||
|
for (p in pixels) {
|
||||||
|
buffer.put(((p shr 16) and 0xFF).toByte())
|
||||||
|
buffer.put(((p shr 8) and 0xFF).toByte())
|
||||||
|
buffer.put((p and 0xFF).toByte())
|
||||||
|
}
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Detection(val label: String, val confidence: Float, val bounds: RectF)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockPoseAnalyzer : PoseAnalyzer {
|
||||||
|
|
||||||
|
private val segmenter by lazy {
|
||||||
|
SubjectSegmentation.getClient(
|
||||||
|
SubjectSegmenterOptions.Builder()
|
||||||
|
.enableForegroundConfidenceMask()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val isSegmentationRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private var lastSegmentationValid: Boolean? = null
|
||||||
|
|
||||||
|
override suspend fun analyze(input: PipelineInput): Instruction {
|
||||||
|
|
||||||
|
val detection = input.previousDetectionResult
|
||||||
|
?: return invalidate("No detection")
|
||||||
|
|
||||||
|
val cowBoxImage = detection.animalBounds
|
||||||
|
?: return invalidate("Cow not detected")
|
||||||
|
|
||||||
|
val image = input.image
|
||||||
|
?: return invalidate("No image")
|
||||||
|
|
||||||
|
val silhouette = SilhouetteManager.getSilhouette(input.orientation)
|
||||||
|
?: return invalidate("Silhouette missing")
|
||||||
|
|
||||||
|
val cowBoxScreen = imageToScreenRect(
|
||||||
|
box = cowBoxImage,
|
||||||
|
imageWidth = image.width,
|
||||||
|
imageHeight = image.height,
|
||||||
|
screenWidth = input.screenWidthPx,
|
||||||
|
screenHeight = input.screenHeightPx
|
||||||
|
)
|
||||||
|
|
||||||
|
val silhouetteBoxScreen = silhouette.boundingBox
|
||||||
|
|
||||||
|
val align = checkAlignment(
|
||||||
|
detected = cowBoxScreen,
|
||||||
|
reference = silhouetteBoxScreen,
|
||||||
|
toleranceRatio = 0.15f
|
||||||
|
)
|
||||||
|
|
||||||
|
if (align.issue != AlignmentIssue.OK) {
|
||||||
|
lastSegmentationValid = false
|
||||||
|
return alignmentToInstruction(align)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If segmentation already running → reuse last result
|
||||||
|
if (!isSegmentationRunning.compareAndSet(false, true)) {
|
||||||
|
|
||||||
|
return Instruction(
|
||||||
|
message = if (lastSegmentationValid == true)
|
||||||
|
"Pose Correct"
|
||||||
|
else
|
||||||
|
"Hold steady",
|
||||||
|
isValid = lastSegmentationValid == true,
|
||||||
|
result = detection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cropped = Bitmap.createBitmap(
|
||||||
|
image,
|
||||||
|
cowBoxImage.left.toInt(),
|
||||||
|
cowBoxImage.top.toInt(),
|
||||||
|
cowBoxImage.width().toInt(),
|
||||||
|
cowBoxImage.height().toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
val resized = Bitmap.createScaledBitmap(
|
||||||
|
cropped,
|
||||||
|
silhouette.croppedBitmap.width,
|
||||||
|
silhouette.croppedBitmap.height,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
val mask = segment(resized)
|
||||||
|
|
||||||
|
val valid = if (mask != null) {
|
||||||
|
val score = similarity(mask, silhouette.signedMask)
|
||||||
|
score >= 0.40f
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSegmentationValid = valid
|
||||||
|
|
||||||
|
return Instruction(
|
||||||
|
message = if (valid) "Pose Correct" else "Adjust Position",
|
||||||
|
isValid = valid,
|
||||||
|
result = detection.copy(segmentationMask = mask) // Pass the mask in the result
|
||||||
|
)
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
isSegmentationRunning.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------- */
|
||||||
|
/* HELPERS */
|
||||||
|
/* -------------------------------------------------- */
|
||||||
|
|
||||||
|
private fun invalidate(reason: String): Instruction {
|
||||||
|
lastSegmentationValid = false
|
||||||
|
return Instruction(reason, isValid = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun segment(bitmap: Bitmap): ByteArray? =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
segmenter.process(InputImage.fromBitmap(bitmap, 0))
|
||||||
|
.addOnSuccessListener { r ->
|
||||||
|
val buf = r.foregroundConfidenceMask
|
||||||
|
?: return@addOnSuccessListener cont.resume(null)
|
||||||
|
|
||||||
|
buf.rewind()
|
||||||
|
val out = ByteArray(bitmap.width * bitmap.height)
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = if (buf.get() > 0.5f) 1 else 0
|
||||||
|
}
|
||||||
|
cont.resume(out)
|
||||||
|
}
|
||||||
|
.addOnFailureListener {
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun similarity(mask: ByteArray, ref: SignedMask): Float {
|
||||||
|
var s = 0f
|
||||||
|
var i = 0
|
||||||
|
for (row in ref.mask)
|
||||||
|
for (v in row)
|
||||||
|
s += mask[i++] * v
|
||||||
|
return if (ref.maxValue == 0f) 0f else s / ref.maxValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* ALIGNMENT HELPERS (UNCHANGED) */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
enum class AlignmentIssue { TOO_SMALL, TOO_LARGE, MOVE_LEFT, MOVE_RIGHT, MOVE_UP, MOVE_DOWN, OK }
|
||||||
|
|
||||||
|
data class AlignmentResult(val issue: AlignmentIssue, val scale: Float, val dx: Float, val dy: Float)
|
||||||
|
|
||||||
|
fun checkAlignment(
|
||||||
|
detected: RectF,
|
||||||
|
reference: RectF,
|
||||||
|
toleranceRatio: Float
|
||||||
|
): AlignmentResult {
|
||||||
|
|
||||||
|
val tolX = reference.width() * toleranceRatio
|
||||||
|
val tolY = reference.height() * toleranceRatio
|
||||||
|
|
||||||
|
if (detected.left < reference.left - tolX)
|
||||||
|
return AlignmentResult(AlignmentIssue.MOVE_RIGHT, detected.width() / reference.width(), detected.left - reference.left, 0f)
|
||||||
|
|
||||||
|
if (detected.right > reference.right + tolX)
|
||||||
|
return AlignmentResult(AlignmentIssue.MOVE_LEFT, detected.width() / reference.width(), detected.right - reference.right, 0f)
|
||||||
|
|
||||||
|
if (detected.top < reference.top - tolY)
|
||||||
|
return AlignmentResult(AlignmentIssue.MOVE_DOWN, detected.height() / reference.height(), 0f, detected.top - reference.top)
|
||||||
|
|
||||||
|
if (detected.bottom > reference.bottom + tolY)
|
||||||
|
return AlignmentResult(AlignmentIssue.MOVE_UP, detected.height() / reference.height(), 0f, detected.bottom - reference.bottom)
|
||||||
|
|
||||||
|
val scale = min(
|
||||||
|
detected.width() / reference.width(),
|
||||||
|
detected.height() / reference.height()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (scale < 1f - toleranceRatio)
|
||||||
|
return AlignmentResult(AlignmentIssue.TOO_SMALL, scale, 0f, 0f)
|
||||||
|
|
||||||
|
if (scale > 1f + toleranceRatio)
|
||||||
|
return AlignmentResult(AlignmentIssue.TOO_LARGE, scale, 0f, 0f)
|
||||||
|
|
||||||
|
return AlignmentResult(AlignmentIssue.OK, scale, 0f, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun alignmentToInstruction(a: AlignmentResult) = when (a.issue) {
|
||||||
|
AlignmentIssue.TOO_SMALL -> Instruction("Move closer", false)
|
||||||
|
AlignmentIssue.TOO_LARGE -> Instruction("Move backward", false)
|
||||||
|
AlignmentIssue.MOVE_LEFT -> Instruction("Move right", false)
|
||||||
|
AlignmentIssue.MOVE_RIGHT -> Instruction("Move left", false)
|
||||||
|
AlignmentIssue.MOVE_UP -> Instruction("Move down", false)
|
||||||
|
AlignmentIssue.MOVE_DOWN -> Instruction("Move up", false)
|
||||||
|
AlignmentIssue.OK -> Instruction("Hold steady", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageToScreenRect(
|
||||||
|
box: RectF,
|
||||||
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
|
screenWidth: Float,
|
||||||
|
screenHeight: Float
|
||||||
|
): RectF {
|
||||||
|
|
||||||
|
// EXACT SAME LOGIC AS DetectionOverlay
|
||||||
|
val widthRatio = screenWidth / imageWidth
|
||||||
|
val heightRatio = screenHeight / imageHeight
|
||||||
|
val scale = max(widthRatio, heightRatio)
|
||||||
|
|
||||||
|
val offsetX = (screenWidth - imageWidth * scale) / 2f
|
||||||
|
val offsetY = (screenHeight - imageHeight * scale) / 2f
|
||||||
|
|
||||||
|
return RectF(
|
||||||
|
box.left * scale + offsetX,
|
||||||
|
box.top * scale + offsetY,
|
||||||
|
box.right * scale + offsetX,
|
||||||
|
box.bottom * scale + offsetY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* CAPTURE + MEASUREMENT (UNCHANGED) */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
class DefaultCaptureHandler : CaptureHandler {
|
||||||
|
override suspend fun capture(input: PipelineInput, detectionResult: DetectionResult): CaptureData =
|
||||||
|
CaptureData(
|
||||||
|
image = input.image!!,
|
||||||
|
segmentationMask = BooleanArray(0),
|
||||||
|
animalMetrics = ObjectMetrics(0f, 0f, 1f),
|
||||||
|
referenceObjects = detectionResult.referenceObjects
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultMeasurementCalculator : MeasurementCalculator {
|
||||||
|
override fun calculateRealMetrics(
|
||||||
|
targetHeight: Float,
|
||||||
|
referenceObject: ReferenceObject,
|
||||||
|
currentMetrics: ObjectMetrics
|
||||||
|
): RealWorldMetrics {
|
||||||
|
|
||||||
|
if (referenceObject.relativeHeight == 0f)
|
||||||
|
return RealWorldMetrics(0f, 0f, 0f)
|
||||||
|
|
||||||
|
val scale = targetHeight / referenceObject.relativeHeight
|
||||||
|
|
||||||
|
return RealWorldMetrics(
|
||||||
|
height = currentMetrics.relativeHeight * scale,
|
||||||
|
width = currentMetrics.relativeWidth * scale,
|
||||||
|
distance = currentMetrics.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.example.livingai.data.local
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
|
|
||||||
|
class AnimalDataPagingSource(
|
||||||
|
private val dataSource: CSVDataSource
|
||||||
|
) : PagingSource<Int, AnimalProfile>() {
|
||||||
|
|
||||||
|
override fun getRefreshKey(state: PagingState<Int, AnimalProfile>): Int? {
|
||||||
|
return state.anchorPosition?.let { anchorPosition ->
|
||||||
|
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||||
|
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AnimalProfile> {
|
||||||
|
val page = params.key ?: 0
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// Since CSVDataSource reads all lines at once currently (simulated simple DB),
|
||||||
|
// we will paginate the list in memory.
|
||||||
|
val allProfiles = dataSource.getAllAnimalProfiles()
|
||||||
|
|
||||||
|
val start = page * params.loadSize
|
||||||
|
val end = minOf(start + params.loadSize, allProfiles.size)
|
||||||
|
|
||||||
|
if (start >= allProfiles.size) {
|
||||||
|
return LoadResult.Page(
|
||||||
|
data = emptyList(),
|
||||||
|
prevKey = if (page > 0) page - 1 else null,
|
||||||
|
nextKey = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pagedData = allProfiles.subList(start, end)
|
||||||
|
|
||||||
|
LoadResult.Page(
|
||||||
|
data = pagedData,
|
||||||
|
prevKey = if (page > 0) page - 1 else null,
|
||||||
|
nextKey = if (end < allProfiles.size) page + 1 else null
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,424 @@
|
||||||
|
package com.example.livingai.data.local
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.paging.Pager
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.example.livingai.domain.model.*
|
||||||
|
import com.example.livingai.domain.repository.business.DataSource
|
||||||
|
import com.opencsv.CSVReader
|
||||||
|
import com.opencsv.CSVWriter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
|
class CSVDataSource(
|
||||||
|
private val context: Context,
|
||||||
|
private val fileName: String,
|
||||||
|
private val dispatchers: com.example.livingai.utils.CoroutineDispatchers
|
||||||
|
) : DataSource {
|
||||||
|
|
||||||
|
private val folderName = "LivingAI"
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private var cachedUri: Uri? = null
|
||||||
|
|
||||||
|
private suspend fun getCsvUri(): Uri = withContext(dispatchers.io) {
|
||||||
|
cachedUri?.let { return@withContext it }
|
||||||
|
|
||||||
|
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
queryOrCreateCsvQ()
|
||||||
|
} else {
|
||||||
|
legacyGetOrCreateFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedUri = uri
|
||||||
|
return@withContext uri
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryOrCreateCsvQ(): Uri {
|
||||||
|
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
|
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Files.FileColumns._ID,
|
||||||
|
MediaStore.Files.FileColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Files.FileColumns.RELATIVE_PATH
|
||||||
|
)
|
||||||
|
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
collection,
|
||||||
|
projection,
|
||||||
|
"${MediaStore.Files.FileColumns.DISPLAY_NAME}=?",
|
||||||
|
arrayOf(fileName),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val idCol = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||||
|
val pathCol = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.RELATIVE_PATH)
|
||||||
|
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val relPath = it.getString(pathCol) ?: ""
|
||||||
|
if (relPath.contains(folderName)) {
|
||||||
|
val id = it.getLong(idCol)
|
||||||
|
return ContentUris.withAppendedId(collection, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file if not found
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.Files.FileColumns.MIME_TYPE, "text/csv")
|
||||||
|
put(
|
||||||
|
MediaStore.Files.FileColumns.RELATIVE_PATH,
|
||||||
|
"${Environment.DIRECTORY_DOCUMENTS}/$folderName/"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.contentResolver.insert(collection, values)!!.also { uri ->
|
||||||
|
writeHeader(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legacyGetOrCreateFile(): Uri {
|
||||||
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
|
||||||
|
val sub = File(dir, folderName)
|
||||||
|
if (!sub.exists()) sub.mkdirs()
|
||||||
|
|
||||||
|
val file = File(sub, fileName)
|
||||||
|
if (!file.exists()) {
|
||||||
|
file.createNewFile()
|
||||||
|
writeHeaderLegacy(file)
|
||||||
|
}
|
||||||
|
return Uri.fromFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readAllLines(): List<Array<String>> = mutex.withLock {
|
||||||
|
val uri = getCsvUri()
|
||||||
|
return withContext(dispatchers.io) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
val reader = CSVReader(InputStreamReader(input))
|
||||||
|
val lines = reader.readAll()
|
||||||
|
reader.close()
|
||||||
|
if (lines.isNotEmpty() && lines[0].contentEquals(HEADER)) lines.drop(1)
|
||||||
|
else lines
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock {
|
||||||
|
val uri = getCsvUri()
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
|
||||||
|
val writer = CSVWriter(OutputStreamWriter(out))
|
||||||
|
writer.writeNext(HEADER)
|
||||||
|
writer.writeAll(lines)
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeHeader(uri: Uri) {
|
||||||
|
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
|
||||||
|
CSVWriter(OutputStreamWriter(out)).use { it.writeNext(HEADER) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeHeaderLegacy(file: File) {
|
||||||
|
CSVWriter(FileWriter(file)).use { it.writeNext(HEADER) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------
|
||||||
|
// 3) PUBLIC API IMPLEMENTATION
|
||||||
|
// --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>> =
|
||||||
|
Pager(PagingConfig(pageSize = 20)) {
|
||||||
|
AnimalDataPagingSource(this)
|
||||||
|
}.flow
|
||||||
|
|
||||||
|
suspend fun getAllAnimalProfiles(): List<AnimalProfile> {
|
||||||
|
return readAllLines().mapNotNull(::parseAnimalProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnimalDetails(animalId: String): Flow<AnimalDetails?> = flow {
|
||||||
|
emit(parseAnimalDetails(readAllLines().find { it.getOrNull(INDEX_ID) == animalId }))
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun getAnimalRatings(animalId: String): Flow<AnimalRating?> = flow {
|
||||||
|
emit(parseAnimalRating(readAllLines().find { it.getOrNull(INDEX_ID) == animalId }))
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
override suspend fun setAnimalProfile(p: AnimalProfile) {
|
||||||
|
val lines = readAllLines().toMutableList()
|
||||||
|
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == p.animalId }
|
||||||
|
|
||||||
|
if (i != -1) lines[i] = updateProfile(lines[i], p)
|
||||||
|
else lines.add(createProfile(p))
|
||||||
|
|
||||||
|
writeAllLines(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAnimalDetails(d: AnimalDetails) {
|
||||||
|
val lines = readAllLines().toMutableList()
|
||||||
|
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == d.animalId }
|
||||||
|
|
||||||
|
if (i != -1) lines[i] = updateDetails(lines[i], d)
|
||||||
|
else lines.add(createDetails(d))
|
||||||
|
|
||||||
|
writeAllLines(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAnimalRatings(r: AnimalRating) {
|
||||||
|
val lines = readAllLines().toMutableList()
|
||||||
|
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == r.animalId }
|
||||||
|
|
||||||
|
if (i != -1) lines[i] = updateRating(lines[i], r)
|
||||||
|
else lines.add(createRating(r))
|
||||||
|
|
||||||
|
writeAllLines(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAnimalProfile(animalId: String) {
|
||||||
|
val lines = readAllLines().toMutableList()
|
||||||
|
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == animalId }
|
||||||
|
if (i != -1) {
|
||||||
|
lines.removeAt(i)
|
||||||
|
writeAllLines(lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------
|
||||||
|
// 4) PARSERS + SERIALIZERS (exact same behavior you had)
|
||||||
|
// --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun empty(): Array<String> = Array(TOTAL_COLUMNS) { "" }
|
||||||
|
|
||||||
|
private fun parseAnimalProfile(row: Array<String>?): AnimalProfile? {
|
||||||
|
row ?: return null
|
||||||
|
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
|
||||||
|
return AnimalProfile(
|
||||||
|
animalId = id,
|
||||||
|
name = row[INDEX_NAME],
|
||||||
|
species = row[INDEX_SPECIES],
|
||||||
|
breed = row[INDEX_BREED],
|
||||||
|
sex = row[INDEX_SEX],
|
||||||
|
weight = row[INDEX_WEIGHT].toIntOrNull() ?: 0,
|
||||||
|
age = row[INDEX_AGE].toIntOrNull() ?: 0,
|
||||||
|
imageUrls = row[INDEX_IMAGES].split(";").filter { it.isNotBlank() },
|
||||||
|
overallRating = row[INDEX_RATING_OVERALL].toIntOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAnimalDetails(row: Array<String>?): AnimalDetails? {
|
||||||
|
row ?: return null
|
||||||
|
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
|
||||||
|
return AnimalDetails(
|
||||||
|
animalId = id,
|
||||||
|
name = row[INDEX_NAME],
|
||||||
|
species = row[INDEX_SPECIES],
|
||||||
|
breed = row[INDEX_BREED],
|
||||||
|
sex = row[INDEX_SEX],
|
||||||
|
weight = row[INDEX_WEIGHT].toIntOrNull() ?: 0,
|
||||||
|
age = row[INDEX_AGE].toIntOrNull() ?: 0,
|
||||||
|
milkYield = row[INDEX_MILK].toIntOrNull() ?: 0,
|
||||||
|
calvingNumber = row[INDEX_CALVING].toIntOrNull() ?: 0,
|
||||||
|
reproductiveStatus = row[INDEX_REPRO],
|
||||||
|
description = row[INDEX_DESC],
|
||||||
|
images = row[INDEX_IMAGES].split(';').asSequence().filter { it.isNotBlank() }
|
||||||
|
.map { pair ->
|
||||||
|
val (k, v) = pair.split('=', limit = 2)
|
||||||
|
k to v
|
||||||
|
}.toMap(),
|
||||||
|
video = row[INDEX_VIDEO],
|
||||||
|
segmentedImages = row.getOrNull(INDEX_SEGMENTED_IMAGES)?.split(';')?.asSequence()?.filter { it.isNotBlank() }
|
||||||
|
?.map { pair ->
|
||||||
|
val (k, v) = pair.split('=', limit = 2)
|
||||||
|
k to v
|
||||||
|
}?.toMap() ?: emptyMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAnimalRating(row: Array<String>?): AnimalRating? {
|
||||||
|
row ?: return null
|
||||||
|
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
|
||||||
|
return AnimalRating(
|
||||||
|
animalId = id,
|
||||||
|
overallRating = row[INDEX_RATING_OVERALL].toIntOrNull() ?: 0,
|
||||||
|
healthRating = row[INDEX_RATING_HEALTH].toIntOrNull() ?: 0,
|
||||||
|
breedRating = row[INDEX_RATING_BREED].toIntOrNull() ?: 0,
|
||||||
|
stature = row[INDEX_RATING_STATURE].toIntOrNull() ?: 0,
|
||||||
|
chestWidth = row[INDEX_RATING_CHEST].toIntOrNull() ?: 0,
|
||||||
|
bodyDepth = row[INDEX_RATING_BODY_DEPTH].toIntOrNull() ?: 0,
|
||||||
|
angularity = row[INDEX_RATING_ANGULARITY].toIntOrNull() ?: 0,
|
||||||
|
rumpAngle = row[INDEX_RATING_RUMP_ANGLE].toIntOrNull() ?: 0,
|
||||||
|
rumpWidth = row[INDEX_RATING_RUMP_WIDTH].toIntOrNull() ?: 0,
|
||||||
|
rearLegSet = row[INDEX_RATING_REAR_LEG_SET].toIntOrNull() ?: 0,
|
||||||
|
rearLegRearView = row[INDEX_RATING_REAR_LEG_REAR].toIntOrNull() ?: 0,
|
||||||
|
footAngle = row[INDEX_RATING_FOOT_ANGLE].toIntOrNull() ?: 0,
|
||||||
|
foreUdderAttachment = row[INDEX_RATING_FORE_UDDER].toIntOrNull() ?: 0,
|
||||||
|
rearUdderHeight = row[INDEX_RATING_REAR_UDDER_HEIGHT].toIntOrNull() ?: 0,
|
||||||
|
centralLigament = row[INDEX_RATING_CENTRAL_LIG].toIntOrNull() ?: 0,
|
||||||
|
udderDepth = row[INDEX_RATING_UDDER_DEPTH].toIntOrNull() ?: 0,
|
||||||
|
frontTeatPosition = row[INDEX_RATING_FRONT_TEAT].toIntOrNull() ?: 0,
|
||||||
|
teatLength = row[INDEX_RATING_TEAT_LEN].toIntOrNull() ?: 0,
|
||||||
|
rearTeatPosition = row[INDEX_RATING_REAR_TEAT].toIntOrNull() ?: 0,
|
||||||
|
locomotion = row[INDEX_RATING_LOCOMOTION].toIntOrNull() ?: 0,
|
||||||
|
bodyConditionScore = row[INDEX_RATING_BCS].toIntOrNull() ?: 0,
|
||||||
|
hockDevelopment = row[INDEX_RATING_HOCK].toIntOrNull() ?: 0,
|
||||||
|
boneStructure = row[INDEX_RATING_BONE].toIntOrNull() ?: 0,
|
||||||
|
rearUdderWidth = row[INDEX_RATING_REAR_UDDER_WIDTH].toIntOrNull() ?: 0,
|
||||||
|
teatThickness = row[INDEX_RATING_TEAT_THICKNESS].toIntOrNull() ?: 0,
|
||||||
|
muscularity = row[INDEX_RATING_MUSCULARITY].toIntOrNull() ?: 0,
|
||||||
|
bodyConditionComments = row[INDEX_RATING_BODY_COND_COMMENTS]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProfile(row: Array<String>, p: AnimalProfile): Array<String> {
|
||||||
|
row[INDEX_ID] = p.animalId
|
||||||
|
row[INDEX_NAME] = p.name
|
||||||
|
row[INDEX_SPECIES] = p.species
|
||||||
|
row[INDEX_BREED] = p.breed
|
||||||
|
row[INDEX_SEX] = p.sex
|
||||||
|
row[INDEX_WEIGHT] = p.weight.toString()
|
||||||
|
row[INDEX_AGE] = p.age.toString()
|
||||||
|
row[INDEX_IMAGES] = p.imageUrls.joinToString(";")
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDetails(row: Array<String>, d: AnimalDetails): Array<String> {
|
||||||
|
row[INDEX_ID] = d.animalId
|
||||||
|
row[INDEX_NAME] = d.name
|
||||||
|
row[INDEX_SPECIES] = d.species
|
||||||
|
row[INDEX_BREED] = d.breed
|
||||||
|
row[INDEX_SEX] = d.sex
|
||||||
|
row[INDEX_WEIGHT] = d.weight.toString()
|
||||||
|
row[INDEX_AGE] = d.age.toString()
|
||||||
|
row[INDEX_MILK] = d.milkYield.toString()
|
||||||
|
row[INDEX_CALVING] = d.calvingNumber.toString()
|
||||||
|
row[INDEX_REPRO] = d.reproductiveStatus
|
||||||
|
row[INDEX_DESC] = d.description
|
||||||
|
row[INDEX_IMAGES] = d.images.entries.joinToString(";") { (k, v) -> "$k=$v" }
|
||||||
|
row[INDEX_VIDEO] = d.video
|
||||||
|
row[INDEX_SEGMENTED_IMAGES] = d.segmentedImages.entries.joinToString(";") { (k, v) -> "$k=$v" }
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRating(row: Array<String>, r: AnimalRating): Array<String> {
|
||||||
|
row[INDEX_ID] = r.animalId
|
||||||
|
row[INDEX_RATING_OVERALL] = r.overallRating.toString()
|
||||||
|
row[INDEX_RATING_HEALTH] = r.healthRating.toString()
|
||||||
|
row[INDEX_RATING_BREED] = r.breedRating.toString()
|
||||||
|
row[INDEX_RATING_STATURE] = r.stature.toString()
|
||||||
|
row[INDEX_RATING_CHEST] = r.chestWidth.toString()
|
||||||
|
row[INDEX_RATING_BODY_DEPTH] = r.bodyDepth.toString()
|
||||||
|
row[INDEX_RATING_ANGULARITY] = r.angularity.toString()
|
||||||
|
row[INDEX_RATING_RUMP_ANGLE] = r.rumpAngle.toString()
|
||||||
|
row[INDEX_RATING_RUMP_WIDTH] = r.rumpWidth.toString()
|
||||||
|
row[INDEX_RATING_REAR_LEG_SET] = r.rearLegSet.toString()
|
||||||
|
row[INDEX_RATING_REAR_LEG_REAR] = r.rearLegRearView.toString()
|
||||||
|
row[INDEX_RATING_FOOT_ANGLE] = r.footAngle.toString()
|
||||||
|
row[INDEX_RATING_FORE_UDDER] = r.foreUdderAttachment.toString()
|
||||||
|
row[INDEX_RATING_REAR_UDDER_HEIGHT] = r.rearUdderHeight.toString()
|
||||||
|
row[INDEX_RATING_CENTRAL_LIG] = r.centralLigament.toString()
|
||||||
|
row[INDEX_RATING_UDDER_DEPTH] = r.udderDepth.toString()
|
||||||
|
row[INDEX_RATING_FRONT_TEAT] = r.frontTeatPosition.toString()
|
||||||
|
row[INDEX_RATING_TEAT_LEN] = r.teatLength.toString()
|
||||||
|
row[INDEX_RATING_REAR_TEAT] = r.rearTeatPosition.toString()
|
||||||
|
row[INDEX_RATING_LOCOMOTION] = r.locomotion.toString()
|
||||||
|
row[INDEX_RATING_BCS] = r.bodyConditionScore.toString()
|
||||||
|
row[INDEX_RATING_HOCK] = r.hockDevelopment.toString()
|
||||||
|
row[INDEX_RATING_BONE] = r.boneStructure.toString()
|
||||||
|
row[INDEX_RATING_REAR_UDDER_WIDTH] = r.rearUdderWidth.toString()
|
||||||
|
row[INDEX_RATING_TEAT_THICKNESS] = r.teatThickness.toString()
|
||||||
|
row[INDEX_RATING_MUSCULARITY] = r.muscularity.toString()
|
||||||
|
row[INDEX_RATING_BODY_COND_COMMENTS] = r.bodyConditionComments
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createProfile(p: AnimalProfile) = updateProfile(empty(), p)
|
||||||
|
private fun createDetails(d: AnimalDetails) = updateDetails(empty(), d)
|
||||||
|
private fun createRating(r: AnimalRating) = updateRating(empty(), r)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Same columns as before
|
||||||
|
const val INDEX_ID = 0
|
||||||
|
const val INDEX_NAME = 1
|
||||||
|
const val INDEX_SPECIES = 2
|
||||||
|
const val INDEX_BREED = 3
|
||||||
|
const val INDEX_SEX = 4
|
||||||
|
const val INDEX_WEIGHT = 5
|
||||||
|
const val INDEX_AGE = 6
|
||||||
|
const val INDEX_MILK = 7
|
||||||
|
const val INDEX_CALVING = 8
|
||||||
|
const val INDEX_REPRO = 9
|
||||||
|
const val INDEX_DESC = 10
|
||||||
|
const val INDEX_IMAGES = 11
|
||||||
|
const val INDEX_VIDEO = 12
|
||||||
|
const val INDEX_RATING_OVERALL = 13
|
||||||
|
const val INDEX_RATING_HEALTH = 14
|
||||||
|
const val INDEX_RATING_BREED = 15
|
||||||
|
const val INDEX_RATING_STATURE = 16
|
||||||
|
const val INDEX_RATING_CHEST = 17
|
||||||
|
const val INDEX_RATING_BODY_DEPTH = 18
|
||||||
|
const val INDEX_RATING_ANGULARITY = 19
|
||||||
|
const val INDEX_RATING_RUMP_ANGLE = 20
|
||||||
|
const val INDEX_RATING_RUMP_WIDTH = 21
|
||||||
|
const val INDEX_RATING_REAR_LEG_SET = 22
|
||||||
|
const val INDEX_RATING_REAR_LEG_REAR = 23
|
||||||
|
const val INDEX_RATING_FOOT_ANGLE = 24
|
||||||
|
const val INDEX_RATING_FORE_UDDER = 25
|
||||||
|
const val INDEX_RATING_REAR_UDDER_HEIGHT = 26
|
||||||
|
const val INDEX_RATING_CENTRAL_LIG = 27
|
||||||
|
const val INDEX_RATING_UDDER_DEPTH = 28
|
||||||
|
const val INDEX_RATING_FRONT_TEAT = 29
|
||||||
|
const val INDEX_RATING_TEAT_LEN = 30
|
||||||
|
const val INDEX_RATING_REAR_TEAT = 31
|
||||||
|
const val INDEX_RATING_LOCOMOTION = 32
|
||||||
|
const val INDEX_RATING_BCS = 33
|
||||||
|
const val INDEX_RATING_HOCK = 34
|
||||||
|
const val INDEX_RATING_BONE = 35
|
||||||
|
const val INDEX_RATING_REAR_UDDER_WIDTH = 36
|
||||||
|
const val INDEX_RATING_TEAT_THICKNESS = 37
|
||||||
|
const val INDEX_RATING_MUSCULARITY = 38
|
||||||
|
const val INDEX_RATING_BODY_COND_COMMENTS = 39
|
||||||
|
const val INDEX_SEGMENTED_IMAGES = 40
|
||||||
|
|
||||||
|
const val TOTAL_COLUMNS = 41
|
||||||
|
|
||||||
|
val HEADER = arrayOf(
|
||||||
|
"ID", "Name", "Species", "Breed", "Sex", "Weight", "Age", "MilkYield",
|
||||||
|
"CalvingNum", "ReproStatus", "Description", "Images", "Video",
|
||||||
|
"OverallRating", "HealthRating", "BreedRating", "Stature", "ChestWidth",
|
||||||
|
"BodyDepth", "Angularity", "RumpAngle", "RumpWidth", "RearLegSet",
|
||||||
|
"RearLegRearView", "FootAngle", "ForeUdderAttachment", "RearUdderHeight",
|
||||||
|
"CentralLigament", "UdderDepth", "FrontTeatPosition", "TeatLength",
|
||||||
|
"RearTeatPosition", "Locomotion", "BodyConditionScore", "HockDevelopment",
|
||||||
|
"BoneStructure", "RearUdderWidth", "TeatThickness", "Muscularity",
|
||||||
|
"BodyConditionComments", "SegmentedImages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.example.livingai.data.local.model
|
||||||
|
|
||||||
|
data class SettingsData(
|
||||||
|
val language: String,
|
||||||
|
val isAutoCaptureOn: Boolean
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.example.livingai.data.manager
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import com.example.livingai.domain.manager.LocalUserManager
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class LocalUserManagerImpl(
|
||||||
|
private val dataStore: DataStore<Preferences>
|
||||||
|
): LocalUserManager {
|
||||||
|
override suspend fun saveAppEntry() {
|
||||||
|
dataStore.edit { settings ->
|
||||||
|
settings[PreferencesKeys.APP_ENTRY] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readAppEntry(): Flow<Boolean> {
|
||||||
|
return dataStore.data.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.APP_ENTRY] ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private object PreferencesKeys {
|
||||||
|
val APP_ENTRY = booleanPreferencesKey(name = Constants.APP_ENTRY)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.example.livingai.data.ml
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.example.livingai.domain.ml.AIModel
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import org.tensorflow.lite.support.common.FileUtil
|
||||||
|
import org.tensorflow.lite.support.image.ImageProcessor
|
||||||
|
import org.tensorflow.lite.support.image.TensorImage
|
||||||
|
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
class AIModelImpl(private val context: Context) : AIModel {
|
||||||
|
|
||||||
|
private val objectDetector: Interpreter
|
||||||
|
private val labels: List<String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Load the TFLite model from assets
|
||||||
|
val modelBuffer = FileUtil.loadMappedFile(context, "efficientdet-lite0.tflite")
|
||||||
|
val options = Interpreter.Options().apply { numThreads = 4 }
|
||||||
|
objectDetector = Interpreter(modelBuffer, options)
|
||||||
|
|
||||||
|
// Load labels from assets
|
||||||
|
labels = try {
|
||||||
|
FileUtil.loadLabels(context, "labels.txt")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun detectObject(bitmap: Bitmap): ObjectDetectionResult? {
|
||||||
|
// Preprocess the image
|
||||||
|
val imageProcessor = ImageProcessor.Builder()
|
||||||
|
.add(ResizeOp(320, 320, ResizeOp.ResizeMethod.BILINEAR))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
var tensorImage = TensorImage.fromBitmap(bitmap)
|
||||||
|
tensorImage = imageProcessor.process(tensorImage)
|
||||||
|
|
||||||
|
// Prepare model inputs and outputs
|
||||||
|
// Based on crash: [1, 25, 4] vs [1, 10, 4]. The model outputs 25 detections, not 10.
|
||||||
|
val locations = Array(1) { Array(25) { FloatArray(4) } }
|
||||||
|
val classes = Array(1) { FloatArray(25) }
|
||||||
|
val scores = Array(1) { FloatArray(25) }
|
||||||
|
val numDetections = FloatArray(1)
|
||||||
|
|
||||||
|
val outputs = mapOf(
|
||||||
|
0 to locations,
|
||||||
|
1 to classes,
|
||||||
|
2 to scores,
|
||||||
|
3 to numDetections
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run inference
|
||||||
|
objectDetector.runForMultipleInputsOutputs(arrayOf(tensorImage.buffer), outputs)
|
||||||
|
|
||||||
|
// Post-process the results
|
||||||
|
val bestDetection = scores[0].withIndex()
|
||||||
|
.maxByOrNull { it.value }
|
||||||
|
?.takeIf { it.value > 0.5f } // Confidence threshold
|
||||||
|
|
||||||
|
if (bestDetection != null) {
|
||||||
|
val index = bestDetection.index
|
||||||
|
val score = bestDetection.value
|
||||||
|
val location = locations[0][index] // [ymin, xmin, ymax, xmax]
|
||||||
|
val labelIndex = classes[0][index].toInt()
|
||||||
|
val label = labels.getOrElse(labelIndex) { "Unknown" }
|
||||||
|
|
||||||
|
// Convert normalized coordinates to absolute pixel values
|
||||||
|
val ymin = location[0] * bitmap.height
|
||||||
|
val xmin = location[1] * bitmap.width
|
||||||
|
val ymax = location[2] * bitmap.height
|
||||||
|
val xmax = location[3] * bitmap.width
|
||||||
|
|
||||||
|
val boundingBox = Rect(xmin.toInt(), ymin.toInt(), xmax.toInt(), ymax.toInt())
|
||||||
|
|
||||||
|
return ObjectDetectionResult(boundingBox, label, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is no longer the primary function, but kept for interface compliance
|
||||||
|
override suspend fun segmentImage(bitmap: Bitmap): Triple<Bitmap, BooleanArray, Rect>? {
|
||||||
|
// Returning null as we are focusing on object detection now
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deriveInference(bitmap: Bitmap): String = "Object Detection"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.example.livingai.data.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import com.example.livingai.domain.ml.DistanceState
|
||||||
|
import com.example.livingai.domain.ml.FrameData
|
||||||
|
import com.example.livingai.domain.ml.Orientation
|
||||||
|
import com.example.livingai.domain.ml.OrientationPixelEstimator
|
||||||
|
import com.example.livingai.domain.ml.OrientationState
|
||||||
|
|
||||||
|
class DistanceEstimatorImpl {
|
||||||
|
|
||||||
|
private val orientationEstimator = OrientationPixelEstimator(iouThreshold = 0.60f)
|
||||||
|
|
||||||
|
fun processFrame(
|
||||||
|
frameData: FrameData,
|
||||||
|
requestedOrientation: Orientation,
|
||||||
|
silhouetteBitmap: Bitmap
|
||||||
|
): OrientationState {
|
||||||
|
|
||||||
|
val segMaskBitmap = frameData.segmentationMaskBitmap
|
||||||
|
?: return OrientationState(
|
||||||
|
success = false,
|
||||||
|
reason = "No segmentation mask",
|
||||||
|
pixelMetrics = null,
|
||||||
|
orientationMatched = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val bbox = frameData.segmentationBox
|
||||||
|
?: return OrientationState(
|
||||||
|
success = false,
|
||||||
|
reason = "No bounding box",
|
||||||
|
pixelMetrics = null,
|
||||||
|
orientationMatched = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = orientationEstimator.analyze(
|
||||||
|
segmentationMaskBitmap = segMaskBitmap,
|
||||||
|
silhouetteBitmap = silhouetteBitmap,
|
||||||
|
bbox = bbox,
|
||||||
|
frameWidth = frameData.imageWidth,
|
||||||
|
frameHeight = frameData.imageHeight,
|
||||||
|
medianDepthMeters = frameData.medianDepth
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrientationState(
|
||||||
|
success = result.orientationMatched,
|
||||||
|
reason = if (result.orientationMatched) "OK" else "Orientation mismatch",
|
||||||
|
pixelMetrics = result.pixelMetrics,
|
||||||
|
orientationMatched = result.orientationMatched,
|
||||||
|
iouScore = result.iouScore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.example.livingai.data.ml
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import org.tensorflow.lite.support.common.FileUtil
|
||||||
|
import org.tensorflow.lite.support.common.ops.NormalizeOp
|
||||||
|
import org.tensorflow.lite.support.image.ImageProcessor
|
||||||
|
import org.tensorflow.lite.support.image.TensorImage
|
||||||
|
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
data class MidasDepthResult(
|
||||||
|
val relativeDepth: Float,
|
||||||
|
val absoluteDistanceMeters: Float?
|
||||||
|
)
|
||||||
|
|
||||||
|
class MidasDepthEstimator(private val context: Context) {
|
||||||
|
|
||||||
|
private var interpreter: Interpreter? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MODEL_NAME = ""
|
||||||
|
private const val INPUT_SIZE = 256
|
||||||
|
|
||||||
|
private val NORM_MEAN = floatArrayOf(123.675f, 116.28f, 103.53f)
|
||||||
|
private val NORM_STD = floatArrayOf(58.395f, 57.12f, 57.375f)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupInterpreter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupInterpreter() {
|
||||||
|
try {
|
||||||
|
val files = context.assets.list("") ?: emptyArray()
|
||||||
|
if (!files.contains(MODEL_NAME)) return
|
||||||
|
|
||||||
|
val model = FileUtil.loadMappedFile(context, MODEL_NAME)
|
||||||
|
interpreter = Interpreter(model, Interpreter.Options().apply { setNumThreads(4) })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun analyzeObject(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
bbox: Rect,
|
||||||
|
realObjectHeightMeters: Float?,
|
||||||
|
focalLengthPixels: Float?
|
||||||
|
): MidasDepthResult? {
|
||||||
|
val interp = interpreter ?: return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Preprocess
|
||||||
|
var tensorImage = TensorImage(org.tensorflow.lite.DataType.FLOAT32)
|
||||||
|
tensorImage.load(bitmap)
|
||||||
|
|
||||||
|
val processor = ImageProcessor.Builder()
|
||||||
|
.add(ResizeOp(INPUT_SIZE, INPUT_SIZE, ResizeOp.ResizeMethod.BILINEAR))
|
||||||
|
.add(NormalizeOp(NORM_MEAN, NORM_STD))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
tensorImage = processor.process(tensorImage)
|
||||||
|
|
||||||
|
// 2. Output Buffer
|
||||||
|
val outShape = interp.getOutputTensor(0).shape()
|
||||||
|
val size = outShape[1] * outShape[2]
|
||||||
|
val output = ByteBuffer.allocateDirect(size * 4).order(ByteOrder.nativeOrder())
|
||||||
|
|
||||||
|
// 3. Run MiDaS
|
||||||
|
interp.run(tensorImage.buffer, output)
|
||||||
|
|
||||||
|
output.rewind()
|
||||||
|
val depthArray = FloatArray(size)
|
||||||
|
output.asFloatBuffer().get(depthArray)
|
||||||
|
|
||||||
|
// Calculate median relative depth (inverse depth) from the BBOX region only?
|
||||||
|
// Usually MiDaS runs on full frame.
|
||||||
|
// If we want depth of the object, we should look at pixels corresponding to the bbox.
|
||||||
|
// But mapping bbox to 256x256 map requires scaling.
|
||||||
|
|
||||||
|
// For now, let's keep it simple: Median of WHOLE FRAME (as relative depth context)
|
||||||
|
// OR median of the center?
|
||||||
|
// The previous implementation used median of whole frame.
|
||||||
|
// Let's refine it: Use median of the whole frame as 'relative depth'
|
||||||
|
// OR if you want object depth, we need to crop.
|
||||||
|
// Given the user wants "relative depth", median of frame is a common proxy for scene depth.
|
||||||
|
// But "distance to object" -> usually means object depth.
|
||||||
|
// Let's sample the center of the bbox in the depth map.
|
||||||
|
|
||||||
|
// Map BBox center to 256x256
|
||||||
|
val cx = bbox.centerX()
|
||||||
|
val cy = bbox.centerY()
|
||||||
|
val mapX = (cx * INPUT_SIZE) / bitmap.width
|
||||||
|
val mapY = (cy * INPUT_SIZE) / bitmap.height
|
||||||
|
|
||||||
|
// Clamp
|
||||||
|
val safeX = mapX.coerceIn(0, INPUT_SIZE - 1)
|
||||||
|
val safeY = mapY.coerceIn(0, INPUT_SIZE - 1)
|
||||||
|
|
||||||
|
val depthIndex = safeY * INPUT_SIZE + safeX
|
||||||
|
val objectRelativeDepth = depthArray[depthIndex]
|
||||||
|
// Note: MiDaS output is inverse depth (disparity).
|
||||||
|
// Higher value = Closer.
|
||||||
|
|
||||||
|
// 4. Absolute Distance (Pinhole)
|
||||||
|
val hPx = bbox.height().toFloat()
|
||||||
|
val absDistance = if (realObjectHeightMeters != null && focalLengthPixels != null && hPx > 0) {
|
||||||
|
(focalLengthPixels * realObjectHeightMeters) / hPx
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return MidasDepthResult(
|
||||||
|
relativeDepth = objectRelativeDepth,
|
||||||
|
absoluteDistanceMeters = absDistance
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept for compatibility if needed, but analyzeObject is the new main entry
|
||||||
|
fun estimateDepth(bitmap: Bitmap): Float? {
|
||||||
|
// Fallback or simpler version
|
||||||
|
return analyzeObject(bitmap, Rect(0,0,bitmap.width, bitmap.height), null, null)?.relativeDepth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.data.ml
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
data class ObjectDetectionResult(
|
||||||
|
val boundingBox: Rect,
|
||||||
|
val label: String,
|
||||||
|
val confidence: Float
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.example.livingai.data.repository
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.example.livingai.domain.model.SettingsData
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class AppDataRepositoryImpl(private val dataStore: DataStore<Preferences>) : AppDataRepository {
|
||||||
|
|
||||||
|
private object PreferencesKeys {
|
||||||
|
val APP_ENTRY = booleanPreferencesKey("app_entry")
|
||||||
|
val LANGUAGE = stringPreferencesKey("language")
|
||||||
|
val IS_AUTO_CAPTURE_ON = booleanPreferencesKey("is_auto_capture_on")
|
||||||
|
val JACCARD_THRESHOLD = floatPreferencesKey("jaccard_threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSettings(): Flow<SettingsData> {
|
||||||
|
return dataStore.data.catch {
|
||||||
|
if (it is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
val language = it[PreferencesKeys.LANGUAGE] ?: "en"
|
||||||
|
val isAutoCaptureOn = it[PreferencesKeys.IS_AUTO_CAPTURE_ON] ?: false
|
||||||
|
val jaccardThreshold = it[PreferencesKeys.JACCARD_THRESHOLD] ?: 50f
|
||||||
|
SettingsData(language, isAutoCaptureOn, jaccardThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveSettings(settings: SettingsData) {
|
||||||
|
dataStore.edit {
|
||||||
|
it[PreferencesKeys.LANGUAGE] = settings.language
|
||||||
|
it[PreferencesKeys.IS_AUTO_CAPTURE_ON] = settings.isAutoCaptureOn
|
||||||
|
it[PreferencesKeys.JACCARD_THRESHOLD] = settings.jaccardThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveAppEntry() {
|
||||||
|
dataStore.edit { settings ->
|
||||||
|
settings[PreferencesKeys.APP_ENTRY] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readAppEntry(): Flow<Boolean> {
|
||||||
|
return dataStore.data.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.APP_ENTRY] ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.example.livingai.data.repository
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.example.livingai.data.local.model.SettingsData
|
||||||
|
import com.example.livingai.domain.repository.SettingsRepository
|
||||||
|
import com.example.livingai.utils.Constants.APP_ENTRY
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class SettingsRepositoryImpl(private val dataStore: DataStore<Preferences>) : SettingsRepository {
|
||||||
|
|
||||||
|
private object PreferencesKeys {
|
||||||
|
val LANGUAGE = stringPreferencesKey("language")
|
||||||
|
val IS_AUTO_CAPTURE_ON = booleanPreferencesKey("is_auto_capture_on")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSettings(): Flow<SettingsData> {
|
||||||
|
return dataStore.data.catch {
|
||||||
|
if (it is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
val language = it[PreferencesKeys.LANGUAGE] ?: "en"
|
||||||
|
val isAutoCaptureOn = it[PreferencesKeys.IS_AUTO_CAPTURE_ON] ?: false
|
||||||
|
SettingsData(language, isAutoCaptureOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveSettings(settings: SettingsData) {
|
||||||
|
dataStore.edit {
|
||||||
|
it[PreferencesKeys.LANGUAGE] = settings.language
|
||||||
|
it[PreferencesKeys.IS_AUTO_CAPTURE_ON] = settings.isAutoCaptureOn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.example.livingai.data.repository.business
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
|
||||||
|
import com.example.livingai.domain.repository.business.DataSource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class AnimalDetailsRepositoryImpl(
|
||||||
|
private val dataSource: DataSource
|
||||||
|
) : AnimalDetailsRepository {
|
||||||
|
override fun getAnimalDetails(id: String): Flow<AnimalDetails?> {
|
||||||
|
return dataSource.getAnimalDetails(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveAnimalDetails(animalDetails: AnimalDetails) {
|
||||||
|
dataSource.setAnimalDetails(animalDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAnimalDetails(id: String) { }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.example.livingai.data.repository.business
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalProfileRepository
|
||||||
|
import com.example.livingai.domain.repository.business.DataSource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class AnimalProfileRepositoryImpl(
|
||||||
|
private val dataSource: DataSource
|
||||||
|
) : AnimalProfileRepository {
|
||||||
|
override fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>> {
|
||||||
|
return dataSource.getAnimalProfiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveAnimalProfile(animalProfile: AnimalProfile) {
|
||||||
|
dataSource.setAnimalProfile(animalProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAnimalProfile(id: String) {
|
||||||
|
dataSource.deleteAnimalProfile(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.example.livingai.data.repository.business
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalRatingRepository
|
||||||
|
import com.example.livingai.domain.repository.business.DataSource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class AnimalRatingRepositoryImpl(
|
||||||
|
private val dataSource: DataSource
|
||||||
|
) : AnimalRatingRepository {
|
||||||
|
override fun getAnimalRating(id: String): Flow<AnimalRating?> {
|
||||||
|
return dataSource.getAnimalRatings(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveAnimalRating(animalRating: AnimalRating) {
|
||||||
|
dataSource.setAnimalRatings(animalRating)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAnimalRating(id: String) {
|
||||||
|
// Same as details, placeholder for now as only full profile delete is requested in DataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
package com.example.livingai.data.repository.media
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import com.example.livingai.data.ml.DistanceEstimatorImpl
|
||||||
|
import com.example.livingai.data.ml.MidasDepthEstimator
|
||||||
|
import com.example.livingai.domain.ml.AIModel
|
||||||
|
import com.example.livingai.domain.ml.FrameMetadataProvider
|
||||||
|
import com.example.livingai.domain.ml.FrameMetadataProvider.toFrameData
|
||||||
|
import com.example.livingai.domain.ml.Orientation
|
||||||
|
import com.example.livingai.domain.ml.OrientationState
|
||||||
|
import com.example.livingai.domain.repository.CameraRepository
|
||||||
|
import com.example.livingai.utils.TiltSensorManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class CameraRepositoryImpl(
|
||||||
|
private val aiModel: AIModel,
|
||||||
|
private val tiltSensorManager: TiltSensorManager,
|
||||||
|
private val context: Context
|
||||||
|
) : CameraRepository {
|
||||||
|
|
||||||
|
private val distanceEstimator = DistanceEstimatorImpl()
|
||||||
|
private val midasEstimator = MidasDepthEstimator(context)
|
||||||
|
|
||||||
|
init {
|
||||||
|
FrameMetadataProvider.aiModel = aiModel
|
||||||
|
FrameMetadataProvider.tiltSensorManager = tiltSensorManager
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun captureImage(imageProxy: ImageProxy): Bitmap =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val rotation = imageProxy.imageInfo.rotationDegrees
|
||||||
|
val bitmap = imageProxy.toBitmap()
|
||||||
|
imageProxy.close()
|
||||||
|
|
||||||
|
if (rotation != 0) {
|
||||||
|
val matrix = Matrix().apply { postRotate(rotation.toFloat()) }
|
||||||
|
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
} else bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processFrame(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
requestedOrientation: Orientation,
|
||||||
|
silhouetteBitmap: Bitmap,
|
||||||
|
realObjectHeightMeters: Float?,
|
||||||
|
focalLengthPixels: Float,
|
||||||
|
boundingBox: Rect?
|
||||||
|
): OrientationState = withContext(Dispatchers.Default) {
|
||||||
|
|
||||||
|
// 1. Collect segmentation
|
||||||
|
// Use the passed boundingBox if available, otherwise it relies on FrameMetadataProvider running segmentation again
|
||||||
|
// But FrameMetadataProvider.collectMetadata runs segmentation internally.
|
||||||
|
// To avoid re-running detection/segmentation if we already have bbox, we can pass it.
|
||||||
|
// However, FrameMetadataProvider currently calls getSegmentation(bitmap) which calls aiModel.segmentImage(bitmap).
|
||||||
|
// AIModel.segmentImage is returning null in current impl.
|
||||||
|
|
||||||
|
// ISSUE: processFrame relies on FrameMetadataProvider.collectMetadata -> getSegmentation -> aiModel.segmentImage
|
||||||
|
// But AIModelImpl.segmentImage returns null!
|
||||||
|
// So bbox will be null, and processFrame returns early with "Segmentation missing".
|
||||||
|
|
||||||
|
// FIX: We need to use the detection result we already have from CameraViewModel.
|
||||||
|
// We will mock the segmentation result using the bounding box from object detection.
|
||||||
|
// And for the mask, since we don't have segmentation, we can either:
|
||||||
|
// a) Create a dummy mask filled within the bbox (simple box mask)
|
||||||
|
// b) Or just proceed if DistanceEstimator can handle it (it needs mask).
|
||||||
|
|
||||||
|
// Let's create a synthetic mask from the bbox.
|
||||||
|
val syntheticMeta = if (boundingBox != null) {
|
||||||
|
// Create a simple mask where pixels inside bbox are true
|
||||||
|
// This is computationally expensive to do full bitmap, so be careful.
|
||||||
|
// But we need a Bitmap mask for DistanceEstimator.
|
||||||
|
// Let's create a black bitmap with white rect.
|
||||||
|
|
||||||
|
// NOTE: This runs on Default dispatcher, so should be okay-ish.
|
||||||
|
|
||||||
|
// However, FrameMetadataProvider.collectMetadata does more (IMU, Depth).
|
||||||
|
// Let's manually construct metadata.
|
||||||
|
|
||||||
|
val maskBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = android.graphics.Canvas(maskBitmap)
|
||||||
|
val paint = android.graphics.Paint().apply { color = android.graphics.Color.WHITE }
|
||||||
|
canvas.drawRect(boundingBox, paint)
|
||||||
|
|
||||||
|
val imu = FrameMetadataProvider.getIMU()
|
||||||
|
val rot = FrameMetadataProvider.getRotation()
|
||||||
|
val depth = FrameMetadataProvider.getDepthData()
|
||||||
|
|
||||||
|
FrameMetadataProvider.FrameCollectedMetadata(
|
||||||
|
segmentationMaskBitmap = maskBitmap,
|
||||||
|
segmentationBox = boundingBox,
|
||||||
|
depthMeters = depth.depthMeters,
|
||||||
|
depthWidth = depth.width,
|
||||||
|
depthHeight = depth.height,
|
||||||
|
depthConfidence = depth.confidence,
|
||||||
|
pitch = imu.pitch,
|
||||||
|
roll = imu.roll,
|
||||||
|
yaw = imu.yaw,
|
||||||
|
rotationDegrees = rot
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FrameMetadataProvider.collectMetadata(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bbox = syntheticMeta.segmentationBox
|
||||||
|
// val mask = syntheticMeta.segmentationMaskBitmap // Mask is used inside distanceEstimator
|
||||||
|
|
||||||
|
if (bbox == null) {
|
||||||
|
return@withContext OrientationState(
|
||||||
|
success = false,
|
||||||
|
reason = "Segmentation missing",
|
||||||
|
pixelMetrics = null,
|
||||||
|
orientationMatched = false,
|
||||||
|
iouScore = null,
|
||||||
|
relativeDepth = null,
|
||||||
|
absoluteDistanceMeters = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MiDaS (relative + absolute if reference height provided)
|
||||||
|
val midasResult = midasEstimator.analyzeObject(
|
||||||
|
bitmap = bitmap,
|
||||||
|
bbox = bbox,
|
||||||
|
realObjectHeightMeters = realObjectHeightMeters,
|
||||||
|
focalLengthPixels = focalLengthPixels
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Build FrameData with relative depth only
|
||||||
|
val frameData = syntheticMeta.toFrameData(bitmap).copy(
|
||||||
|
medianDepth = midasResult?.relativeDepth
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Orientation detection
|
||||||
|
val orientationState = distanceEstimator.processFrame(
|
||||||
|
frameData = frameData,
|
||||||
|
requestedOrientation = requestedOrientation,
|
||||||
|
silhouetteBitmap = silhouetteBitmap
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Inject relative + absolute values into final result
|
||||||
|
orientationState.copy(
|
||||||
|
relativeDepth = midasResult?.relativeDepth,
|
||||||
|
absoluteDistanceMeters = midasResult?.absoluteDistanceMeters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveImage(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
animalId: String,
|
||||||
|
orientation: String?
|
||||||
|
): String = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
|
val suffix = orientation?.let { "_$it" } ?: ""
|
||||||
|
val fileName = "$animalId$suffix.jpg"
|
||||||
|
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/LivingAI/Media/$animalId")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||||
|
?: throw RuntimeException("Failed to insert image")
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
uri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.example.livingai.data.repository.media
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import com.example.livingai.domain.ml.AIModel
|
||||||
|
import com.example.livingai.domain.repository.VideoRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class VideoRepositoryImpl(private val aiModel: AIModel) : VideoRepository {
|
||||||
|
|
||||||
|
private var isRecording = false
|
||||||
|
|
||||||
|
override fun startRecording(onRecordingStarted: () -> Unit) {
|
||||||
|
isRecording = true
|
||||||
|
// Logic to start recording video if needed
|
||||||
|
onRecordingStarted()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopRecording() {
|
||||||
|
isRecording = false
|
||||||
|
// Logic to stop recording
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processFrame(bitmap: Bitmap): String = withContext(Dispatchers.Default) {
|
||||||
|
if (isRecording) {
|
||||||
|
aiModel.deriveInference(bitmap)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
package com.example.livingai.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import androidx.window.layout.WindowMetricsCalculator
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.SvgDecoder
|
||||||
|
import com.example.livingai.data.camera.DefaultCaptureHandler
|
||||||
|
import com.example.livingai.data.camera.DefaultMeasurementCalculator
|
||||||
|
import com.example.livingai.data.camera.DefaultOrientationChecker
|
||||||
|
import com.example.livingai.data.camera.DefaultTiltChecker
|
||||||
|
import com.example.livingai.data.camera.MockPoseAnalyzer
|
||||||
|
import com.example.livingai.data.camera.TFLiteObjectDetector
|
||||||
|
import com.example.livingai.data.local.CSVDataSource
|
||||||
|
import com.example.livingai.data.ml.AIModelImpl
|
||||||
|
import com.example.livingai.data.repository.AppDataRepositoryImpl
|
||||||
|
import com.example.livingai.data.repository.business.AnimalDetailsRepositoryImpl
|
||||||
|
import com.example.livingai.data.repository.business.AnimalProfileRepositoryImpl
|
||||||
|
import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl
|
||||||
|
import com.example.livingai.data.repository.media.CameraRepositoryImpl
|
||||||
|
import com.example.livingai.data.repository.media.VideoRepositoryImpl
|
||||||
|
import com.example.livingai.domain.camera.CaptureHandler
|
||||||
|
import com.example.livingai.domain.camera.MeasurementCalculator
|
||||||
|
import com.example.livingai.domain.camera.OrientationChecker
|
||||||
|
import com.example.livingai.domain.camera.PoseAnalyzer
|
||||||
|
import com.example.livingai.domain.camera.TiltChecker
|
||||||
|
import com.example.livingai.domain.ml.AIModel
|
||||||
|
import com.example.livingai.domain.ml.AnalyzerThresholds
|
||||||
|
import com.example.livingai.domain.ml.FeedbackAnalyzer
|
||||||
|
import com.example.livingai.domain.ml.FeedbackAnalyzerImpl
|
||||||
|
import com.example.livingai.domain.ml.ObjectDetector
|
||||||
|
import com.example.livingai.domain.ml.ObjectDetectorImpl
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import com.example.livingai.domain.repository.CameraRepository
|
||||||
|
import com.example.livingai.domain.repository.VideoRepository
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalProfileRepository
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalRatingRepository
|
||||||
|
import com.example.livingai.domain.repository.business.DataSource
|
||||||
|
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||||
|
import com.example.livingai.domain.usecases.DeleteAnimalProfile
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalDetails
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalProfiles
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalRatings
|
||||||
|
import com.example.livingai.domain.usecases.GetSettingsUseCase
|
||||||
|
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
|
||||||
|
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
|
||||||
|
import com.example.livingai.domain.usecases.ReadAppEntryUseCase
|
||||||
|
import com.example.livingai.domain.usecases.SaveAppEntryUseCase
|
||||||
|
import com.example.livingai.domain.usecases.SaveSettingsUseCase
|
||||||
|
import com.example.livingai.domain.usecases.SetAnimalDetails
|
||||||
|
import com.example.livingai.domain.usecases.SetAnimalRatings
|
||||||
|
import com.example.livingai.pages.addprofile.AddProfileViewModel
|
||||||
|
import com.example.livingai.pages.camera.CameraViewModel
|
||||||
|
import com.example.livingai.pages.camera.VideoViewModel
|
||||||
|
import com.example.livingai.pages.home.HomeViewModel
|
||||||
|
import com.example.livingai.pages.imagepreview.ImagePreviewViewModel
|
||||||
|
import com.example.livingai.pages.listings.ListingsViewModel
|
||||||
|
import com.example.livingai.pages.onboarding.OnBoardingViewModel
|
||||||
|
import com.example.livingai.pages.ratings.RatingViewModel
|
||||||
|
import com.example.livingai.pages.settings.SettingsViewModel
|
||||||
|
import com.example.livingai.pages.videopreview.VideoPreviewViewModel
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
import com.example.livingai.utils.CoroutineDispatchers
|
||||||
|
import com.example.livingai.utils.DefaultCoroutineDispatchers
|
||||||
|
import com.example.livingai.utils.ScreenDimensions
|
||||||
|
import com.example.livingai.utils.SilhouetteManager
|
||||||
|
import com.example.livingai.utils.TiltSensorManager
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.USER_SETTINGS)
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
|
||||||
|
single<DataStore<Preferences>> { androidContext().dataStore }
|
||||||
|
|
||||||
|
single<AppDataRepository> { AppDataRepositoryImpl(get()) }
|
||||||
|
|
||||||
|
single {
|
||||||
|
AppDataUseCases(
|
||||||
|
getSettings = GetSettingsUseCase(get()),
|
||||||
|
saveSettings = SaveSettingsUseCase(get()),
|
||||||
|
readAppEntry = ReadAppEntryUseCase(get()),
|
||||||
|
saveAppEntry = SaveAppEntryUseCase(get())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coroutine dispatchers (for testability)
|
||||||
|
single<CoroutineDispatchers> { DefaultCoroutineDispatchers() }
|
||||||
|
|
||||||
|
// Data Source
|
||||||
|
single<DataSource> {
|
||||||
|
CSVDataSource(
|
||||||
|
context = androidContext(),
|
||||||
|
fileName = Constants.ANIMAL_DATA_FILENAME,
|
||||||
|
dispatchers = get()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coil ImageLoader singleton
|
||||||
|
single {
|
||||||
|
ImageLoader.Builder(androidContext())
|
||||||
|
.components {
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
factory<OrientationChecker> { DefaultOrientationChecker() }
|
||||||
|
factory<TiltChecker> { DefaultTiltChecker() }
|
||||||
|
factory<com.example.livingai.domain.camera.ObjectDetector> { TFLiteObjectDetector(androidContext()) }
|
||||||
|
factory<PoseAnalyzer> { MockPoseAnalyzer() }
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
factory<CaptureHandler> { DefaultCaptureHandler() }
|
||||||
|
factory<MeasurementCalculator> { DefaultMeasurementCalculator() }
|
||||||
|
|
||||||
|
// Initialize silhouettes once
|
||||||
|
single<ScreenDimensions>(createdAtStart = true) {
|
||||||
|
val ctx: Context = androidContext()
|
||||||
|
val metrics = WindowMetricsCalculator.getOrCreate()
|
||||||
|
.computeCurrentWindowMetrics(ctx)
|
||||||
|
|
||||||
|
val bounds = metrics.bounds
|
||||||
|
val screenWidth = bounds.width()
|
||||||
|
val screenHeight = bounds.height()
|
||||||
|
SilhouetteManager.initialize(ctx, screenWidth, screenHeight)
|
||||||
|
ScreenDimensions(screenWidth, screenHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ML Model
|
||||||
|
single<AIModel> { AIModelImpl(androidContext()) }
|
||||||
|
single<ObjectDetector> {
|
||||||
|
ObjectDetectorImpl(
|
||||||
|
context = androidContext(),
|
||||||
|
onResults = { _, _ -> }, // Callback will be set by ViewModel
|
||||||
|
onError = { error -> Log.e("ObjectDetector", "Error: $error") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
single<FeedbackAnalyzer> { FeedbackAnalyzerImpl(AnalyzerThresholds()) }
|
||||||
|
|
||||||
|
single { TiltSensorManager(androidContext()) }
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
|
||||||
|
single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) }
|
||||||
|
single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) }
|
||||||
|
single<CameraRepository> { CameraRepositoryImpl(get(), get(), androidContext()) }
|
||||||
|
single<VideoRepository> { VideoRepositoryImpl(get()) }
|
||||||
|
|
||||||
|
// Use Cases
|
||||||
|
single { GetAnimalProfiles(get()) }
|
||||||
|
single { GetAnimalDetails(get()) }
|
||||||
|
single { GetAnimalRatings(get()) }
|
||||||
|
single { SetAnimalDetails(get()) }
|
||||||
|
single { SetAnimalRatings(get()) }
|
||||||
|
single { DeleteAnimalProfile(get()) }
|
||||||
|
|
||||||
|
//Use Cases
|
||||||
|
single {
|
||||||
|
ProfileEntryUseCase(
|
||||||
|
getAnimalDetails = GetAnimalDetails(get()),
|
||||||
|
setAnimalDetails = SetAnimalDetails(get())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
single {
|
||||||
|
ProfileListingUseCase(
|
||||||
|
getAnimalProfiles = GetAnimalProfiles(get()),
|
||||||
|
deleteAnimalProfile = DeleteAnimalProfile(get())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewModels
|
||||||
|
viewModel { HomeViewModel(get()) }
|
||||||
|
viewModel { OnBoardingViewModel(get()) }
|
||||||
|
viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
|
||||||
|
AddProfileViewModel(get(), get(), savedStateHandle)
|
||||||
|
}
|
||||||
|
viewModel { ListingsViewModel(get()) }
|
||||||
|
viewModel { SettingsViewModel(get()) }
|
||||||
|
viewModel { RatingViewModel(get(), get(), get(), get()) }
|
||||||
|
viewModel { CameraViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
|
viewModel { VideoViewModel(get(), get(), get()) }
|
||||||
|
viewModel { ImagePreviewViewModel() }
|
||||||
|
viewModel { VideoPreviewViewModel() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.di
|
||||||
|
|
||||||
|
import com.example.livingai.data.camera.*
|
||||||
|
import com.example.livingai.domain.camera.*
|
||||||
|
import com.example.livingai.utils.ScreenDimensions
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val cameraModule = module {
|
||||||
|
// Pipeline Steps
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.example.livingai.domain.camera
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import com.example.livingai.domain.model.camera.CameraOrientation
|
||||||
|
import com.example.livingai.domain.model.camera.CaptureData
|
||||||
|
import com.example.livingai.domain.model.camera.DetectionResult
|
||||||
|
import com.example.livingai.domain.model.camera.Instruction
|
||||||
|
import com.example.livingai.domain.model.camera.ObjectMetrics
|
||||||
|
import com.example.livingai.domain.model.camera.ReferenceObject
|
||||||
|
|
||||||
|
interface CameraPipelineStep {
|
||||||
|
/**
|
||||||
|
* Analyzes the current frame (or sensor data) and returns an instruction.
|
||||||
|
*/
|
||||||
|
suspend fun analyze(input: PipelineInput): Instruction
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PipelineInput(
|
||||||
|
val image: Bitmap?,
|
||||||
|
val deviceOrientation: Int, // degrees
|
||||||
|
val deviceRoll: Float,
|
||||||
|
val devicePitch: Float,
|
||||||
|
val deviceAzimuth: Float,
|
||||||
|
val requiredOrientation: CameraOrientation,
|
||||||
|
val screenWidthPx: Float,
|
||||||
|
val screenHeightPx: Float,
|
||||||
|
val targetAnimal: String, // e.g., "Dog", "Cat"
|
||||||
|
val orientation: String, // "front", "back", "side", etc.
|
||||||
|
val previousDetectionResult: DetectionResult? = null // To pass detection result to subsequent steps
|
||||||
|
)
|
||||||
|
|
||||||
|
interface OrientationChecker : CameraPipelineStep
|
||||||
|
interface TiltChecker : CameraPipelineStep
|
||||||
|
interface ObjectDetector : CameraPipelineStep
|
||||||
|
interface PoseAnalyzer : CameraPipelineStep
|
||||||
|
|
||||||
|
interface CaptureHandler {
|
||||||
|
suspend fun capture(input: PipelineInput, detectionResult: DetectionResult): CaptureData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasurementCalculator {
|
||||||
|
/**
|
||||||
|
* Calculates the real world dimensions of the animal based on a known reference object dimension.
|
||||||
|
* @param targetHeight The real height of the reference object provided by the user.
|
||||||
|
* @param referenceObject The reference object selected by the user.
|
||||||
|
* @param currentMetrics The current relative metrics of the animal.
|
||||||
|
* @return The calculated real-world metrics for the animal.
|
||||||
|
*/
|
||||||
|
fun calculateRealMetrics(
|
||||||
|
targetHeight: Float,
|
||||||
|
referenceObject: ReferenceObject,
|
||||||
|
currentMetrics: ObjectMetrics
|
||||||
|
): RealWorldMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RealWorldMetrics(
|
||||||
|
val height: Float,
|
||||||
|
val width: Float,
|
||||||
|
val distance: Float,
|
||||||
|
val unit: String = "cm"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.example.livingai.domain.manager
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface LocalUserManager {
|
||||||
|
suspend fun saveAppEntry()
|
||||||
|
fun readAppEntry(): Flow<Boolean>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.example.livingai.data.ml.ObjectDetectionResult
|
||||||
|
|
||||||
|
interface AIModel {
|
||||||
|
fun deriveInference(bitmap: Bitmap): String
|
||||||
|
suspend fun segmentImage(bitmap: Bitmap): Triple<Bitmap, BooleanArray, Rect>?
|
||||||
|
suspend fun detectObject(bitmap: Bitmap): ObjectDetectionResult?
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class ArcoreDepthEstimator(
|
||||||
|
private val params: ArcoreDepthParams = ArcoreDepthParams()
|
||||||
|
) : DistanceEstimator {
|
||||||
|
|
||||||
|
data class ArcoreDepthParams(
|
||||||
|
val targetDistanceMeters: Float = Constants.TARGET_DISTANCE_METERS,
|
||||||
|
val strictToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_STRICT,
|
||||||
|
val relaxedToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_RELAXED,
|
||||||
|
val depthConfidenceMin: Float = Constants.DEPTH_CONFIDENCE_MIN,
|
||||||
|
val minDepthSamples: Int = Constants.MIN_DEPTH_SAMPLES
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun analyze(frame: FrameData, cameraInfo: CameraInfoData): DistanceState {
|
||||||
|
|
||||||
|
val isTilted = abs(frame.imuPitchDegrees) > Constants.MAX_ACCEPTABLE_PITCH_DEGREES ||
|
||||||
|
abs(frame.imuRollDegrees) > Constants.MAX_ACCEPTABLE_ROLL_DEGREES
|
||||||
|
|
||||||
|
val isRotated = (frame.cameraRotationDegrees % 360) != 0
|
||||||
|
val isOrientationCorrect = !isTilted && !isRotated
|
||||||
|
|
||||||
|
val centered = checkCentered(frame, cameraInfo)
|
||||||
|
|
||||||
|
val depthEstimate = sampleDepthMedian(frame)
|
||||||
|
val fallbackEstimate = computeKnownDimensionEstimate(frame, cameraInfo)
|
||||||
|
|
||||||
|
val fusedDistance = when {
|
||||||
|
depthEstimate != null ->
|
||||||
|
Constants.WEIGHT_ARCORE * depthEstimate +
|
||||||
|
Constants.WEIGHT_KNOWN_DIM * (fallbackEstimate ?: depthEstimate)
|
||||||
|
|
||||||
|
fallbackEstimate != null -> fallbackEstimate
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val (recommendation, ready, conf) =
|
||||||
|
evaluateDistanceAndReadiness(fusedDistance, centered, isOrientationCorrect)
|
||||||
|
|
||||||
|
return DistanceState(
|
||||||
|
distanceMeters = fusedDistance,
|
||||||
|
recommendation = recommendation,
|
||||||
|
isCameraTilted = isTilted,
|
||||||
|
isCameraRotated = isRotated,
|
||||||
|
isOrientationCorrect = isOrientationCorrect,
|
||||||
|
isObjectCentered = centered,
|
||||||
|
readyToCapture = ready,
|
||||||
|
confidenceScore = conf
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleDepthMedian(frame: FrameData): Float? {
|
||||||
|
val depth = frame.depthMapMeters ?: return null
|
||||||
|
val w = frame.depthWidth
|
||||||
|
val h = frame.depthHeight
|
||||||
|
if (w <= 0 || h <= 0) return null
|
||||||
|
|
||||||
|
val box = frame.segmentationBox ?: return null
|
||||||
|
|
||||||
|
val left = max(0, box.left)
|
||||||
|
val top = max(0, box.top)
|
||||||
|
val right = min(w - 1, box.right)
|
||||||
|
val bottom = min(h - 1, box.bottom)
|
||||||
|
|
||||||
|
val conf = frame.depthConfidence
|
||||||
|
val samples = ArrayList<Float>()
|
||||||
|
|
||||||
|
for (y in top..bottom) {
|
||||||
|
for (x in left..right) {
|
||||||
|
val idx = y * w + x
|
||||||
|
val d = depth[idx]
|
||||||
|
if (d <= 0f || d.isNaN()) continue
|
||||||
|
|
||||||
|
if (conf != null && conf[idx] < params.depthConfidenceMin)
|
||||||
|
continue
|
||||||
|
|
||||||
|
samples.add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (samples.size < params.minDepthSamples) return null
|
||||||
|
samples.sort()
|
||||||
|
return samples[samples.size / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeKnownDimensionEstimate(
|
||||||
|
frame: FrameData,
|
||||||
|
cameraInfo: CameraInfoData
|
||||||
|
): Float? {
|
||||||
|
val box = frame.segmentationBox ?: return null
|
||||||
|
val hPixels = box.height().toFloat()
|
||||||
|
if (hPixels <= 1f) return null
|
||||||
|
|
||||||
|
val f = cameraInfo.focalLengthPixels
|
||||||
|
val Hreal = Constants.DEFAULT_OBJECT_REAL_HEIGHT_METERS
|
||||||
|
|
||||||
|
return (f * Hreal) / hPixels
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateDistanceAndReadiness(
|
||||||
|
distMeters: Float?,
|
||||||
|
centered: Boolean,
|
||||||
|
orientationOk: Boolean
|
||||||
|
): Triple<DistanceRecommendation, Boolean, Float> {
|
||||||
|
|
||||||
|
if (distMeters == null)
|
||||||
|
return Triple(DistanceRecommendation.DISTANCE_UNKNOWN, false, 0f)
|
||||||
|
|
||||||
|
val diff = distMeters - params.targetDistanceMeters
|
||||||
|
val absDiff = abs(diff)
|
||||||
|
|
||||||
|
val withinStrict = absDiff <= params.strictToleranceMeters
|
||||||
|
val withinRelaxed = absDiff <= params.relaxedToleranceMeters
|
||||||
|
|
||||||
|
val recommendation = when {
|
||||||
|
withinStrict -> DistanceRecommendation.AT_OPTIMAL_DISTANCE
|
||||||
|
diff > 0f -> DistanceRecommendation.MOVE_CLOSER
|
||||||
|
else -> DistanceRecommendation.MOVE_AWAY
|
||||||
|
}
|
||||||
|
|
||||||
|
val ready = withinRelaxed && centered && orientationOk
|
||||||
|
|
||||||
|
val closenessScore = 1f - (absDiff / (params.relaxedToleranceMeters * 4f))
|
||||||
|
val confidence = closenessScore * 0.8f +
|
||||||
|
(if (centered && orientationOk) 0.2f else 0f)
|
||||||
|
|
||||||
|
return Triple(recommendation, ready, confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkCentered(frame: FrameData, cameraInfo: CameraInfoData): Boolean {
|
||||||
|
val box = frame.segmentationBox ?: return false
|
||||||
|
val imgW = cameraInfo.sensorWidthPx
|
||||||
|
val imgH = cameraInfo.sensorHeightPx
|
||||||
|
|
||||||
|
val cxObj = (box.left + box.right) / 2f
|
||||||
|
val cyObj = (box.top + box.bottom) / 2f
|
||||||
|
val cx = imgW / 2f
|
||||||
|
val cy = imgH / 2f
|
||||||
|
|
||||||
|
val dx = abs(cxObj - cx) / imgW
|
||||||
|
val dy = abs(cyObj - cy) / imgH
|
||||||
|
|
||||||
|
return dx <= Constants.CENTER_TOLERANCE_X_FRACTION &&
|
||||||
|
dy <= Constants.CENTER_TOLERANCE_Y_FRACTION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton provider of camera intrinsic data.
|
||||||
|
* Must be initialized once per session.
|
||||||
|
*/
|
||||||
|
object CameraInfoProvider {
|
||||||
|
@Volatile
|
||||||
|
private var cameraInfoData: CameraInfoData? = null
|
||||||
|
|
||||||
|
fun init(info: CameraInfoData) {
|
||||||
|
cameraInfoData = info
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(): CameraInfoData {
|
||||||
|
return cameraInfoData
|
||||||
|
?: throw IllegalStateException("CameraInfoProvider not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryGet(): CameraInfoData? = cameraInfoData
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.hardware.camera2.CameraCharacteristics
|
||||||
|
import android.hardware.camera2.CameraManager
|
||||||
|
import android.util.Size
|
||||||
|
import android.util.SizeF
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to read camera intrinsics from Camera2 and compute focal length (pixels).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* val (fPx, imgW, imgH) = CameraIntrinsicsFetcher.fetch(context, cameraId, imageSize)
|
||||||
|
* CameraInfoProvider.init(CameraInfoData(fPx, imgW, imgH, px, py, ...))
|
||||||
|
*
|
||||||
|
* imageSize = the resolution you will actually receive from the ImageReader / CameraX output (width,height)
|
||||||
|
*
|
||||||
|
* Formula:
|
||||||
|
* f_px = f_mm / sensorWidth_mm * imageWidth_px
|
||||||
|
*
|
||||||
|
* More accurate: use activeArray size mapping to sensor physical size if needed.
|
||||||
|
*/
|
||||||
|
object CameraIntrinsicsFetcher {
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val focalLengthPixels: Float,
|
||||||
|
val imageWidthPx: Int,
|
||||||
|
val imageHeightPx: Int,
|
||||||
|
val principalPointX: Float,
|
||||||
|
val principalPointY: Float,
|
||||||
|
val sensorPhysicalSizeMm: SizeF?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cameraId = device camera id (get from CameraManager)
|
||||||
|
* imageSize = the actual output image size you will capture (e.g., 1920x1080)
|
||||||
|
*/
|
||||||
|
fun fetch(context: Context, cameraId: String, imageSize: Size): Result {
|
||||||
|
val mgr = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||||
|
val characteristics = mgr.getCameraCharacteristics(cameraId)
|
||||||
|
|
||||||
|
val focalLengths = characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||||
|
val fMm = when {
|
||||||
|
focalLengths != null && focalLengths.isNotEmpty() -> focalLengths[0] // mm
|
||||||
|
else -> 4.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
val sensorSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) // in mm
|
||||||
|
val sensorSizeMm = sensorSize
|
||||||
|
|
||||||
|
// active array size gives pixel array cropping of sensor -> map principal point
|
||||||
|
val activeRect = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) // Rect
|
||||||
|
val activeRectW = activeRect?.width() ?: imageSize.width
|
||||||
|
val activeRectH = activeRect?.height() ?: imageSize.height
|
||||||
|
|
||||||
|
// Compute focal in pixels: ratio f_mm / sensorWidth_mm * imageWidth_px
|
||||||
|
val fPx = if (sensorSizeMm != null && sensorSizeMm.width > 0f) {
|
||||||
|
(fMm / sensorSizeMm.width) * imageSize.width
|
||||||
|
} else {
|
||||||
|
// fallback: estimate based on sensor pixel array
|
||||||
|
(fMm / 4.0f) * imageSize.width
|
||||||
|
}
|
||||||
|
|
||||||
|
val principalX = (activeRect?.centerX() ?: imageSize.width / 2).toFloat()
|
||||||
|
val principalY = (activeRect?.centerY() ?: imageSize.height / 2).toFloat()
|
||||||
|
|
||||||
|
return Result(
|
||||||
|
focalLengthPixels = fPx,
|
||||||
|
imageWidthPx = imageSize.width,
|
||||||
|
imageHeightPx = imageSize.height,
|
||||||
|
principalPointX = principalX,
|
||||||
|
principalPointY = principalY,
|
||||||
|
sensorPhysicalSizeMm = sensorSizeMm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for all distance estimators.
|
||||||
|
*/
|
||||||
|
interface DistanceEstimator {
|
||||||
|
fun analyze(
|
||||||
|
frame: FrameData,
|
||||||
|
cameraInfo: CameraInfoData
|
||||||
|
): DistanceState
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameData is defined in FrameData.kt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-provided camera intrinsics for metric calculations.
|
||||||
|
*/
|
||||||
|
data class CameraInfoData(
|
||||||
|
val focalLengthPixels: Float, // fx in pixels
|
||||||
|
val sensorWidthPx: Int,
|
||||||
|
val sensorHeightPx: Int,
|
||||||
|
val principalPointX: Float,
|
||||||
|
val principalPointY: Float,
|
||||||
|
val distortionCoeffs: FloatArray? = null,
|
||||||
|
val cameraModel: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output state describing computed distance and capture readiness.
|
||||||
|
*/
|
||||||
|
data class DistanceState(
|
||||||
|
val distanceMeters: Float?,
|
||||||
|
val recommendation: DistanceRecommendation,
|
||||||
|
|
||||||
|
val isCameraTilted: Boolean,
|
||||||
|
val isCameraRotated: Boolean,
|
||||||
|
val isOrientationCorrect: Boolean,
|
||||||
|
val isObjectCentered: Boolean,
|
||||||
|
|
||||||
|
val readyToCapture: Boolean,
|
||||||
|
val confidenceScore: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DistanceRecommendation {
|
||||||
|
MOVE_CLOSER,
|
||||||
|
MOVE_AWAY,
|
||||||
|
AT_OPTIMAL_DISTANCE,
|
||||||
|
DISTANCE_UNKNOWN
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// CONFIG CLASS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
data class AnalyzerThresholds(
|
||||||
|
val toleranceRatio: Float = 0.02f,
|
||||||
|
|
||||||
|
// Height estimation
|
||||||
|
val minTargetHeightMeters: Float = 0.60f,
|
||||||
|
val maxTargetHeightMeters: Float = 0.70f,
|
||||||
|
|
||||||
|
// Real physical height of subject
|
||||||
|
val subjectRealHeightMeters: Float = 1.55f
|
||||||
|
)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// STATES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
sealed class FeedbackState(val message: String) {
|
||||||
|
object Idle : FeedbackState("")
|
||||||
|
object Searching : FeedbackState("Searching for subject...")
|
||||||
|
object TooFar : FeedbackState("Move closer")
|
||||||
|
object TooClose : FeedbackState("Move back")
|
||||||
|
object TooLow : FeedbackState("Raise phone")
|
||||||
|
object TooHigh : FeedbackState("Lower phone")
|
||||||
|
object PhoneTooLow : FeedbackState("Raise phone to 60 to 70 cm from ground")
|
||||||
|
object PhoneTooHigh : FeedbackState("Lower phone to 60 to 70 cm from ground")
|
||||||
|
object Optimal : FeedbackState("Hold still")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// ANALYZER INTERFACE
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
interface FeedbackAnalyzer {
|
||||||
|
fun analyze(
|
||||||
|
detection: ObjectDetector.DetectionResult?,
|
||||||
|
frameWidth: Int,
|
||||||
|
frameHeight: Int,
|
||||||
|
screenHeight: Int,
|
||||||
|
tiltDegrees: Float,
|
||||||
|
focalLengthPx: Float
|
||||||
|
): FeedbackState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// IMPLEMENTATION
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
class FeedbackAnalyzerImpl(
|
||||||
|
private val thresholds: AnalyzerThresholds
|
||||||
|
) : FeedbackAnalyzer {
|
||||||
|
|
||||||
|
override fun analyze(
|
||||||
|
detection: ObjectDetector.DetectionResult?,
|
||||||
|
frameWidth: Int,
|
||||||
|
frameHeight: Int,
|
||||||
|
screenHeight: Int,
|
||||||
|
tiltDegrees: Float,
|
||||||
|
focalLengthPx: Float
|
||||||
|
): FeedbackState {
|
||||||
|
|
||||||
|
if (detection == null) return FeedbackState.Searching
|
||||||
|
if (frameWidth <= 0 || frameHeight <= 0) return FeedbackState.Idle
|
||||||
|
|
||||||
|
val pc = Precomputed(
|
||||||
|
detection.boundingBox,
|
||||||
|
frameWidth,
|
||||||
|
frameHeight,
|
||||||
|
screenHeight,
|
||||||
|
thresholds
|
||||||
|
)
|
||||||
|
|
||||||
|
val cameraHeight = estimateCameraHeight(pc.detectionHeight, tiltDegrees, focalLengthPx)
|
||||||
|
Log.d("FeedbackAnalyzerImpl", "Camera Height: $cameraHeight")
|
||||||
|
return when {
|
||||||
|
// ORDER MATTERS — evaluate alignment first
|
||||||
|
isTooHigh(pc) -> FeedbackState.TooHigh
|
||||||
|
isTooLow(pc) -> FeedbackState.TooLow
|
||||||
|
isTooClose(pc) -> FeedbackState.TooClose
|
||||||
|
isTooFar(pc) -> FeedbackState.TooFar
|
||||||
|
|
||||||
|
// Height estimation last
|
||||||
|
isHeightTooLow(cameraHeight) -> FeedbackState.PhoneTooLow
|
||||||
|
isHeightTooHigh(cameraHeight) -> FeedbackState.PhoneTooHigh
|
||||||
|
|
||||||
|
else -> FeedbackState.Optimal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTooLow(pc: Precomputed): Boolean =
|
||||||
|
pc.isBottomInside && !pc.isTopInside && ((pc.frameTop - pc.detectionTop) > pc.tolerance)
|
||||||
|
|
||||||
|
private fun isTooHigh(pc: Precomputed): Boolean =
|
||||||
|
!pc.isBottomInside && pc.isTopInside && ((pc.detectionBottom - pc.frameBottom) > pc.tolerance)
|
||||||
|
|
||||||
|
// OBJECT TOO CLOSE (bigger than allowed)
|
||||||
|
private fun isTooClose(pc: Precomputed): Boolean =
|
||||||
|
!pc.isTopInside && !pc.isBottomInside && ((pc.detectionHeight - pc.frameHeight) > pc.tolerance)
|
||||||
|
|
||||||
|
// OBJECT TOO FAR (too small)
|
||||||
|
private fun isTooFar(pc: Precomputed): Boolean =
|
||||||
|
pc.isTopInside && pc.isBottomInside &&
|
||||||
|
((pc.frameHeight - pc.detectionHeight) > pc.tolerance)
|
||||||
|
|
||||||
|
private fun isHeightTooLow(heightMeters: Float): Boolean =
|
||||||
|
heightMeters > 0 &&
|
||||||
|
(thresholds.minTargetHeightMeters > heightMeters)
|
||||||
|
|
||||||
|
private fun isHeightTooHigh(heightMeters: Float): Boolean =
|
||||||
|
heightMeters > (thresholds.maxTargetHeightMeters)
|
||||||
|
|
||||||
|
private fun estimateCameraHeight(
|
||||||
|
pixelHeight: Float,
|
||||||
|
tiltDegrees: Float,
|
||||||
|
focalLengthPx: Float
|
||||||
|
): Float {
|
||||||
|
val tiltRad = Math.toRadians(tiltDegrees.toDouble())
|
||||||
|
|
||||||
|
val realHeight = thresholds.subjectRealHeightMeters
|
||||||
|
if (pixelHeight <= 0f || focalLengthPx <= 0f) return -1f
|
||||||
|
|
||||||
|
val distance = (realHeight * focalLengthPx) / pixelHeight
|
||||||
|
Log.d("FeedbackAnalyzerImpl", "Distance: $distance")
|
||||||
|
return (distance * sin(tiltRad)).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Precomputed(
|
||||||
|
val box: RectF,
|
||||||
|
val frameWidth: Int,
|
||||||
|
val frameHeight: Int,
|
||||||
|
val screenHeight: Int,
|
||||||
|
val t: AnalyzerThresholds
|
||||||
|
) {
|
||||||
|
private val modelFrameHeight = Constants.MODEL_HEIGHT
|
||||||
|
private val scaleSlip = ((screenHeight - modelFrameHeight) * screenHeight) / (2F * modelFrameHeight)
|
||||||
|
val detectionTop = (box.top * screenHeight / modelFrameHeight) - scaleSlip
|
||||||
|
val detectionBottom = (box.bottom * screenHeight / modelFrameHeight) - scaleSlip
|
||||||
|
val detectionHeight = max(0f, detectionBottom - detectionTop)
|
||||||
|
|
||||||
|
// Frame centered vertically
|
||||||
|
val frameTop = (screenHeight - frameHeight) / 2f
|
||||||
|
val frameBottom = frameTop + frameHeight
|
||||||
|
|
||||||
|
val tolerance = t.toleranceRatio * screenHeight
|
||||||
|
|
||||||
|
// Inside checks with tolerance
|
||||||
|
val isTopInside = detectionTop >= frameTop
|
||||||
|
val isBottomInside = detectionBottom <= frameBottom
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame-specific data for one inference cycle.
|
||||||
|
*/
|
||||||
|
data class FrameData(
|
||||||
|
val imageBitmap: Bitmap?,
|
||||||
|
val segmentationBox: Rect?,
|
||||||
|
val segmentationMaskBitmap: Bitmap?,
|
||||||
|
|
||||||
|
// Optional ARCore depth inputs
|
||||||
|
val depthMapMeters: FloatArray?, // row-major R* C
|
||||||
|
val depthWidth: Int = 0,
|
||||||
|
val depthHeight: Int = 0,
|
||||||
|
val depthConfidence: FloatArray? = null,
|
||||||
|
|
||||||
|
// IMU orientation
|
||||||
|
val imuPitchDegrees: Float = 0f,
|
||||||
|
val imuRollDegrees: Float = 0f,
|
||||||
|
val imuYawDegrees: Float = 0f,
|
||||||
|
|
||||||
|
val cameraRotationDegrees: Int = 0,
|
||||||
|
val timestampMs: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
//relative
|
||||||
|
val imageWidth: Int = 0,
|
||||||
|
val imageHeight: Int = 0,
|
||||||
|
val medianDepth: Float? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.example.livingai.utils.TiltSensorManager
|
||||||
|
|
||||||
|
object FrameMetadataProvider {
|
||||||
|
|
||||||
|
lateinit var aiModel: AIModel // injected once from AppModule
|
||||||
|
var tiltSensorManager: TiltSensorManager? = null
|
||||||
|
|
||||||
|
// External data sources
|
||||||
|
var latestDepthResult: DepthResult? = null
|
||||||
|
var deviceRotation: Int = 0
|
||||||
|
|
||||||
|
suspend fun getSegmentation(bitmap: Bitmap): SegmentationResult? {
|
||||||
|
return try {
|
||||||
|
val (maskBitmap, booleanMask, bbox) = aiModel.segmentImage(bitmap) ?: return null
|
||||||
|
SegmentationResult(maskBitmap, booleanMask, bbox)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SegmentationResult(
|
||||||
|
val maskBitmap: Bitmap?,
|
||||||
|
val mask: BooleanArray,
|
||||||
|
val boundingBox: Rect
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as SegmentationResult
|
||||||
|
|
||||||
|
if (maskBitmap != other.maskBitmap) return false
|
||||||
|
if (!mask.contentEquals(other.mask)) return false
|
||||||
|
if (boundingBox != other.boundingBox) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = maskBitmap?.hashCode() ?: 0
|
||||||
|
result = 31 * result + mask.contentHashCode()
|
||||||
|
result = 31 * result + boundingBox.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDepthData(): DepthResult {
|
||||||
|
return latestDepthResult ?: DepthResult(null, 0, 0, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DepthResult(
|
||||||
|
val depthMeters: FloatArray?,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val confidence: FloatArray?
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as DepthResult
|
||||||
|
|
||||||
|
if (depthMeters != null) {
|
||||||
|
if (other.depthMeters == null) return false
|
||||||
|
if (!depthMeters.contentEquals(other.depthMeters)) return false
|
||||||
|
} else if (other.depthMeters != null) return false
|
||||||
|
if (width != other.width) return false
|
||||||
|
if (height != other.height) return false
|
||||||
|
if (confidence != null) {
|
||||||
|
if (other.confidence == null) return false
|
||||||
|
if (!confidence.contentEquals(other.confidence)) return false
|
||||||
|
} else if (other.confidence != null) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = depthMeters?.contentHashCode() ?: 0
|
||||||
|
result = 31 * result + width
|
||||||
|
result = 31 * result + height
|
||||||
|
result = 31 * result + (confidence?.contentHashCode() ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getIMU(): IMUResult {
|
||||||
|
val (pitch, roll, yaw) = tiltSensorManager?.tilt?.value ?: Triple(0f, 0f, 0f)
|
||||||
|
return IMUResult(pitch, roll, yaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class IMUResult(val pitch: Float, val roll: Float, val yaw: Float)
|
||||||
|
|
||||||
|
fun getRotation(): Int {
|
||||||
|
return deviceRotation
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FrameCollectedMetadata(
|
||||||
|
val segmentationMaskBitmap: Bitmap?,
|
||||||
|
val segmentationBox: Rect?,
|
||||||
|
val depthMeters: FloatArray?,
|
||||||
|
val depthWidth: Int,
|
||||||
|
val depthHeight: Int,
|
||||||
|
val depthConfidence: FloatArray?,
|
||||||
|
val pitch: Float,
|
||||||
|
val roll: Float,
|
||||||
|
val yaw: Float,
|
||||||
|
val rotationDegrees: Int
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as FrameCollectedMetadata
|
||||||
|
|
||||||
|
if (segmentationMaskBitmap != other.segmentationMaskBitmap) return false
|
||||||
|
if (segmentationBox != other.segmentationBox) return false
|
||||||
|
if (depthMeters != null) {
|
||||||
|
if (other.depthMeters == null) return false
|
||||||
|
if (!depthMeters.contentEquals(other.depthMeters)) return false
|
||||||
|
} else if (other.depthMeters != null) return false
|
||||||
|
if (depthWidth != other.depthWidth) return false
|
||||||
|
if (depthHeight != other.depthHeight) return false
|
||||||
|
if (depthConfidence != null) {
|
||||||
|
if (other.depthConfidence == null) return false
|
||||||
|
if (!depthConfidence.contentEquals(other.depthConfidence)) return false
|
||||||
|
} else if (other.depthConfidence != null) return false
|
||||||
|
if (pitch != other.pitch) return false
|
||||||
|
if (roll != other.roll) return false
|
||||||
|
if (yaw != other.yaw) return false
|
||||||
|
if (rotationDegrees != other.rotationDegrees) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = segmentationMaskBitmap?.hashCode() ?: 0
|
||||||
|
result = 31 * result + (segmentationBox?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (depthMeters?.contentHashCode() ?: 0)
|
||||||
|
result = 31 * result + depthWidth
|
||||||
|
result = 31 * result + depthHeight
|
||||||
|
result = 31 * result + (depthConfidence?.contentHashCode() ?: 0)
|
||||||
|
result = 31 * result + pitch.hashCode()
|
||||||
|
result = 31 * result + roll.hashCode()
|
||||||
|
result = 31 * result + yaw.hashCode()
|
||||||
|
result = 31 * result + rotationDegrees
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun collectMetadata(bitmap: Bitmap): FrameCollectedMetadata {
|
||||||
|
val seg = getSegmentation(bitmap)
|
||||||
|
val depth = getDepthData()
|
||||||
|
val imu = getIMU()
|
||||||
|
val rot = getRotation()
|
||||||
|
|
||||||
|
return FrameCollectedMetadata(
|
||||||
|
segmentationMaskBitmap = seg?.maskBitmap,
|
||||||
|
segmentationBox = seg?.boundingBox,
|
||||||
|
depthMeters = depth.depthMeters,
|
||||||
|
depthWidth = depth.width,
|
||||||
|
depthHeight = depth.height,
|
||||||
|
depthConfidence = depth.confidence,
|
||||||
|
pitch = imu.pitch,
|
||||||
|
roll = imu.roll,
|
||||||
|
yaw = imu.yaw,
|
||||||
|
rotationDegrees = rot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun FrameCollectedMetadata.toFrameData(bitmap: Bitmap): FrameData {
|
||||||
|
return FrameData(
|
||||||
|
imageBitmap = bitmap,
|
||||||
|
segmentationBox = segmentationBox,
|
||||||
|
segmentationMaskBitmap = segmentationMaskBitmap,
|
||||||
|
depthMapMeters = depthMeters,
|
||||||
|
depthWidth = depthWidth,
|
||||||
|
depthHeight = depthHeight,
|
||||||
|
depthConfidence = depthConfidence,
|
||||||
|
imuPitchDegrees = pitch,
|
||||||
|
imuRollDegrees = roll,
|
||||||
|
imuYawDegrees = yaw,
|
||||||
|
cameraRotationDegrees = rotationDegrees,
|
||||||
|
|
||||||
|
// New fields populated from bitmap if available or passed down
|
||||||
|
imageWidth = bitmap.width,
|
||||||
|
imageHeight = bitmap.height,
|
||||||
|
medianDepth = null // Can calculate median from depthMeters if needed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class KnownDimensionEstimator(
|
||||||
|
private val params: KnownDimensionParams = KnownDimensionParams()
|
||||||
|
) : DistanceEstimator {
|
||||||
|
|
||||||
|
data class KnownDimensionParams(
|
||||||
|
val knownObjectHeightMeters: Float = Constants.DEFAULT_OBJECT_REAL_HEIGHT_METERS,
|
||||||
|
val targetDistanceMeters: Float = Constants.TARGET_DISTANCE_METERS,
|
||||||
|
val strictToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_STRICT,
|
||||||
|
val relaxedToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_RELAXED
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun analyze(frame: FrameData, cameraInfo: CameraInfoData): DistanceState {
|
||||||
|
|
||||||
|
val tilted = abs(frame.imuPitchDegrees) > Constants.MAX_ACCEPTABLE_PITCH_DEGREES ||
|
||||||
|
abs(frame.imuRollDegrees) > Constants.MAX_ACCEPTABLE_ROLL_DEGREES
|
||||||
|
|
||||||
|
val rotated = (frame.cameraRotationDegrees % 360) != 0
|
||||||
|
val orientationOk = !tilted && !rotated
|
||||||
|
val centered = checkCentered(frame, cameraInfo)
|
||||||
|
|
||||||
|
val distEstimate = computeDistance(frame, cameraInfo)
|
||||||
|
|
||||||
|
val (recommendation, ready, conf) =
|
||||||
|
evaluateDistanceAndReadiness(distEstimate, centered, orientationOk)
|
||||||
|
|
||||||
|
return DistanceState(
|
||||||
|
distanceMeters = distEstimate,
|
||||||
|
recommendation = recommendation,
|
||||||
|
isCameraTilted = tilted,
|
||||||
|
isCameraRotated = rotated,
|
||||||
|
isOrientationCorrect = orientationOk,
|
||||||
|
isObjectCentered = centered,
|
||||||
|
readyToCapture = ready,
|
||||||
|
confidenceScore = conf
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeDistance(
|
||||||
|
frame: FrameData,
|
||||||
|
cameraInfo: CameraInfoData
|
||||||
|
): Float? {
|
||||||
|
val box = frame.segmentationBox ?: return null
|
||||||
|
val hPx = box.height().toFloat()
|
||||||
|
if (hPx <= 1f) return null
|
||||||
|
|
||||||
|
val f = cameraInfo.focalLengthPixels
|
||||||
|
return (f * params.knownObjectHeightMeters) / hPx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateDistanceAndReadiness(
|
||||||
|
distMeters: Float?,
|
||||||
|
centered: Boolean,
|
||||||
|
orientationOk: Boolean
|
||||||
|
): Triple<DistanceRecommendation, Boolean, Float> {
|
||||||
|
|
||||||
|
if (distMeters == null)
|
||||||
|
return Triple(DistanceRecommendation.DISTANCE_UNKNOWN, false, 0f)
|
||||||
|
|
||||||
|
val diff = distMeters - params.targetDistanceMeters
|
||||||
|
val absDiff = abs(diff)
|
||||||
|
|
||||||
|
val withinStrict = absDiff <= params.strictToleranceMeters
|
||||||
|
val withinRelaxed = absDiff <= params.relaxedToleranceMeters
|
||||||
|
|
||||||
|
val recommendation = when {
|
||||||
|
withinStrict -> DistanceRecommendation.AT_OPTIMAL_DISTANCE
|
||||||
|
diff > 0f -> DistanceRecommendation.MOVE_CLOSER
|
||||||
|
else -> DistanceRecommendation.MOVE_AWAY
|
||||||
|
}
|
||||||
|
|
||||||
|
val ready = withinRelaxed && centered && orientationOk
|
||||||
|
|
||||||
|
val closenessScore = 1f - min(1f, absDiff / (params.relaxedToleranceMeters * 4f))
|
||||||
|
val conf = closenessScore * 0.9f +
|
||||||
|
(if (centered && orientationOk) 0.1f else 0f)
|
||||||
|
|
||||||
|
return Triple(recommendation, ready, conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkCentered(frame: FrameData, cameraInfo: CameraInfoData): Boolean {
|
||||||
|
val box = frame.segmentationBox ?: return false
|
||||||
|
val imgW = cameraInfo.sensorWidthPx
|
||||||
|
val imgH = cameraInfo.sensorHeightPx
|
||||||
|
|
||||||
|
val objCx = (box.left + box.right) / 2f
|
||||||
|
val objCy = (box.top + box.bottom) / 2f
|
||||||
|
|
||||||
|
val cx = imgW / 2f
|
||||||
|
val cy = imgH / 2f
|
||||||
|
|
||||||
|
val dx = abs(objCx - cx) / imgW
|
||||||
|
val dy = abs(objCy - cy) / imgH
|
||||||
|
|
||||||
|
return dx <= Constants.CENTER_TOLERANCE_X_FRACTION &&
|
||||||
|
dy <= Constants.CENTER_TOLERANCE_Y_FRACTION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
|
||||||
|
interface ObjectDetector {
|
||||||
|
var onResults: (List<DetectionResult>, Long) -> Unit
|
||||||
|
var onError: (String) -> Unit
|
||||||
|
fun detect(bitmap: Bitmap, imageRotation: Int)
|
||||||
|
|
||||||
|
data class DetectionResult(val boundingBox: RectF, val text: String, val score: Float)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
import org.tensorflow.lite.DataType
|
||||||
|
import org.tensorflow.lite.Interpreter
|
||||||
|
import org.tensorflow.lite.support.image.ImageProcessor
|
||||||
|
import org.tensorflow.lite.support.image.TensorImage
|
||||||
|
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||||
|
import org.tensorflow.lite.support.image.ops.Rot90Op
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
|
||||||
|
class ObjectDetectorImpl(
|
||||||
|
private val context: Context,
|
||||||
|
override var onResults: (List<ObjectDetector.DetectionResult>, Long) -> Unit,
|
||||||
|
override var onError: (String) -> Unit
|
||||||
|
) : ObjectDetector {
|
||||||
|
|
||||||
|
private var interpreter: Interpreter? = null
|
||||||
|
private val modelName = "efficientdet-lite0.tflite"
|
||||||
|
private val inputSize = 320 // EfficientDet-Lite0 expects 320x320
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupInterpreter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupInterpreter() {
|
||||||
|
try {
|
||||||
|
val assetFileDescriptor = context.assets.openFd(modelName)
|
||||||
|
val fileInputStream = FileInputStream(assetFileDescriptor.fileDescriptor)
|
||||||
|
val fileChannel = fileInputStream.channel
|
||||||
|
val startOffset = assetFileDescriptor.startOffset
|
||||||
|
val declaredLength = assetFileDescriptor.declaredLength
|
||||||
|
val modelBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
|
||||||
|
|
||||||
|
val options = Interpreter.Options()
|
||||||
|
options.setNumThreads(4)
|
||||||
|
interpreter = Interpreter(modelBuffer, options)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError(e.message ?: "Error loading model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detect(bitmap: Bitmap, imageRotation: Int) {
|
||||||
|
val tflite = interpreter ?: return
|
||||||
|
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// 1. Preprocess Image
|
||||||
|
// Rotate -> Resize -> Convert to TensorImage (UINT8)
|
||||||
|
val imageProcessor = ImageProcessor.Builder()
|
||||||
|
.add(Rot90Op(-imageRotation / 90))
|
||||||
|
.add(ResizeOp(inputSize, inputSize, ResizeOp.ResizeMethod.BILINEAR))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
var tensorImage = TensorImage(DataType.UINT8)
|
||||||
|
tensorImage.load(bitmap)
|
||||||
|
tensorImage = imageProcessor.process(tensorImage)
|
||||||
|
|
||||||
|
// 2. Prepare Output Buffers
|
||||||
|
val outputBoxes = Array(1) { Array(25) { FloatArray(4) } }
|
||||||
|
val outputClasses = Array(1) { FloatArray(25) }
|
||||||
|
val outputScores = Array(1) { FloatArray(25) }
|
||||||
|
val outputCount = FloatArray(1)
|
||||||
|
|
||||||
|
val outputs = mapOf(
|
||||||
|
0 to outputBoxes,
|
||||||
|
1 to outputClasses,
|
||||||
|
2 to outputScores,
|
||||||
|
3 to outputCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Run Inference
|
||||||
|
try {
|
||||||
|
tflite.runForMultipleInputsOutputs(arrayOf(tensorImage.buffer), outputs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError(e.message ?: "Inference failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val inferenceTime = System.currentTimeMillis() - startTime
|
||||||
|
|
||||||
|
// 4. Parse Results
|
||||||
|
val results = mutableListOf<ObjectDetector.DetectionResult>()
|
||||||
|
|
||||||
|
// Calculate dimensions of the rotated image (the coordinate space of the detections)
|
||||||
|
val rotatedWidth = if (imageRotation % 180 == 0) bitmap.width else bitmap.height
|
||||||
|
val rotatedHeight = if (imageRotation % 180 == 0) bitmap.height else bitmap.width
|
||||||
|
|
||||||
|
for (i in 0 until 25) {
|
||||||
|
val score = outputScores[0][i]
|
||||||
|
if (score < 0.4f) continue
|
||||||
|
|
||||||
|
val classId = outputClasses[0][i].toInt()
|
||||||
|
val label = if (classId == 20) "Cow" else "Object $classId"
|
||||||
|
|
||||||
|
// Get box: [ymin, xmin, ymax, xmax] (normalized 0..1)
|
||||||
|
val box = outputBoxes[0][i]
|
||||||
|
val ymin = box[0]
|
||||||
|
val xmin = box[1]
|
||||||
|
val ymax = box[2]
|
||||||
|
val xmax = box[3]
|
||||||
|
|
||||||
|
// Scale to rotated image dimensions
|
||||||
|
val left = xmin * rotatedWidth
|
||||||
|
val top = ymin * rotatedHeight
|
||||||
|
val right = xmax * rotatedWidth
|
||||||
|
val bottom = ymax * rotatedHeight
|
||||||
|
|
||||||
|
val boundingBox = RectF(left, top, right, bottom)
|
||||||
|
|
||||||
|
results.add(ObjectDetector.DetectionResult(boundingBox, label, score))
|
||||||
|
}
|
||||||
|
|
||||||
|
onResults(results, inferenceTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class OrientationPixelEstimator(
|
||||||
|
private val iouThreshold: Float = 0.60f
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function:
|
||||||
|
* - segmentationMaskBitmap: MLKit’s alpha mask (animal foreground)
|
||||||
|
* - silhouetteBitmap: template mask for EXPECTED orientation (e.g., LEFT)
|
||||||
|
* - bbox: detected bounding box from segmentation
|
||||||
|
*/
|
||||||
|
fun analyze(
|
||||||
|
segmentationMaskBitmap: Bitmap,
|
||||||
|
silhouetteBitmap: Bitmap,
|
||||||
|
bbox: Rect,
|
||||||
|
frameWidth: Int,
|
||||||
|
frameHeight: Int,
|
||||||
|
medianDepthMeters: Float? = null
|
||||||
|
): OrientationPixelResult {
|
||||||
|
|
||||||
|
// 1) Convert both masks → boolean
|
||||||
|
val segFullMask = bitmapToBooleanMask(segmentationMaskBitmap)
|
||||||
|
val silhouetteMask = bitmapToBooleanMask(silhouetteBitmap)
|
||||||
|
|
||||||
|
// 2) Crop segmentation mask to bbox
|
||||||
|
val croppedMask = cropMaskToBBox(segFullMask, frameWidth, frameHeight, bbox)
|
||||||
|
|
||||||
|
// 3) Scale silhouette mask to bbox size
|
||||||
|
val scaledSilhouette = scaleMask(
|
||||||
|
silhouetteMask,
|
||||||
|
silhouetteBitmap.width,
|
||||||
|
silhouetteBitmap.height,
|
||||||
|
bbox.width(),
|
||||||
|
bbox.height()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4) Compute IoU
|
||||||
|
val iou = computeIoU(croppedMask, scaledSilhouette)
|
||||||
|
val orientationMatched = iou >= iouThreshold
|
||||||
|
|
||||||
|
// 5) Pixel metrics extraction
|
||||||
|
val metrics = computePixelMetrics(croppedMask, bbox, medianDepthMeters)
|
||||||
|
|
||||||
|
return OrientationPixelResult(
|
||||||
|
orientationMatched = orientationMatched,
|
||||||
|
matchedOrientation = null,
|
||||||
|
iouScore = iou,
|
||||||
|
iouBestOther = 0f,
|
||||||
|
pixelMetrics = metrics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// MASK HELPERS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
private fun bitmapToBooleanMask(bitmap: Bitmap): BooleanArray {
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
val pixels = IntArray(w * h)
|
||||||
|
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
|
||||||
|
|
||||||
|
val out = BooleanArray(w * h)
|
||||||
|
for (i in pixels.indices) {
|
||||||
|
val alpha = (pixels[i] ushr 24) and 0xFF
|
||||||
|
out[i] = alpha > 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cropMaskToBBox(
|
||||||
|
fullMask: BooleanArray,
|
||||||
|
frameW: Int,
|
||||||
|
frameH: Int,
|
||||||
|
bbox: Rect
|
||||||
|
): BooleanArray {
|
||||||
|
|
||||||
|
val left = max(0, bbox.left)
|
||||||
|
val top = max(0, bbox.top)
|
||||||
|
val right = min(frameW - 1, bbox.right)
|
||||||
|
val bottom = min(frameH - 1, bbox.bottom)
|
||||||
|
|
||||||
|
val width = right - left + 1
|
||||||
|
val height = bottom - top + 1
|
||||||
|
|
||||||
|
val out = BooleanArray(width * height)
|
||||||
|
var idx = 0
|
||||||
|
|
||||||
|
for (y in top..bottom) {
|
||||||
|
for (x in left..right) {
|
||||||
|
out[idx++] = fullMask[y * frameW + x]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleMask(
|
||||||
|
src: BooleanArray,
|
||||||
|
srcW: Int,
|
||||||
|
srcH: Int,
|
||||||
|
dstW: Int,
|
||||||
|
dstH: Int
|
||||||
|
): BooleanArray {
|
||||||
|
|
||||||
|
val out = BooleanArray(dstW * dstH)
|
||||||
|
|
||||||
|
for (y in 0 until dstH) {
|
||||||
|
val sy = ((y.toFloat() / dstH) * srcH).toInt().coerceIn(0, srcH - 1)
|
||||||
|
for (x in 0 until dstW) {
|
||||||
|
val sx = ((x.toFloat() / dstW) * srcW).toInt().coerceIn(0, srcW - 1)
|
||||||
|
out[y * dstW + x] = src[sy * srcW + sx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeIoU(a: BooleanArray, b: BooleanArray): Float {
|
||||||
|
if (a.size != b.size) return 0f
|
||||||
|
|
||||||
|
var inter = 0
|
||||||
|
var union = 0
|
||||||
|
|
||||||
|
for (i in a.indices) {
|
||||||
|
val ai = a[i]
|
||||||
|
val bi = b[i]
|
||||||
|
if (ai || bi) union++
|
||||||
|
if (ai && bi) inter++
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (union == 0) 0f else inter.toFloat() / union
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// PIXEL METRICS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
private fun computePixelMetrics(
|
||||||
|
croppedMask: BooleanArray,
|
||||||
|
bbox: Rect,
|
||||||
|
medianDepthMeters: Float?
|
||||||
|
): PixelMetrics {
|
||||||
|
|
||||||
|
val w = bbox.width()
|
||||||
|
val h = bbox.height()
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
var sumX = 0L
|
||||||
|
var sumY = 0L
|
||||||
|
|
||||||
|
for (y in 0 until h) {
|
||||||
|
for (x in 0 until w) {
|
||||||
|
if (croppedMask[y * w + x]) {
|
||||||
|
count++
|
||||||
|
sumX += x
|
||||||
|
sumY += y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val centroidX = bbox.left + (sumX.toFloat() / max(1, count))
|
||||||
|
val centroidY = bbox.top + (sumY.toFloat() / max(1, count))
|
||||||
|
|
||||||
|
return PixelMetrics(
|
||||||
|
widthPx = w,
|
||||||
|
heightPx = h,
|
||||||
|
areaPx = count,
|
||||||
|
centroidX = centroidX,
|
||||||
|
centroidY = centroidY,
|
||||||
|
distanceProxyInvHeight = if (h > 0) 1f / h.toFloat() else Float.POSITIVE_INFINITY,
|
||||||
|
heightPxFloat = h.toFloat(),
|
||||||
|
medianDepthMeters = medianDepthMeters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
data class OrientationPixelResult(
|
||||||
|
val orientationMatched: Boolean, // true only if requested orientation is confidently matched
|
||||||
|
val matchedOrientation: Orientation?,// which orientation matched (if any)
|
||||||
|
val iouScore: Float, // IoU score for matched orientation (0..1)
|
||||||
|
val iouBestOther: Float, // best IoU among other orientations
|
||||||
|
val pixelMetrics: PixelMetrics? // null if orientation not matched
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Orientation {
|
||||||
|
LEFT, RIGHT, FRONT, BACK, LEFT_45, RIGHT_45, TOP, BOTTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PixelMetrics(
|
||||||
|
val widthPx: Int,
|
||||||
|
val heightPx: Int,
|
||||||
|
val areaPx: Int,
|
||||||
|
val centroidX: Float,
|
||||||
|
val centroidY: Float,
|
||||||
|
val distanceProxyInvHeight: Float, // 1 / heightPx (relative distance proxy)
|
||||||
|
val heightPxFloat: Float, // convenience
|
||||||
|
val medianDepthMeters: Float? // if depth map available (null otherwise)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
data class OrientationState(
|
||||||
|
val success: Boolean,
|
||||||
|
val reason: String,
|
||||||
|
val pixelMetrics: PixelMetrics?,
|
||||||
|
val orientationMatched: Boolean,
|
||||||
|
val iouScore: Float? = null,
|
||||||
|
val relativeDepth: Float? = null,
|
||||||
|
val absoluteDistanceMeters: Float? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
data class OrientationTemplate(
|
||||||
|
val orientation: Orientation,
|
||||||
|
val mask: BooleanArray,
|
||||||
|
val templateWidth: Int,
|
||||||
|
val templateHeight: Int
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
package com.example.livingai.domain.ml
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.io.OutputStream
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class SubjectSegmenterHelper(private val context: Context) {
|
||||||
|
|
||||||
|
private suspend fun segmentInternal(image: InputImage): Bitmap? =
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
|
||||||
|
val options = SubjectSegmenterOptions.Builder()
|
||||||
|
.enableMultipleSubjects(
|
||||||
|
SubjectSegmenterOptions.SubjectResultOptions.Builder()
|
||||||
|
.enableSubjectBitmap()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val segmenter = SubjectSegmentation.getClient(options)
|
||||||
|
|
||||||
|
segmenter.process(image)
|
||||||
|
.addOnSuccessListener { result ->
|
||||||
|
val subject = result.subjects
|
||||||
|
.maxByOrNull { it.width * it.height }
|
||||||
|
|
||||||
|
if (subject?.bitmap == null) {
|
||||||
|
continuation.resume(null)
|
||||||
|
return@addOnSuccessListener
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val output = Bitmap.createBitmap(
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
|
||||||
|
val canvas = Canvas(output)
|
||||||
|
canvas.drawColor(Color.BLACK)
|
||||||
|
canvas.drawBitmap(
|
||||||
|
subject.bitmap!!,
|
||||||
|
subject.startX.toFloat(),
|
||||||
|
subject.startY.toFloat(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.resume(output)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
.addOnCompleteListener {
|
||||||
|
segmenter.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun segmentToBitmap(inputBitmap: Bitmap): Bitmap? {
|
||||||
|
val image = InputImage.fromBitmap(inputBitmap, 0)
|
||||||
|
return segmentInternal(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun segmentAndSave(
|
||||||
|
inputBitmap: Bitmap,
|
||||||
|
animalId: String,
|
||||||
|
orientation: String,
|
||||||
|
subFolder: String? = null
|
||||||
|
): Uri? {
|
||||||
|
val image = InputImage.fromBitmap(inputBitmap, 0)
|
||||||
|
val bitmap = segmentInternal(image) ?: return null
|
||||||
|
return saveBitmap(bitmap, animalId, orientation, subFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun segmentAndSave(
|
||||||
|
inputUri: Uri,
|
||||||
|
animalId: String,
|
||||||
|
orientation: String,
|
||||||
|
subFolder: String? = null
|
||||||
|
): Uri? {
|
||||||
|
val image = InputImage.fromFilePath(context, inputUri)
|
||||||
|
val bitmap = segmentInternal(image) ?: return null
|
||||||
|
return saveBitmap(bitmap, animalId, orientation, subFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBitmap(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
animalId: String,
|
||||||
|
orientation: String,
|
||||||
|
subFolder: String?
|
||||||
|
): Uri? {
|
||||||
|
|
||||||
|
val filename = "${animalId}_${orientation}_segmented.jpg"
|
||||||
|
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val path =
|
||||||
|
if (subFolder != null)
|
||||||
|
"Pictures/LivingAI/$animalId/$subFolder"
|
||||||
|
else
|
||||||
|
"Pictures/LivingAI/$animalId"
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = context.contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
values
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
val outputStream: OutputStream? =
|
||||||
|
context.contentResolver.openOutputStream(uri)
|
||||||
|
|
||||||
|
outputStream?.use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.example.livingai.domain.model
|
||||||
|
|
||||||
|
data class AnimalDetails(
|
||||||
|
val animalId: String,
|
||||||
|
val name: String,
|
||||||
|
val species: String,
|
||||||
|
val breed: String,
|
||||||
|
val sex: String,
|
||||||
|
val weight: Int,
|
||||||
|
val age: Int,
|
||||||
|
val milkYield: Int,
|
||||||
|
val calvingNumber: Int,
|
||||||
|
val reproductiveStatus: String,
|
||||||
|
val description: String,
|
||||||
|
val images: Map<String, String>,
|
||||||
|
val video: String,
|
||||||
|
val segmentedImages: Map<String, String>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.example.livingai.domain.model
|
||||||
|
|
||||||
|
data class AnimalProfile(
|
||||||
|
val animalId: String,
|
||||||
|
val name: String,
|
||||||
|
val species: String,
|
||||||
|
val breed: String,
|
||||||
|
val sex: String,
|
||||||
|
val weight: Int,
|
||||||
|
val age: Int,
|
||||||
|
val overallRating: Int? = null,
|
||||||
|
val imageUrls: List<String>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.example.livingai.domain.model
|
||||||
|
|
||||||
|
data class AnimalRating(
|
||||||
|
val animalId: String,
|
||||||
|
val overallRating: Int,
|
||||||
|
val healthRating: Int,
|
||||||
|
val breedRating: Int,
|
||||||
|
val stature: Int,
|
||||||
|
val chestWidth: Int,
|
||||||
|
val bodyDepth: Int,
|
||||||
|
val angularity: Int,
|
||||||
|
val rumpAngle: Int,
|
||||||
|
val rumpWidth: Int,
|
||||||
|
val rearLegSet: Int,
|
||||||
|
val rearLegRearView: Int,
|
||||||
|
val footAngle: Int,
|
||||||
|
val foreUdderAttachment: Int,
|
||||||
|
val rearUdderHeight: Int,
|
||||||
|
val centralLigament: Int,
|
||||||
|
val udderDepth: Int,
|
||||||
|
val frontTeatPosition: Int,
|
||||||
|
val teatLength: Int,
|
||||||
|
val rearTeatPosition: Int,
|
||||||
|
val locomotion: Int,
|
||||||
|
val bodyConditionScore: Int,
|
||||||
|
val hockDevelopment: Int,
|
||||||
|
val boneStructure: Int,
|
||||||
|
val rearUdderWidth: Int,
|
||||||
|
val teatThickness: Int,
|
||||||
|
val muscularity: Int,
|
||||||
|
val bodyConditionComments: String,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SettingsData(
|
||||||
|
val language: String = "en",
|
||||||
|
val isAutoCaptureOn: Boolean = false,
|
||||||
|
val jaccardThreshold: Float = 50f,
|
||||||
|
val distanceMethod: String = "Jaccard"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.example.livingai.domain.model.camera
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.RectF
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the output of a pipeline analysis step.
|
||||||
|
* @param message Instruction text to be displayed to the user.
|
||||||
|
* @param animationResId Resource ID for a visual GIF/Animation explaining the instruction.
|
||||||
|
* @param isValid True if the step passed validation, False otherwise.
|
||||||
|
* @param result The detailed analysis result (optional).
|
||||||
|
*/
|
||||||
|
data class Instruction(
|
||||||
|
val message: String,
|
||||||
|
val isValid: Boolean,
|
||||||
|
val animationResId: Int? = null,
|
||||||
|
val result: AnalysisResult? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed interface for different types of analysis results.
|
||||||
|
*/
|
||||||
|
sealed interface AnalysisResult
|
||||||
|
|
||||||
|
data class OrientationResult(
|
||||||
|
val currentOrientation: Int,
|
||||||
|
val requiredOrientation: CameraOrientation
|
||||||
|
) : AnalysisResult
|
||||||
|
|
||||||
|
data class TiltResult(
|
||||||
|
val roll: Float,
|
||||||
|
val pitch: Float,
|
||||||
|
val isLevel: Boolean
|
||||||
|
) : AnalysisResult
|
||||||
|
|
||||||
|
data class DetectionResult(
|
||||||
|
val isAnimalDetected: Boolean,
|
||||||
|
val animalBounds: RectF?,
|
||||||
|
val referenceObjects: List<ReferenceObject>,
|
||||||
|
val label: String? = null,
|
||||||
|
val confidence: Float = 0f,
|
||||||
|
val segmentationMask: ByteArray? = null
|
||||||
|
) : AnalysisResult
|
||||||
|
|
||||||
|
data class PoseResult(
|
||||||
|
val isCorrectPose: Boolean,
|
||||||
|
val feedback: String
|
||||||
|
) : AnalysisResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a reference object detected in the scene.
|
||||||
|
*/
|
||||||
|
data class ReferenceObject(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val bounds: RectF,
|
||||||
|
val relativeHeight: Float,
|
||||||
|
val relativeWidth: Float,
|
||||||
|
val distance: Float? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class CameraOrientation {
|
||||||
|
PORTRAIT, LANDSCAPE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data to be saved after a successful capture.
|
||||||
|
*/
|
||||||
|
data class CaptureData(
|
||||||
|
val image: Bitmap,
|
||||||
|
val segmentationMask: BooleanArray, // Flattened 2D array or similar representation
|
||||||
|
val animalMetrics: ObjectMetrics,
|
||||||
|
val referenceObjects: List<ReferenceObject>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ObjectMetrics(
|
||||||
|
val relativeHeight: Float,
|
||||||
|
val relativeWidth: Float,
|
||||||
|
val distance: Float
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.repository
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.SettingsData
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface AppDataRepository {
|
||||||
|
fun getSettings(): Flow<SettingsData>
|
||||||
|
suspend fun saveSettings(settings: SettingsData)
|
||||||
|
suspend fun saveAppEntry()
|
||||||
|
fun readAppEntry(): Flow<Boolean>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.example.livingai.domain.repository
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import com.example.livingai.domain.ml.Orientation
|
||||||
|
import com.example.livingai.domain.ml.OrientationState
|
||||||
|
|
||||||
|
interface CameraRepository {
|
||||||
|
suspend fun captureImage(imageProxy: ImageProxy): Bitmap
|
||||||
|
suspend fun processFrame(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
requestedOrientation: Orientation,
|
||||||
|
silhouetteBitmap: Bitmap,
|
||||||
|
realObjectHeightMeters: Float?,
|
||||||
|
focalLengthPixels: Float,
|
||||||
|
boundingBox: Rect? = null
|
||||||
|
): OrientationState
|
||||||
|
suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.domain.repository
|
||||||
|
|
||||||
|
import com.example.livingai.data.local.model.SettingsData
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface SettingsRepository {
|
||||||
|
fun getSettings(): Flow<SettingsData>
|
||||||
|
suspend fun saveSettings(settings: SettingsData)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.example.livingai.domain.repository
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.camera.video.Recording
|
||||||
|
|
||||||
|
interface VideoRepository {
|
||||||
|
fun startRecording(onRecordingStarted: () -> Unit)
|
||||||
|
fun stopRecording()
|
||||||
|
suspend fun processFrame(bitmap: Bitmap): String
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.repository.business
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface AnimalDetailsRepository {
|
||||||
|
fun getAnimalDetails(id: String): Flow<AnimalDetails?>
|
||||||
|
|
||||||
|
suspend fun saveAnimalDetails(animalDetails: AnimalDetails)
|
||||||
|
|
||||||
|
suspend fun deleteAnimalDetails(id: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.example.livingai.domain.repository.business
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface AnimalProfileRepository {
|
||||||
|
fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>>
|
||||||
|
|
||||||
|
suspend fun saveAnimalProfile(animalProfile: AnimalProfile)
|
||||||
|
|
||||||
|
suspend fun deleteAnimalProfile(id: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.repository.business
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface AnimalRatingRepository {
|
||||||
|
fun getAnimalRating(id: String): Flow<AnimalRating?>
|
||||||
|
|
||||||
|
suspend fun saveAnimalRating(animalRating: AnimalRating)
|
||||||
|
|
||||||
|
suspend fun deleteAnimalRating(id: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.example.livingai.domain.repository.business
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface DataSource {
|
||||||
|
fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>>
|
||||||
|
fun getAnimalDetails(animalId: String): Flow<AnimalDetails?>
|
||||||
|
fun getAnimalRatings(animalId: String): Flow<AnimalRating?>
|
||||||
|
|
||||||
|
suspend fun setAnimalProfile(animalProfile: AnimalProfile)
|
||||||
|
suspend fun setAnimalDetails(animalDetails: AnimalDetails)
|
||||||
|
suspend fun setAnimalRatings(animalRating: AnimalRating)
|
||||||
|
suspend fun deleteAnimalProfile(animalId: String)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
data class AppDataUseCases(
|
||||||
|
val getSettings: GetSettingsUseCase,
|
||||||
|
val saveSettings: SaveSettingsUseCase,
|
||||||
|
val readAppEntry: ReadAppEntryUseCase,
|
||||||
|
val saveAppEntry: SaveAppEntryUseCase
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.domain.usecases.AppEntry
|
||||||
|
|
||||||
|
import com.example.livingai.domain.usecases.ReadAppEntry
|
||||||
|
import com.example.livingai.domain.usecases.SaveAppEntry
|
||||||
|
|
||||||
|
data class AppEntryUseCases(
|
||||||
|
val readAppEntry: ReadAppEntry,
|
||||||
|
val saveAppEntry: SaveAppEntry
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalProfileRepository
|
||||||
|
|
||||||
|
class DeleteAnimalProfile(
|
||||||
|
private val animalProfileRepository: AnimalProfileRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(animalId: String) {
|
||||||
|
animalProfileRepository.deleteAnimalProfile(animalId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class GetAnimalDetails(
|
||||||
|
private val animalDetailsRepository: AnimalDetailsRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(animalId: String): Flow<AnimalDetails?> {
|
||||||
|
return animalDetailsRepository.getAnimalDetails(animalId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalProfileRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class GetAnimalProfiles(
|
||||||
|
private val animalProfileRepository: AnimalProfileRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<PagingData<AnimalProfile>> {
|
||||||
|
return animalProfileRepository.getAnimalProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalRatingRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class GetAnimalRatings(
|
||||||
|
private val animalRatingRepository: AnimalRatingRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(animalId: String): Flow<AnimalRating?> {
|
||||||
|
return animalRatingRepository.getAnimalRating(animalId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetSettingsUseCase @Inject constructor(
|
||||||
|
private val appDataRepository: AppDataRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke() = appDataRepository.getSettings()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.domain.usecases.ProfileEntry
|
||||||
|
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalDetails
|
||||||
|
import com.example.livingai.domain.usecases.SetAnimalDetails
|
||||||
|
|
||||||
|
data class ProfileEntryUseCase(
|
||||||
|
val getAnimalDetails: GetAnimalDetails,
|
||||||
|
val setAnimalDetails: SetAnimalDetails,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.domain.usecases.ProfileListing
|
||||||
|
|
||||||
|
import com.example.livingai.domain.usecases.DeleteAnimalProfile
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalProfiles
|
||||||
|
|
||||||
|
data class ProfileListingUseCase(
|
||||||
|
val getAnimalProfiles: GetAnimalProfiles,
|
||||||
|
val deleteAnimalProfile: DeleteAnimalProfile
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.example.livingai.domain.usecases.ProfilesEntry
|
||||||
|
|
||||||
|
import com.example.livingai.domain.usecases.DeleteAnimalProfile
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalProfiles
|
||||||
|
|
||||||
|
data class ProfilesEntryUseCases(
|
||||||
|
val getAnimalProfiles: GetAnimalProfiles,
|
||||||
|
val deleteAnimalProfile: DeleteAnimalProfile
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.manager.LocalUserManager
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class ReadAppEntry(
|
||||||
|
private val localUserManager: LocalUserManager
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<Boolean> {
|
||||||
|
return localUserManager.readAppEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ReadAppEntryUseCase @Inject constructor(
|
||||||
|
private val appDataRepository: AppDataRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<Boolean> = appDataRepository.readAppEntry()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.manager.LocalUserManager
|
||||||
|
|
||||||
|
class SaveAppEntry(
|
||||||
|
private val localUserManager: LocalUserManager
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
localUserManager.saveAppEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SaveAppEntryUseCase @Inject constructor(
|
||||||
|
private val appDataRepository: AppDataRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() = appDataRepository.saveAppEntry()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.SettingsData
|
||||||
|
import com.example.livingai.domain.repository.AppDataRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SaveSettingsUseCase @Inject constructor(
|
||||||
|
private val appDataRepository: AppDataRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(settings: SettingsData) = appDataRepository.saveSettings(settings)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
|
||||||
|
|
||||||
|
class SetAnimalDetails(
|
||||||
|
private val animalDetailsRepository: AnimalDetailsRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(animalDetails: AnimalDetails) {
|
||||||
|
animalDetailsRepository.saveAnimalDetails(animalDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.example.livingai.domain.usecases
|
||||||
|
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
|
import com.example.livingai.domain.repository.business.AnimalRatingRepository
|
||||||
|
|
||||||
|
class SetAnimalRatings(
|
||||||
|
private val animalRatingRepository: AnimalRatingRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(animalRating: AnimalRating) {
|
||||||
|
animalRatingRepository.saveAnimalRating(animalRating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
package com.example.livingai.pages.addprofile
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.example.livingai.R
|
||||||
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
|
import com.example.livingai.pages.components.CommonScaffold
|
||||||
|
import com.example.livingai.pages.components.ImageThumbnailButton
|
||||||
|
import com.example.livingai.pages.components.LabeledDropdown
|
||||||
|
import com.example.livingai.pages.components.LabeledTextField
|
||||||
|
import com.example.livingai.pages.components.RadioGroup
|
||||||
|
import com.example.livingai.pages.components.VideoThumbnailButton
|
||||||
|
import com.example.livingai.utils.Constants
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||||
|
@Composable
|
||||||
|
fun AddProfileScreen(
|
||||||
|
navController: NavController,
|
||||||
|
viewModel: AddProfileViewModel,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onTakePhoto: (String) -> Unit,
|
||||||
|
onTakeVideo: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val speciesList = stringArrayResource(id = R.array.species_list).toList()
|
||||||
|
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
|
||||||
|
|
||||||
|
val reproList = listOf(
|
||||||
|
stringResource(R.string.option_pregnant),
|
||||||
|
stringResource(R.string.option_calved),
|
||||||
|
stringResource(R.string.option_none)
|
||||||
|
)
|
||||||
|
|
||||||
|
val silhouette = Constants.silhouetteList.associateWith { item ->
|
||||||
|
val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName)
|
||||||
|
if (resId != 0) resId else R.string.default_orientation_label
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ViewModel state
|
||||||
|
var species by viewModel.species
|
||||||
|
var breed by viewModel.breed
|
||||||
|
var age by viewModel.age
|
||||||
|
var milkYield by viewModel.milkYield
|
||||||
|
var calvingNumber by viewModel.calvingNumber
|
||||||
|
var reproductiveStatus by viewModel.reproductiveStatus
|
||||||
|
var description by viewModel.description
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
val speciesError by viewModel.speciesError
|
||||||
|
val breedError by viewModel.breedError
|
||||||
|
val ageError by viewModel.ageError
|
||||||
|
val milkYieldError by viewModel.milkYieldError
|
||||||
|
val calvingNumberError by viewModel.calvingNumberError
|
||||||
|
val reproductiveStatusError by viewModel.reproductiveStatusError
|
||||||
|
|
||||||
|
val photos = viewModel.photos
|
||||||
|
val videoUri by viewModel.videoUri
|
||||||
|
|
||||||
|
// Focus Requesters
|
||||||
|
val speciesFocus = remember { FocusRequester() }
|
||||||
|
val breedFocus = remember { FocusRequester() }
|
||||||
|
val ageFocus = remember { FocusRequester() }
|
||||||
|
val milkYieldFocus = remember { FocusRequester() }
|
||||||
|
val calvingNumberFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Auto-focus logic on error
|
||||||
|
LaunchedEffect(speciesError, breedError, ageError, milkYieldError, calvingNumberError, reproductiveStatusError) {
|
||||||
|
if (speciesError != null) {
|
||||||
|
speciesFocus.requestFocus()
|
||||||
|
} else if (breedError != null) {
|
||||||
|
breedFocus.requestFocus()
|
||||||
|
} else if (ageError != null) {
|
||||||
|
ageFocus.requestFocus()
|
||||||
|
} else if (milkYieldError != null) {
|
||||||
|
milkYieldFocus.requestFocus()
|
||||||
|
} else if (calvingNumberError != null) {
|
||||||
|
calvingNumberFocus.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommonScaffold(
|
||||||
|
navController = navController,
|
||||||
|
title = stringResource(id = R.string.top_bar_add_profile)
|
||||||
|
) { innerPadding ->
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_IMAGE),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(Dimentions.SMALL_PADDING_TEXT)
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(2) }) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT)
|
||||||
|
) {
|
||||||
|
LabeledDropdown(
|
||||||
|
labelRes = R.string.label_species,
|
||||||
|
options = speciesList,
|
||||||
|
selected = species,
|
||||||
|
onSelected = viewModel::validateSpeciesInputs,
|
||||||
|
modifier = Modifier.focusRequester(speciesFocus),
|
||||||
|
isError = speciesError != null,
|
||||||
|
supportingText = speciesError
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledDropdown(
|
||||||
|
labelRes = R.string.label_breed,
|
||||||
|
options = breedList,
|
||||||
|
selected = breed,
|
||||||
|
onSelected = viewModel::validateBreedInputs,
|
||||||
|
modifier = Modifier.focusRequester(breedFocus),
|
||||||
|
isError = breedError != null,
|
||||||
|
supportingText = breedError
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
LabeledTextField(
|
||||||
|
labelRes = R.string.label_age,
|
||||||
|
value = age,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(ageFocus),
|
||||||
|
onValueChange = viewModel::validateAgeInputs,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
isError = ageError != null,
|
||||||
|
supportingText = ageError
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledTextField(
|
||||||
|
labelRes = R.string.label_milk_yield,
|
||||||
|
value = milkYield,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(milkYieldFocus),
|
||||||
|
onValueChange = viewModel::validateMilkYieldInputs,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
isError = milkYieldError != null,
|
||||||
|
supportingText = milkYieldError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledTextField(
|
||||||
|
labelRes = R.string.label_calving_number,
|
||||||
|
value = calvingNumber,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(calvingNumberFocus),
|
||||||
|
onValueChange = viewModel::validateCalvingInputs,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
isError = calvingNumberError != null,
|
||||||
|
supportingText = calvingNumberError
|
||||||
|
)
|
||||||
|
|
||||||
|
RadioGroup(
|
||||||
|
titleRes = R.string.label_reproductive_status,
|
||||||
|
options = reproList,
|
||||||
|
selected = reproductiveStatus,
|
||||||
|
onSelected = viewModel::validateReproductiveStatusInputs,
|
||||||
|
isError = reproductiveStatusError != null
|
||||||
|
)
|
||||||
|
if (reproductiveStatusError != null) {
|
||||||
|
Text(
|
||||||
|
text = reproductiveStatusError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(start = Dimentions.SMALL_PADDING_TEXT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledTextField(
|
||||||
|
labelRes = R.string.label_description,
|
||||||
|
value = description,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
onValueChange = { description = it },
|
||||||
|
keyboardType = KeyboardType.Text
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.label_upload_media),
|
||||||
|
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(silhouette.entries.toList()) { (key, value) ->
|
||||||
|
ImageThumbnailButton(
|
||||||
|
image = photos[key],
|
||||||
|
onClick = { onTakePhoto(key) },
|
||||||
|
labelRes = value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video Button
|
||||||
|
item {
|
||||||
|
VideoThumbnailButton(
|
||||||
|
videoSource = videoUri,
|
||||||
|
onClick = onTakeVideo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and Cancel Buttons
|
||||||
|
item(span = { GridItemSpan(2) }) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = Dimentions.MEDIUM_PADDING_BUTTON),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = Dimentions.SMALL_PADDING_BUTTON)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = Dimentions.SMALL_PADDING_BUTTON)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.btn_save_profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(span = { GridItemSpan(2) }) {
|
||||||
|
Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
package com.example.livingai.pages.addprofile
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
|
||||||
|
import com.example.livingai.utils.CoroutineDispatchers
|
||||||
|
import com.example.livingai.utils.IdGenerator
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class AddProfileViewModel(
|
||||||
|
private val profileEntryUseCase: ProfileEntryUseCase,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val savedStateHandle: SavedStateHandle? = null
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_ANIMAL_ID = "animal_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _animalDetails = mutableStateOf<AnimalDetails?>(null)
|
||||||
|
|
||||||
|
private val _currentAnimalId =
|
||||||
|
mutableStateOf(savedStateHandle?.get<String>(KEY_ANIMAL_ID))
|
||||||
|
val currentAnimalId: State<String?> = _currentAnimalId
|
||||||
|
|
||||||
|
var species = mutableStateOf<String?>(null)
|
||||||
|
var breed = mutableStateOf<String?>(null)
|
||||||
|
var age = mutableStateOf("")
|
||||||
|
var milkYield = mutableStateOf("")
|
||||||
|
var calvingNumber = mutableStateOf("")
|
||||||
|
var reproductiveStatus = mutableStateOf<String?>(null)
|
||||||
|
var description = mutableStateOf("")
|
||||||
|
|
||||||
|
var ageError = mutableStateOf<String?>(null)
|
||||||
|
var milkYieldError = mutableStateOf<String?>(null)
|
||||||
|
var calvingNumberError = mutableStateOf<String?>(null)
|
||||||
|
var speciesError = mutableStateOf<String?>(null)
|
||||||
|
var breedError = mutableStateOf<String?>(null)
|
||||||
|
var reproductiveStatusError = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val _saveSuccess = mutableStateOf(false)
|
||||||
|
|
||||||
|
val photos = mutableStateMapOf<String, String>()
|
||||||
|
val segmentedImages = mutableStateMapOf<String, String>()
|
||||||
|
private val _videoUri = mutableStateOf<String?>(null)
|
||||||
|
val videoUri: State<String?> = _videoUri
|
||||||
|
|
||||||
|
fun initializeNewProfileIfNeeded() {
|
||||||
|
if (_currentAnimalId.value != null) return
|
||||||
|
|
||||||
|
val id = IdGenerator.generateAnimalId()
|
||||||
|
_currentAnimalId.value = id
|
||||||
|
savedStateHandle?.let { it[KEY_ANIMAL_ID] = id }
|
||||||
|
|
||||||
|
_animalDetails.value = null
|
||||||
|
species.value = null
|
||||||
|
breed.value = null
|
||||||
|
age.value = ""
|
||||||
|
milkYield.value = ""
|
||||||
|
calvingNumber.value = ""
|
||||||
|
reproductiveStatus.value = null
|
||||||
|
description.value = ""
|
||||||
|
clearErrors()
|
||||||
|
photos.clear()
|
||||||
|
segmentedImages.clear()
|
||||||
|
_videoUri.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAnimal(animalId: String) {
|
||||||
|
if (_currentAnimalId.value == animalId) return
|
||||||
|
|
||||||
|
_currentAnimalId.value = animalId
|
||||||
|
savedStateHandle?.set(KEY_ANIMAL_ID, animalId)
|
||||||
|
|
||||||
|
profileEntryUseCase.getAnimalDetails(animalId)
|
||||||
|
.onEach { details ->
|
||||||
|
details ?: return@onEach
|
||||||
|
|
||||||
|
_animalDetails.value = details
|
||||||
|
species.value = details.species.ifBlank { null }
|
||||||
|
breed.value = details.breed.ifBlank { null }
|
||||||
|
age.value = details.age.takeIf { it > 0 }?.toString() ?: ""
|
||||||
|
milkYield.value = details.milkYield.takeIf { it > 0 }?.toString() ?: ""
|
||||||
|
calvingNumber.value = details.calvingNumber.takeIf { it > 0 }?.toString() ?: ""
|
||||||
|
reproductiveStatus.value = details.reproductiveStatus.ifBlank { null }
|
||||||
|
description.value = details.description
|
||||||
|
clearErrors()
|
||||||
|
|
||||||
|
photos.clear()
|
||||||
|
segmentedImages.clear()
|
||||||
|
|
||||||
|
withContext(dispatchers.main) {
|
||||||
|
details.images.forEach { photos[it.key] = it.value }
|
||||||
|
details.segmentedImages.forEach { segmentedImages[it.key] = it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
_videoUri.value = details.video.ifBlank { null }
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAnimalDetails(currentId: String): Boolean {
|
||||||
|
if (!validateInputs()) return false
|
||||||
|
|
||||||
|
val id = currentId
|
||||||
|
|
||||||
|
val details = AnimalDetails(
|
||||||
|
animalId = id,
|
||||||
|
species = species.value ?: "",
|
||||||
|
breed = breed.value ?: "",
|
||||||
|
age = age.value.toIntOrNull() ?: 0,
|
||||||
|
milkYield = milkYield.value.toIntOrNull() ?: 0,
|
||||||
|
calvingNumber = calvingNumber.value.toIntOrNull() ?: 0,
|
||||||
|
reproductiveStatus = reproductiveStatus.value ?: "",
|
||||||
|
description = description.value,
|
||||||
|
images = photos.toMap(),
|
||||||
|
video = _videoUri.value ?: "",
|
||||||
|
segmentedImages = segmentedImages.toMap(),
|
||||||
|
name = "",
|
||||||
|
sex = "",
|
||||||
|
weight = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
profileEntryUseCase.setAnimalDetails(details)
|
||||||
|
_saveSuccess.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearErrors() {
|
||||||
|
ageError.value = null
|
||||||
|
milkYieldError.value = null
|
||||||
|
calvingNumberError.value = null
|
||||||
|
speciesError.value = null
|
||||||
|
breedError.value = null
|
||||||
|
reproductiveStatusError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addPhoto(orientation: String, uri: String) {
|
||||||
|
photos[orientation] = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSegmentedImage(orientation: String, uri: String) {
|
||||||
|
segmentedImages[orientation] = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVideo(uri: String) {
|
||||||
|
_videoUri.value = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateInputs(): Boolean {
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (!validateSpeciesInputs()) isValid = false
|
||||||
|
if (!validateBreedInputs()) isValid = false
|
||||||
|
if (!validateAgeInputs()) isValid = false
|
||||||
|
if (!validateMilkYieldInputs()) isValid = false
|
||||||
|
if (!validateCalvingInputs()) isValid = false
|
||||||
|
if (!validateReproductiveStatusInputs()) isValid = false
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateSpeciesInputs(s: String? = null): Boolean {
|
||||||
|
if (s != null)
|
||||||
|
species.value = s
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (species.value.isNullOrBlank()) {
|
||||||
|
speciesError.value = "Species is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
speciesError.value = null
|
||||||
|
}
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateBreedInputs(b: String? = null): Boolean {
|
||||||
|
if (b != null)
|
||||||
|
breed.value = b
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (breed.value.isNullOrBlank()) {
|
||||||
|
breedError.value = "Breed is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
breedError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateReproductiveStatusInputs(r: String? = null): Boolean {
|
||||||
|
if (r != null)
|
||||||
|
reproductiveStatus.value = r
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (reproductiveStatus.value.isNullOrBlank()) {
|
||||||
|
reproductiveStatusError.value = "Status is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
reproductiveStatusError.value = null
|
||||||
|
}
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateAgeInputs(a: String? = null): Boolean {
|
||||||
|
if (a != null)
|
||||||
|
age.value = a
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
val ageInt = age.value.toIntOrNull()
|
||||||
|
if (ageInt == null || ageInt <= 0 || ageInt > 20) {
|
||||||
|
ageError.value = "Invalid age"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
ageError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateMilkYieldInputs(m: String? = null): Boolean {
|
||||||
|
if (m != null)
|
||||||
|
milkYield.value = m
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
val milkInt = milkYield.value.toIntOrNull()
|
||||||
|
if (milkInt == null || milkInt <= 0 || milkInt > 75) {
|
||||||
|
milkYieldError.value = "Invalid milk yield"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
milkYieldError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateCalvingInputs(c: String? = null): Boolean {
|
||||||
|
if (c != null)
|
||||||
|
calvingNumber.value = c
|
||||||
|
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
val calvingInt = calvingNumber.value.toIntOrNull()
|
||||||
|
if (calvingInt == null || calvingInt < 0 || calvingInt > 12) {
|
||||||
|
calvingNumberError.value = "Invalid calving number"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
calvingNumberError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue