app integration

This commit is contained in:
SaiD 2025-12-15 22:25:56 +05:30
parent 3bbf128d9c
commit bcb4ee1a39
16 changed files with 459 additions and 68 deletions

View File

@ -22,7 +22,7 @@ class MidasDepthEstimator(private val context: Context) {
private var interpreter: Interpreter? = null private var interpreter: Interpreter? = null
companion object { companion object {
private const val MODEL_NAME = "midas_v2_1_small.tflite" private const val MODEL_NAME = ""
private const val INPUT_SIZE = 256 private const val INPUT_SIZE = 256
private val NORM_MEAN = floatArrayOf(123.675f, 116.28f, 103.53f) private val NORM_MEAN = floatArrayOf(123.675f, 116.28f, 103.53f)

View File

@ -186,7 +186,7 @@ val appModule = module {
viewModel { ListingsViewModel(get()) } viewModel { ListingsViewModel(get()) }
viewModel { SettingsViewModel(get()) } viewModel { SettingsViewModel(get()) }
viewModel { RatingViewModel(get(), get(), get(), get()) } viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { CameraViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { VideoViewModel(get(), get(), get()) } viewModel { VideoViewModel(get(), get(), get()) }
viewModel { ImagePreviewViewModel() } viewModel { ImagePreviewViewModel() }
viewModel { VideoPreviewViewModel() } viewModel { VideoPreviewViewModel() }

View File

@ -20,8 +20,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -80,9 +83,40 @@ fun AddProfileScreen(
var reproductiveStatus by viewModel.reproductiveStatus var reproductiveStatus by viewModel.reproductiveStatus
var description by viewModel.description 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 photos = viewModel.photos
val videoUri by viewModel.videoUri 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() }
val reproductiveStatusFocus = remember { FocusRequester() } // Probably not useful for RadioGroup but good for consistency
// 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( CommonScaffold(
navController = navController, navController = navController,
title = stringResource(id = R.string.top_bar_add_profile) title = stringResource(id = R.string.top_bar_add_profile)
@ -103,14 +137,20 @@ fun AddProfileScreen(
labelRes = R.string.label_species, labelRes = R.string.label_species,
options = speciesList, options = speciesList,
selected = species, selected = species,
onSelected = { species = it } onSelected = { species = it },
modifier = Modifier.focusRequester(speciesFocus),
isError = speciesError != null,
supportingText = speciesError
) )
LabeledDropdown( LabeledDropdown(
labelRes = R.string.label_breed, labelRes = R.string.label_breed,
options = breedList, options = breedList,
selected = breed, selected = breed,
onSelected = { breed = it } onSelected = { breed = it },
modifier = Modifier.focusRequester(breedFocus),
isError = breedError != null,
supportingText = breedError
) )
Row( Row(
@ -120,34 +160,55 @@ fun AddProfileScreen(
LabeledTextField( LabeledTextField(
labelRes = R.string.label_age, labelRes = R.string.label_age,
value = age, value = age,
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.focusRequester(ageFocus),
onValueChange = { age = it }, onValueChange = { age = it },
keyboardType = KeyboardType.Number keyboardType = KeyboardType.Number,
isError = ageError != null,
supportingText = ageError
) )
LabeledTextField( LabeledTextField(
labelRes = R.string.label_milk_yield, labelRes = R.string.label_milk_yield,
value = milkYield, value = milkYield,
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.focusRequester(milkYieldFocus),
onValueChange = { milkYield = it }, onValueChange = { milkYield = it },
keyboardType = KeyboardType.Number keyboardType = KeyboardType.Number,
isError = milkYieldError != null,
supportingText = milkYieldError
) )
} }
LabeledTextField( LabeledTextField(
labelRes = R.string.label_calving_number, labelRes = R.string.label_calving_number,
value = calvingNumber, value = calvingNumber,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.focusRequester(calvingNumberFocus),
onValueChange = { calvingNumber = it }, onValueChange = { calvingNumber = it },
keyboardType = KeyboardType.Number keyboardType = KeyboardType.Number,
isError = calvingNumberError != null,
supportingText = calvingNumberError
) )
RadioGroup( RadioGroup(
titleRes = R.string.label_reproductive_status, titleRes = R.string.label_reproductive_status,
options = reproList, options = reproList,
selected = reproductiveStatus, selected = reproductiveStatus,
onSelected = { reproductiveStatus = it } onSelected = { reproductiveStatus = it },
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( LabeledTextField(
labelRes = R.string.label_description, labelRes = R.string.label_description,

View File

@ -42,6 +42,18 @@ class AddProfileViewModel(
var reproductiveStatus = mutableStateOf<String?>(null) var reproductiveStatus = mutableStateOf<String?>(null)
var description = mutableStateOf("") var description = mutableStateOf("")
// Errors
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)
// Save state
private val _saveSuccess = mutableStateOf(false)
val saveSuccess: State<Boolean> = _saveSuccess
// State for photos and video // State for photos and video
val photos = mutableStateMapOf<String, String>() val photos = mutableStateMapOf<String, String>()
private val _videoUri = mutableStateOf<String?>(null) private val _videoUri = mutableStateOf<String?>(null)
@ -61,6 +73,7 @@ class AddProfileViewModel(
calvingNumber.value = "" calvingNumber.value = ""
reproductiveStatus.value = null reproductiveStatus.value = null
description.value = "" description.value = ""
clearErrors()
photos.clear() photos.clear()
_videoUri.value = null _videoUri.value = null
@ -78,6 +91,7 @@ class AddProfileViewModel(
calvingNumber.value = if (details.calvingNumber == 0) "" else details.calvingNumber.toString() calvingNumber.value = if (details.calvingNumber == 0) "" else details.calvingNumber.toString()
reproductiveStatus.value = details.reproductiveStatus.ifBlank { null } reproductiveStatus.value = details.reproductiveStatus.ifBlank { null }
description.value = details.description description.value = details.description
clearErrors()
// Populate photos // Populate photos
photos.clear() photos.clear()
@ -104,6 +118,15 @@ class AddProfileViewModel(
} }
} }
private fun clearErrors() {
ageError.value = null
milkYieldError.value = null
calvingNumberError.value = null
speciesError.value = null
breedError.value = null
reproductiveStatusError.value = null
}
private fun getFileName(uri: Uri): String? { private fun getFileName(uri: Uri): String? {
var result: String? = null var result: String? = null
if (uri.scheme == "content") { if (uri.scheme == "content") {
@ -139,7 +162,60 @@ class AddProfileViewModel(
_videoUri.value = uri _videoUri.value = uri
} }
fun validateInputs(): Boolean {
var isValid = true
if (species.value.isNullOrBlank()) {
speciesError.value = "Species is required"
isValid = false
} else {
speciesError.value = null
}
if (breed.value.isNullOrBlank()) {
breedError.value = "Breed is required"
isValid = false
} else {
breedError.value = null
}
if (reproductiveStatus.value.isNullOrBlank()) {
reproductiveStatusError.value = "Status is required"
isValid = false
} else {
reproductiveStatusError.value = null
}
val ageInt = age.value.toIntOrNull()
if (ageInt == null || ageInt < 0) {
ageError.value = "Invalid age"
isValid = false
} else {
ageError.value = null
}
val milkInt = milkYield.value.toIntOrNull()
if (milkInt == null || milkInt < 0) {
milkYieldError.value = "Invalid milk yield"
isValid = false
} else {
milkYieldError.value = null
}
val calvingInt = calvingNumber.value.toIntOrNull()
if (calvingInt == null || calvingInt < 0) {
calvingNumberError.value = "Invalid calving number"
isValid = false
} else {
calvingNumberError.value = null
}
return isValid
}
fun saveAnimalDetails() { fun saveAnimalDetails() {
if (!validateInputs()) return
val id = _currentAnimalId.value ?: IdGenerator.generateAnimalId().also { _currentAnimalId.value = it } val id = _currentAnimalId.value ?: IdGenerator.generateAnimalId().also { _currentAnimalId.value = it }
val details = AnimalDetails( val details = AnimalDetails(
@ -158,9 +234,14 @@ class AddProfileViewModel(
viewModelScope.launch { viewModelScope.launch {
profileEntryUseCase.setAnimalDetails(details) profileEntryUseCase.setAnimalDetails(details)
_saveSuccess.value = true
} }
} }
fun onSaveComplete() {
_saveSuccess.value = false
}
init { init {
// Try to auto-load when editing via saved state handle // Try to auto-load when editing via saved state handle
val animalId: String? = savedStateHandle?.get<String>("animalId") val animalId: String? = savedStateHandle?.get<String>("animalId")

View File

@ -1,8 +1,12 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.content.ContentValues
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@ -12,6 +16,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -30,22 +35,30 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.exifinterface.media.ExifInterface
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.livingai.domain.camera.RealWorldMetrics import com.example.livingai.domain.camera.RealWorldMetrics
import com.example.livingai.domain.model.camera.* import com.example.livingai.domain.model.camera.*
import com.example.livingai.pages.components.CameraPreview import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.utils.SilhouetteManager import com.example.livingai.utils.SilhouetteManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.io.OutputStream
import kotlin.math.max import kotlin.math.max
@Composable @Composable
fun CameraCaptureScreen( fun CameraCaptureScreen(
navController: NavController, navController: NavController,
orientation: String?, orientation: String?,
animalId: String,
viewModel: CameraViewModel = koinViewModel() viewModel: CameraViewModel = koinViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(orientation) { LaunchedEffect(orientation) {
if (orientation != null) { if (orientation != null) {
@ -61,6 +74,71 @@ fun CameraCaptureScreen(
onRetake = { viewModel.resetCamera() }, onRetake = { viewModel.resetCamera() },
onSelectReference = { ref, height -> onSelectReference = { ref, height ->
viewModel.onReferenceObjectSelected(ref, height) viewModel.onReferenceObjectSelected(ref, height)
},
onAccept = {
scope.launch {
val bitmap = uiState.captureData!!.image
val filename = "${animalId}_${orientation ?: "unknown"}.jpg"
var uri: Uri? = null
withContext(Dispatchers.IO) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/LivingAI/$animalId")
}
}
val imageUri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
if (imageUri != null) {
val outputStream: OutputStream? = context.contentResolver.openOutputStream(imageUri)
outputStream?.use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
// Write Exif metadata for bounding box
val detection = uiState.lastDetectionResult
val analysisSize = uiState.analysisSize
var bounds = detection?.animalBounds
if (bounds != null && analysisSize != null) {
// Scale bounds to captured image size
val scaleX = bitmap.width.toFloat() / analysisSize.width
val scaleY = bitmap.height.toFloat() / analysisSize.height
bounds = RectF(
bounds.left * scaleX,
bounds.top * scaleY,
bounds.right * scaleX,
bounds.bottom * scaleY
)
}
if (bounds != null) {
try {
context.contentResolver.openFileDescriptor(imageUri, "rw")?.use { fd ->
val exif = ExifInterface(fd.fileDescriptor)
exif.setAttribute(ExifInterface.TAG_USER_COMMENT, "${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}")
exif.saveAttributes()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
uri = imageUri
}
}
if (uri != null) {
navController.previousBackStackEntry?.savedStateHandle?.set("newImageUri", uri.toString())
navController.previousBackStackEntry?.savedStateHandle?.set("newImageOrientation", orientation)
navController.popBackStack()
}
}
} }
) )
} else { } else {
@ -136,9 +214,16 @@ fun ActiveCameraScreen(
) )
} }
LaunchedEffect(uiState.shouldCapture) {
if (uiState.shouldCapture) {
captureImage()
viewModel.onCaptureTriggered()
}
}
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
if (uiState.isReadyToCapture) { if (uiState.isReadyToCapture && !uiState.isAutoCaptureOn) {
FloatingActionButton(onClick = { captureImage() }) { FloatingActionButton(onClick = { captureImage() }) {
Icon(Icons.Default.Camera, contentDescription = "Capture") Icon(Icons.Default.Camera, contentDescription = "Capture")
} }
@ -298,7 +383,8 @@ fun CapturePreviewScreen(
captureData: CaptureData, captureData: CaptureData,
realWorldMetrics: RealWorldMetrics?, realWorldMetrics: RealWorldMetrics?,
onRetake: () -> Unit, onRetake: () -> Unit,
onSelectReference: (ReferenceObject, Float) -> Unit onSelectReference: (ReferenceObject, Float) -> Unit,
onAccept: () -> Unit
) { ) {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
var selectedRefObject by remember { mutableStateOf<ReferenceObject?>(null) } var selectedRefObject by remember { mutableStateOf<ReferenceObject?>(null) }
@ -364,13 +450,10 @@ fun CapturePreviewScreen(
Text("Retake") Text("Retake")
} }
if (captureData.referenceObjects.isNotEmpty()) { Button(onClick = onAccept) {
Button(onClick = { Icon(Icons.Default.Check, contentDescription = null)
selectedRefObject = captureData.referenceObjects.first() Spacer(Modifier.width(8.dp))
showDialog = true Text("Accept")
}) {
Text("Select Ref Object")
}
} }
} }
} }

View File

@ -1,14 +1,20 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.Size
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.camera.* import com.example.livingai.domain.camera.*
import com.example.livingai.domain.model.camera.* import com.example.livingai.domain.model.camera.*
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.utils.TiltSensorManager import com.example.livingai.utils.TiltSensorManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class CameraViewModel( class CameraViewModel(
@ -18,7 +24,8 @@ class CameraViewModel(
private val poseAnalyzer: PoseAnalyzer, private val poseAnalyzer: PoseAnalyzer,
private val captureHandler: CaptureHandler, private val captureHandler: CaptureHandler,
private val measurementCalculator: MeasurementCalculator, private val measurementCalculator: MeasurementCalculator,
private val tiltSensorManager: TiltSensorManager private val tiltSensorManager: TiltSensorManager,
private val appDataUseCases: AppDataUseCases
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(CameraUiState()) private val _uiState = MutableStateFlow(CameraUiState())
@ -27,9 +34,18 @@ class CameraViewModel(
private var frameCounter = 0 private var frameCounter = 0
private val frameSkipInterval = 5 private val frameSkipInterval = 5
private var isAutoCaptureOn = false
init { init {
tiltSensorManager.start() tiltSensorManager.start()
observeSettings()
}
private fun observeSettings() {
appDataUseCases.getSettings().onEach {
isAutoCaptureOn = it.isAutoCaptureOn
_uiState.value = _uiState.value.copy(isAutoCaptureOn = it.isAutoCaptureOn)
}.launchIn(viewModelScope)
} }
override fun onCleared() { override fun onCleared() {
@ -59,6 +75,8 @@ class CameraViewModel(
viewModelScope.launch { viewModelScope.launch {
val currentTilt = tilt.value val currentTilt = tilt.value
val currentAnalysisSize = Size(image.width, image.height)
val input = PipelineInput( val input = PipelineInput(
image = image, image = image,
deviceOrientation = deviceOrientation, deviceOrientation = deviceOrientation,
@ -76,49 +94,58 @@ class CameraViewModel(
// Step 1: Check Orientation // Step 1: Check Orientation
val orientationInstruction = orientationChecker.analyze(input) val orientationInstruction = orientationChecker.analyze(input)
if (!orientationInstruction.isValid) { if (!orientationInstruction.isValid) {
updateState(orientationInstruction) updateState(orientationInstruction, analysisSize = currentAnalysisSize)
return@launch return@launch
} }
// Step 2: Check Tilt // Step 2: Check Tilt
val tiltInstruction = tiltChecker.analyze(input) val tiltInstruction = tiltChecker.analyze(input)
if (!tiltInstruction.isValid) { if (!tiltInstruction.isValid) {
updateState(tiltInstruction) updateState(tiltInstruction, analysisSize = currentAnalysisSize)
return@launch return@launch
} }
// Step 3: Detect Objects // Step 3: Detect Objects
val detectionInstruction = objectDetector.analyze(input) val detectionInstruction = objectDetector.analyze(input)
if (!detectionInstruction.isValid) { if (!detectionInstruction.isValid) {
updateState(detectionInstruction) updateState(detectionInstruction, analysisSize = currentAnalysisSize)
return@launch return@launch
} }
// Step 4: Check Pose (Silhouette matching) // Step 4: Check Pose (Silhouette matching)
val poseInstruction = poseAnalyzer.analyze(input.copy(previousDetectionResult = detectionInstruction.result as? DetectionResult)) val poseInstruction = poseAnalyzer.analyze(input.copy(previousDetectionResult = detectionInstruction.result as? DetectionResult))
if (!poseInstruction.isValid) { if (!poseInstruction.isValid) {
updateState(poseInstruction, detectionInstruction.result as? DetectionResult) updateState(poseInstruction, detectionInstruction.result as? DetectionResult, analysisSize = currentAnalysisSize)
return@launch return@launch
} }
// All checks passed // All checks passed
_uiState.value = _uiState.value.copy( val currentState = _uiState.value
_uiState.value = currentState.copy(
currentInstruction = Instruction("Ready to capture", isValid = true), currentInstruction = Instruction("Ready to capture", isValid = true),
isReadyToCapture = true, isReadyToCapture = true,
lastDetectionResult = detectionInstruction.result as? DetectionResult lastDetectionResult = detectionInstruction.result as? DetectionResult,
analysisSize = currentAnalysisSize,
shouldCapture = isAutoCaptureOn && !currentState.isPreviewMode && !currentState.shouldCapture
) )
} }
} }
private fun updateState(instruction: Instruction, latestDetectionResult: DetectionResult? = null) { private fun updateState(instruction: Instruction, latestDetectionResult: DetectionResult? = null, analysisSize: Size? = null) {
val detectionResult = (instruction.result as? DetectionResult) ?: latestDetectionResult val detectionResult = (instruction.result as? DetectionResult) ?: latestDetectionResult
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
currentInstruction = instruction, currentInstruction = instruction,
isReadyToCapture = false, isReadyToCapture = false,
lastDetectionResult = detectionResult ?: _uiState.value.lastDetectionResult lastDetectionResult = detectionResult ?: _uiState.value.lastDetectionResult,
analysisSize = analysisSize ?: _uiState.value.analysisSize,
shouldCapture = false
) )
} }
fun onCaptureTriggered() {
_uiState.value = _uiState.value.copy(shouldCapture = false)
}
fun onCaptureClicked( fun onCaptureClicked(
image: Bitmap, image: Bitmap,
deviceOrientation: Int, deviceOrientation: Int,
@ -127,8 +154,36 @@ class CameraViewModel(
) { ) {
viewModelScope.launch { viewModelScope.launch {
val detectionResult = _uiState.value.lastDetectionResult ?: return@launch val detectionResult = _uiState.value.lastDetectionResult ?: return@launch
val analysisSize = _uiState.value.analysisSize
val currentTilt = tilt.value val currentTilt = tilt.value
// Scale detection result to match capture image resolution
val scaledDetectionResult = if (analysisSize != null) {
val scaleX = image.width.toFloat() / analysisSize.width
val scaleY = image.height.toFloat() / analysisSize.height
val matrix = Matrix()
matrix.postScale(scaleX, scaleY)
val scaledBounds = detectionResult.animalBounds?.let {
val r = RectF(it)
matrix.mapRect(r)
r
}
val scaledRefs = detectionResult.referenceObjects.map { ref ->
val r = RectF(ref.bounds)
matrix.mapRect(r)
ref.copy(bounds = r)
}
detectionResult.copy(
animalBounds = scaledBounds,
referenceObjects = scaledRefs
)
} else {
detectionResult
}
val input = PipelineInput( val input = PipelineInput(
image = image, image = image,
deviceOrientation = deviceOrientation, deviceOrientation = deviceOrientation,
@ -142,7 +197,7 @@ class CameraViewModel(
orientation = _uiState.value.targetOrientation orientation = _uiState.value.targetOrientation
) )
val captureData = captureHandler.capture(input, detectionResult) val captureData = captureHandler.capture(input, scaledDetectionResult)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
captureData = captureData, captureData = captureData,
@ -171,7 +226,8 @@ class CameraViewModel(
captureData = null, captureData = null,
realWorldMetrics = null, realWorldMetrics = null,
isReadyToCapture = false, isReadyToCapture = false,
currentInstruction = null currentInstruction = null,
shouldCapture = false
) )
} }
} }
@ -183,7 +239,10 @@ data class CameraUiState(
val currentInstruction: Instruction? = null, val currentInstruction: Instruction? = null,
val isReadyToCapture: Boolean = false, val isReadyToCapture: Boolean = false,
val lastDetectionResult: DetectionResult? = null, val lastDetectionResult: DetectionResult? = null,
val analysisSize: Size? = null,
val isPreviewMode: Boolean = false, val isPreviewMode: Boolean = false,
val captureData: CaptureData? = null, val captureData: CaptureData? = null,
val realWorldMetrics: RealWorldMetrics? = null val realWorldMetrics: RealWorldMetrics? = null,
val shouldCapture: Boolean = false,
val isAutoCaptureOn: Boolean = false
) )

View File

@ -1,6 +1,7 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -17,11 +18,26 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.exifinterface.media.ExifInterface
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import com.example.livingai.ui.theme.LivingAITheme import com.example.livingai.ui.theme.LivingAITheme
import java.io.InputStream
import kotlin.math.max
import kotlin.math.min
@Composable @Composable
fun ViewImageScreen( fun ViewImageScreen(
@ -33,16 +49,94 @@ fun ViewImageScreen(
onAccept: () -> Unit, onAccept: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val context = LocalContext.current
var boundingBox by remember { mutableStateOf<String?>(null) }
var imageWidth by remember { mutableStateOf(0f) }
var imageHeight by remember { mutableStateOf(0f) }
LaunchedEffect(imageUri) {
val uri = Uri.parse(imageUri)
try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val exif = ExifInterface(inputStream)
boundingBox = exif.getAttribute(ExifInterface.TAG_USER_COMMENT)
// Get image dimensions from Exif if possible, or we will rely on loading
val width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
val height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
if (width > 0 && height > 0) {
imageWidth = width.toFloat()
imageHeight = height.toFloat()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
LivingAITheme { LivingAITheme {
Scaffold { Scaffold {
Box(modifier = Modifier Box(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it)) { .padding(it)) {
Image( Image(
painter = rememberAsyncImagePainter(model = Uri.parse(imageUri)), painter = rememberAsyncImagePainter(
model = Uri.parse(imageUri),
onSuccess = { state ->
if (imageWidth == 0f || imageHeight == 0f) {
imageWidth = state.result.drawable.intrinsicWidth.toFloat()
imageHeight = state.result.drawable.intrinsicHeight.toFloat()
}
}
),
contentDescription = "Captured Image", contentDescription = "Captured Image",
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alignment = Alignment.Center
) )
// Draw Bounding Box if available
if (boundingBox != null && imageWidth > 0 && imageHeight > 0) {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
// Parse bounding box string "left,top,right,bottom"
val parts = boundingBox!!.split(",")
if (parts.size == 4) {
val left = parts[0].toFloatOrNull() ?: 0f
val top = parts[1].toFloatOrNull() ?: 0f
val right = parts[2].toFloatOrNull() ?: 0f
val bottom = parts[3].toFloatOrNull() ?: 0f
// Calculate scale and offset (CenterInside/Fit logic)
// Image composable uses 'Fit' by default which scales to fit within bounds while maintaining aspect ratio
val widthRatio = canvasWidth / imageWidth
val heightRatio = canvasHeight / imageHeight
val scale = min(widthRatio, heightRatio)
val displayedWidth = imageWidth * scale
val displayedHeight = imageHeight * scale
val offsetX = (canvasWidth - displayedWidth) / 2
val offsetY = (canvasHeight - displayedHeight) / 2
// Transform coordinates
val rectLeft = left * scale + offsetX
val rectTop = top * scale + offsetY
val rectRight = right * scale + offsetX
val rectBottom = bottom * scale + offsetY
drawRect(
color = Color.Yellow,
topLeft = Offset(rectLeft, rectTop),
size = Size(rectRight - rectLeft, rectBottom - rectTop),
style = Stroke(width = 3.dp.toPx())
)
}
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -27,7 +27,9 @@ fun LabeledDropdown(
options: List<String>, options: List<String>,
selected: String?, selected: String?,
onSelected: (String) -> Unit, onSelected: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isError: Boolean = false,
supportingText: String? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@ -46,7 +48,11 @@ fun LabeledDropdown(
}, },
modifier = Modifier modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryEditable, true) .menuAnchor(MenuAnchorType.PrimaryEditable, true)
.fillMaxWidth() .fillMaxWidth(),
isError = isError,
supportingText = if (supportingText != null) {
{ Text(supportingText) }
} else null
) )
ExposedDropdownMenu( ExposedDropdownMenu(

View File

@ -19,7 +19,9 @@ fun LabeledTextField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text keyboardType: KeyboardType = KeyboardType.Text,
isError: Boolean = false,
supportingText: String? = null
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
OutlinedTextField( OutlinedTextField(
@ -27,7 +29,11 @@ fun LabeledTextField(
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(id = labelRes)) }, label = { Text(stringResource(id = labelRes)) },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType) keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
isError = isError,
supportingText = if (supportingText != null) {
{ Text(supportingText) }
} else null
) )
} }
} }

View File

@ -20,13 +20,14 @@ fun RadioGroup(
@StringRes titleRes: Int, @StringRes titleRes: Int,
options: List<String>, options: List<String>,
selected: String?, selected: String?,
onSelected: (String) -> Unit onSelected: (String) -> Unit,
isError: Boolean = false
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = stringResource(id = titleRes), text = stringResource(id = titleRes),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
) )
Row( Row(

View File

@ -85,24 +85,24 @@ fun HomeScreen(navController: NavController) {
onClick = { navController.navigate(Route.AddProfileScreen()) } onClick = { navController.navigate(Route.AddProfileScreen()) }
) )
Spacer(modifier = Modifier.height(Dimentions.SMALL_PADDING)) // Spacer(modifier = Modifier.height(Dimentions.SMALL_PADDING))
//
// Dropdown for selecting orientation // // Dropdown for selecting orientation
LabeledDropdown( // LabeledDropdown(
labelRes = R.string.default_orientation_label, // Or create a generic "Orientation" label // labelRes = R.string.default_orientation_label, // Or create a generic "Orientation" label
options = orientationOptions, // options = orientationOptions,
selected = selectedOrientationDisplay, // selected = selectedOrientationDisplay,
onSelected = { selectedOrientationDisplay = it }, // onSelected = { selectedOrientationDisplay = it },
modifier = Modifier.fillMaxWidth() // modifier = Modifier.fillMaxWidth()
) // )
//
HomeButton( // HomeButton(
text = "Camera Capture", // text = "Camera Capture",
onClick = { // onClick = {
val orientationId = displayToIdMap[selectedOrientationDisplay] ?: "side" // val orientationId = displayToIdMap[selectedOrientationDisplay] ?: "side"
navController.navigate(Route.CameraScreen(orientation = orientationId)) // navController.navigate(Route.CameraScreen(orientation = orientationId, animalId = "home_test"))
} // }
) // )
} }
} }

View File

@ -21,11 +21,11 @@ class HomeViewModel(
init { init {
appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen -> appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
if(shouldStartFromHomeScreen){ // if(shouldStartFromHomeScreen){
_startDestination.value = Route.HomeNavigation _startDestination.value = Route.HomeNavigation
}else{ // }else{
_startDestination.value = Route.AppStartNavigation // _startDestination.value = Route.AppStartNavigation
} // }
delay(350) //Without this delay, the onBoarding screen will show for a momentum. delay(350) //Without this delay, the onBoarding screen will show for a momentum.
_splashCondition.value = false _splashCondition.value = false
}.launchIn(viewModelScope) }.launchIn(viewModelScope)

View File

@ -98,7 +98,7 @@ fun NavGraph(
animalId = currentId ?: "unknown" animalId = currentId ?: "unknown"
)) ))
} else { } else {
// navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown")) // Commented until existing camera flow is restored or migrated navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown"))
} }
}, },
onTakeVideo = { onTakeVideo = {
@ -126,7 +126,7 @@ fun NavGraph(
composable<Route.CameraScreen> { backStackEntry -> composable<Route.CameraScreen> { backStackEntry ->
val route: Route.CameraScreen = backStackEntry.toRoute() val route: Route.CameraScreen = backStackEntry.toRoute()
CameraCaptureScreen(navController = navController, orientation = route.orientation) CameraCaptureScreen(navController = navController, orientation = route.orientation, animalId = route.animalId)
} }
composable<Route.VideoRecordScreen> { backStackEntry -> composable<Route.VideoRecordScreen> { backStackEntry ->

View File

@ -19,7 +19,7 @@ sealed class Route {
@Serializable @Serializable
data class RatingScreen(val animalId: String) : Route() data class RatingScreen(val animalId: String) : Route()
@Serializable @Serializable
data class CameraScreen(val orientation: String) : Route() data class CameraScreen(val orientation: String, val animalId: String) : Route()
@Serializable @Serializable
data class OldCameraScreen(val orientation: String? = null, val animalId: String) : Route() data class OldCameraScreen(val orientation: String? = null, val animalId: String) : Route()
@Serializable @Serializable