Compare commits
No commits in common. "livingai-cleanarch-kmp" and "animal-rating-android-studio" have entirely different histories.
livingai-c
...
animal-rat
|
|
@ -4,6 +4,17 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?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.
|
|
@ -1,4 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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,17 +2,16 @@ 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.livingai"
|
namespace = "com.example.animalrating"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36)
|
version = release(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.livingai"
|
applicationId = "com.example.animalrating"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
|
|
@ -28,6 +27,7 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -39,46 +39,25 @@ android {
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
mlModelBinding = true
|
|
||||||
}
|
|
||||||
aaptOptions {
|
|
||||||
noCompress += "tflite"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.paging.common)
|
val cameraxVersion = "1.5.1"
|
||||||
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")
|
||||||
//Splash Api
|
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||||
implementation("androidx.camera:camera-core:${cameraxVersion}")
|
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||||
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
|
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||||
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
|
implementation("androidx.camera:camera-mlkit-vision:$cameraxVersion")
|
||||||
implementation("androidx.camera:camera-view:${cameraxVersion}")
|
|
||||||
implementation("androidx.camera:camera-video:${cameraxVersion}")
|
// ML Kit Object Detection
|
||||||
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)
|
||||||
|
|
@ -87,23 +66,6 @@ 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)
|
||||||
|
|
@ -111,4 +73,4 @@ dependencies {
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.livingai
|
package com.example.animalrating
|
||||||
|
|
||||||
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.livingai", appContext.packageName)
|
assertEquals("com.example.animalrating", appContext.packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
app/src/main.zip
BIN
app/src/main.zip
Binary file not shown.
|
|
@ -2,21 +2,13 @@
|
||||||
<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-feature android:name="android.hardware.camera" />
|
|
||||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-feature android:name="android.hardware.camera" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||||
<!-- 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"
|
||||||
|
|
@ -24,23 +16,46 @@
|
||||||
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/LivingAI.Starting.Theme">
|
android:theme="@style/Theme.AnimalRating"
|
||||||
|
android:requestLegacyExternalStorage="true">
|
||||||
<meta-data
|
|
||||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
|
||||||
android:value="obj" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".HomeActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/LivingAI.Starting.Theme">
|
android:theme="@style/Theme.AnimalRating">
|
||||||
<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.
|
|
@ -1,81 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,519 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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)
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,424 +0,0 @@
|
||||||
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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package com.example.livingai.data.local.model
|
|
||||||
|
|
||||||
data class SettingsData(
|
|
||||||
val language: String,
|
|
||||||
val isAutoCaptureOn: Boolean
|
|
||||||
)
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package com.example.livingai.data.ml
|
|
||||||
|
|
||||||
import android.graphics.Rect
|
|
||||||
|
|
||||||
data class ObjectDetectionResult(
|
|
||||||
val boundingBox: Rect,
|
|
||||||
val label: String,
|
|
||||||
val confidence: Float
|
|
||||||
)
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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) { }
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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 {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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() }
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package com.example.livingai.domain.manager
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface LocalUserManager {
|
|
||||||
suspend fun saveAppEntry()
|
|
||||||
fun readAppEntry(): Flow<Boolean>
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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?
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package com.example.livingai.domain.ml
|
|
||||||
|
|
||||||
data class OrientationTemplate(
|
|
||||||
val orientation: Orientation,
|
|
||||||
val mask: BooleanArray,
|
|
||||||
val templateWidth: Int,
|
|
||||||
val templateHeight: Int
|
|
||||||
)
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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>
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package com.example.livingai.domain.usecases
|
|
||||||
|
|
||||||
data class AppDataUseCases(
|
|
||||||
val getSettings: GetSettingsUseCase,
|
|
||||||
val saveSettings: SaveSettingsUseCase,
|
|
||||||
val readAppEntry: ReadAppEntryUseCase,
|
|
||||||
val saveAppEntry: SaveAppEntryUseCase
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
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