app integration
This commit is contained in:
parent
3bbf128d9c
commit
bcb4ee1a39
Binary file not shown.
|
|
@ -22,7 +22,7 @@ class MidasDepthEstimator(private val context: Context) {
|
|||
private var interpreter: Interpreter? = null
|
||||
|
||||
companion object {
|
||||
private const val MODEL_NAME = "midas_v2_1_small.tflite"
|
||||
private const val MODEL_NAME = ""
|
||||
private const val INPUT_SIZE = 256
|
||||
|
||||
private val NORM_MEAN = floatArrayOf(123.675f, 116.28f, 103.53f)
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ val appModule = module {
|
|||
viewModel { ListingsViewModel(get()) }
|
||||
viewModel { SettingsViewModel(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 { ImagePreviewViewModel() }
|
||||
viewModel { VideoPreviewViewModel() }
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ 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
|
||||
|
|
@ -80,9 +83,40 @@ fun AddProfileScreen(
|
|||
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() }
|
||||
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(
|
||||
navController = navController,
|
||||
title = stringResource(id = R.string.top_bar_add_profile)
|
||||
|
|
@ -103,14 +137,20 @@ fun AddProfileScreen(
|
|||
labelRes = R.string.label_species,
|
||||
options = speciesList,
|
||||
selected = species,
|
||||
onSelected = { species = it }
|
||||
onSelected = { species = it },
|
||||
modifier = Modifier.focusRequester(speciesFocus),
|
||||
isError = speciesError != null,
|
||||
supportingText = speciesError
|
||||
)
|
||||
|
||||
LabeledDropdown(
|
||||
labelRes = R.string.label_breed,
|
||||
options = breedList,
|
||||
selected = breed,
|
||||
onSelected = { breed = it }
|
||||
onSelected = { breed = it },
|
||||
modifier = Modifier.focusRequester(breedFocus),
|
||||
isError = breedError != null,
|
||||
supportingText = breedError
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -120,34 +160,55 @@ fun AddProfileScreen(
|
|||
LabeledTextField(
|
||||
labelRes = R.string.label_age,
|
||||
value = age,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(ageFocus),
|
||||
onValueChange = { age = it },
|
||||
keyboardType = KeyboardType.Number
|
||||
keyboardType = KeyboardType.Number,
|
||||
isError = ageError != null,
|
||||
supportingText = ageError
|
||||
)
|
||||
|
||||
LabeledTextField(
|
||||
labelRes = R.string.label_milk_yield,
|
||||
value = milkYield,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(milkYieldFocus),
|
||||
onValueChange = { milkYield = it },
|
||||
keyboardType = KeyboardType.Number
|
||||
keyboardType = KeyboardType.Number,
|
||||
isError = milkYieldError != null,
|
||||
supportingText = milkYieldError
|
||||
)
|
||||
}
|
||||
|
||||
LabeledTextField(
|
||||
labelRes = R.string.label_calving_number,
|
||||
value = calvingNumber,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(calvingNumberFocus),
|
||||
onValueChange = { calvingNumber = it },
|
||||
keyboardType = KeyboardType.Number
|
||||
keyboardType = KeyboardType.Number,
|
||||
isError = calvingNumberError != null,
|
||||
supportingText = calvingNumberError
|
||||
)
|
||||
|
||||
RadioGroup(
|
||||
titleRes = R.string.label_reproductive_status,
|
||||
options = reproList,
|
||||
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(
|
||||
labelRes = R.string.label_description,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ class AddProfileViewModel(
|
|||
var calvingNumber = mutableStateOf("")
|
||||
var reproductiveStatus = mutableStateOf<String?>(null)
|
||||
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
|
||||
val photos = mutableStateMapOf<String, String>()
|
||||
|
|
@ -61,6 +73,7 @@ class AddProfileViewModel(
|
|||
calvingNumber.value = ""
|
||||
reproductiveStatus.value = null
|
||||
description.value = ""
|
||||
clearErrors()
|
||||
|
||||
photos.clear()
|
||||
_videoUri.value = null
|
||||
|
|
@ -78,6 +91,7 @@ class AddProfileViewModel(
|
|||
calvingNumber.value = if (details.calvingNumber == 0) "" else details.calvingNumber.toString()
|
||||
reproductiveStatus.value = details.reproductiveStatus.ifBlank { null }
|
||||
description.value = details.description
|
||||
clearErrors()
|
||||
|
||||
// Populate photos
|
||||
photos.clear()
|
||||
|
|
@ -103,6 +117,15 @@ class AddProfileViewModel(
|
|||
}.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
var result: String? = null
|
||||
|
|
@ -139,7 +162,60 @@ class AddProfileViewModel(
|
|||
_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() {
|
||||
if (!validateInputs()) return
|
||||
|
||||
val id = _currentAnimalId.value ?: IdGenerator.generateAnimalId().also { _currentAnimalId.value = it }
|
||||
|
||||
val details = AnimalDetails(
|
||||
|
|
@ -158,8 +234,13 @@ class AddProfileViewModel(
|
|||
|
||||
viewModelScope.launch {
|
||||
profileEntryUseCase.setAnimalDetails(details)
|
||||
_saveSuccess.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onSaveComplete() {
|
||||
_saveSuccess.value = false
|
||||
}
|
||||
|
||||
init {
|
||||
// Try to auto-load when editing via saved state handle
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package com.example.livingai.pages.camera
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.compose.foundation.Canvas
|
||||
|
|
@ -12,6 +16,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
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.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.navigation.NavController
|
||||
import com.example.livingai.domain.camera.RealWorldMetrics
|
||||
import com.example.livingai.domain.model.camera.*
|
||||
import com.example.livingai.pages.components.CameraPreview
|
||||
import com.example.livingai.pages.components.PermissionWrapper
|
||||
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 java.io.OutputStream
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun CameraCaptureScreen(
|
||||
navController: NavController,
|
||||
orientation: String?,
|
||||
animalId: String,
|
||||
viewModel: CameraViewModel = koinViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(orientation) {
|
||||
if (orientation != null) {
|
||||
|
|
@ -61,6 +74,71 @@ fun CameraCaptureScreen(
|
|||
onRetake = { viewModel.resetCamera() },
|
||||
onSelectReference = { 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 {
|
||||
|
|
@ -136,9 +214,16 @@ fun ActiveCameraScreen(
|
|||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.shouldCapture) {
|
||||
if (uiState.shouldCapture) {
|
||||
captureImage()
|
||||
viewModel.onCaptureTriggered()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
if (uiState.isReadyToCapture) {
|
||||
if (uiState.isReadyToCapture && !uiState.isAutoCaptureOn) {
|
||||
FloatingActionButton(onClick = { captureImage() }) {
|
||||
Icon(Icons.Default.Camera, contentDescription = "Capture")
|
||||
}
|
||||
|
|
@ -298,7 +383,8 @@ fun CapturePreviewScreen(
|
|||
captureData: CaptureData,
|
||||
realWorldMetrics: RealWorldMetrics?,
|
||||
onRetake: () -> Unit,
|
||||
onSelectReference: (ReferenceObject, Float) -> Unit
|
||||
onSelectReference: (ReferenceObject, Float) -> Unit,
|
||||
onAccept: () -> Unit
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var selectedRefObject by remember { mutableStateOf<ReferenceObject?>(null) }
|
||||
|
|
@ -364,13 +450,10 @@ fun CapturePreviewScreen(
|
|||
Text("Retake")
|
||||
}
|
||||
|
||||
if (captureData.referenceObjects.isNotEmpty()) {
|
||||
Button(onClick = {
|
||||
selectedRefObject = captureData.referenceObjects.first()
|
||||
showDialog = true
|
||||
}) {
|
||||
Text("Select Ref Object")
|
||||
}
|
||||
Button(onClick = onAccept) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Accept")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
package com.example.livingai.pages.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.util.Size
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.livingai.domain.camera.*
|
||||
import com.example.livingai.domain.model.camera.*
|
||||
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||
import com.example.livingai.utils.TiltSensorManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CameraViewModel(
|
||||
|
|
@ -18,7 +24,8 @@ class CameraViewModel(
|
|||
private val poseAnalyzer: PoseAnalyzer,
|
||||
private val captureHandler: CaptureHandler,
|
||||
private val measurementCalculator: MeasurementCalculator,
|
||||
private val tiltSensorManager: TiltSensorManager
|
||||
private val tiltSensorManager: TiltSensorManager,
|
||||
private val appDataUseCases: AppDataUseCases
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(CameraUiState())
|
||||
|
|
@ -27,9 +34,18 @@ class CameraViewModel(
|
|||
|
||||
private var frameCounter = 0
|
||||
private val frameSkipInterval = 5
|
||||
private var isAutoCaptureOn = false
|
||||
|
||||
init {
|
||||
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() {
|
||||
|
|
@ -59,6 +75,8 @@ class CameraViewModel(
|
|||
|
||||
viewModelScope.launch {
|
||||
val currentTilt = tilt.value
|
||||
val currentAnalysisSize = Size(image.width, image.height)
|
||||
|
||||
val input = PipelineInput(
|
||||
image = image,
|
||||
deviceOrientation = deviceOrientation,
|
||||
|
|
@ -76,49 +94,58 @@ class CameraViewModel(
|
|||
// Step 1: Check Orientation
|
||||
val orientationInstruction = orientationChecker.analyze(input)
|
||||
if (!orientationInstruction.isValid) {
|
||||
updateState(orientationInstruction)
|
||||
updateState(orientationInstruction, analysisSize = currentAnalysisSize)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Step 2: Check Tilt
|
||||
val tiltInstruction = tiltChecker.analyze(input)
|
||||
if (!tiltInstruction.isValid) {
|
||||
updateState(tiltInstruction)
|
||||
updateState(tiltInstruction, analysisSize = currentAnalysisSize)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Step 3: Detect Objects
|
||||
val detectionInstruction = objectDetector.analyze(input)
|
||||
if (!detectionInstruction.isValid) {
|
||||
updateState(detectionInstruction)
|
||||
updateState(detectionInstruction, analysisSize = currentAnalysisSize)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Step 4: Check Pose (Silhouette matching)
|
||||
val poseInstruction = poseAnalyzer.analyze(input.copy(previousDetectionResult = detectionInstruction.result as? DetectionResult))
|
||||
if (!poseInstruction.isValid) {
|
||||
updateState(poseInstruction, detectionInstruction.result as? DetectionResult)
|
||||
updateState(poseInstruction, detectionInstruction.result as? DetectionResult, analysisSize = currentAnalysisSize)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
_uiState.value = _uiState.value.copy(
|
||||
val currentState = _uiState.value
|
||||
_uiState.value = currentState.copy(
|
||||
currentInstruction = Instruction("Ready to capture", isValid = 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
|
||||
_uiState.value = _uiState.value.copy(
|
||||
currentInstruction = instruction,
|
||||
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(
|
||||
image: Bitmap,
|
||||
deviceOrientation: Int,
|
||||
|
|
@ -127,8 +154,36 @@ class CameraViewModel(
|
|||
) {
|
||||
viewModelScope.launch {
|
||||
val detectionResult = _uiState.value.lastDetectionResult ?: return@launch
|
||||
val analysisSize = _uiState.value.analysisSize
|
||||
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(
|
||||
image = image,
|
||||
deviceOrientation = deviceOrientation,
|
||||
|
|
@ -142,7 +197,7 @@ class CameraViewModel(
|
|||
orientation = _uiState.value.targetOrientation
|
||||
)
|
||||
|
||||
val captureData = captureHandler.capture(input, detectionResult)
|
||||
val captureData = captureHandler.capture(input, scaledDetectionResult)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
captureData = captureData,
|
||||
|
|
@ -171,7 +226,8 @@ class CameraViewModel(
|
|||
captureData = null,
|
||||
realWorldMetrics = null,
|
||||
isReadyToCapture = false,
|
||||
currentInstruction = null
|
||||
currentInstruction = null,
|
||||
shouldCapture = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +239,10 @@ data class CameraUiState(
|
|||
val currentInstruction: Instruction? = null,
|
||||
val isReadyToCapture: Boolean = false,
|
||||
val lastDetectionResult: DetectionResult? = null,
|
||||
val analysisSize: Size? = null,
|
||||
val isPreviewMode: Boolean = false,
|
||||
val captureData: CaptureData? = null,
|
||||
val realWorldMetrics: RealWorldMetrics? = null
|
||||
val realWorldMetrics: RealWorldMetrics? = null,
|
||||
val shouldCapture: Boolean = false,
|
||||
val isAutoCaptureOn: Boolean = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.example.livingai.pages.camera
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -17,11 +18,26 @@ import androidx.compose.material3.OutlinedButton
|
|||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
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.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.exifinterface.media.ExifInterface
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.example.livingai.ui.theme.LivingAITheme
|
||||
import java.io.InputStream
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun ViewImageScreen(
|
||||
|
|
@ -33,16 +49,94 @@ fun ViewImageScreen(
|
|||
onAccept: () -> 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 {
|
||||
Scaffold {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)) {
|
||||
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",
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ fun LabeledDropdown(
|
|||
options: List<String>,
|
||||
selected: String?,
|
||||
onSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
supportingText: String? = null
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -46,7 +48,11 @@ fun LabeledDropdown(
|
|||
},
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryEditable, true)
|
||||
.fillMaxWidth()
|
||||
.fillMaxWidth(),
|
||||
isError = isError,
|
||||
supportingText = if (supportingText != null) {
|
||||
{ Text(supportingText) }
|
||||
} else null
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ fun LabeledTextField(
|
|||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardType: KeyboardType = KeyboardType.Text
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
isError: Boolean = false,
|
||||
supportingText: String? = null
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
|
|
@ -27,7 +29,11 @@ fun LabeledTextField(
|
|||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(id = labelRes)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType)
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
isError = isError,
|
||||
supportingText = if (supportingText != null) {
|
||||
{ Text(supportingText) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ fun RadioGroup(
|
|||
@StringRes titleRes: Int,
|
||||
options: List<String>,
|
||||
selected: String?,
|
||||
onSelected: (String) -> Unit
|
||||
onSelected: (String) -> Unit,
|
||||
isError: Boolean = false
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(id = titleRes),
|
||||
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -51,4 +52,4 @@ fun RadioGroup(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,24 +85,24 @@ fun HomeScreen(navController: NavController) {
|
|||
onClick = { navController.navigate(Route.AddProfileScreen()) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Dimentions.SMALL_PADDING))
|
||||
|
||||
// Dropdown for selecting orientation
|
||||
LabeledDropdown(
|
||||
labelRes = R.string.default_orientation_label, // Or create a generic "Orientation" label
|
||||
options = orientationOptions,
|
||||
selected = selectedOrientationDisplay,
|
||||
onSelected = { selectedOrientationDisplay = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HomeButton(
|
||||
text = "Camera Capture",
|
||||
onClick = {
|
||||
val orientationId = displayToIdMap[selectedOrientationDisplay] ?: "side"
|
||||
navController.navigate(Route.CameraScreen(orientation = orientationId))
|
||||
}
|
||||
)
|
||||
// Spacer(modifier = Modifier.height(Dimentions.SMALL_PADDING))
|
||||
//
|
||||
// // Dropdown for selecting orientation
|
||||
// LabeledDropdown(
|
||||
// labelRes = R.string.default_orientation_label, // Or create a generic "Orientation" label
|
||||
// options = orientationOptions,
|
||||
// selected = selectedOrientationDisplay,
|
||||
// onSelected = { selectedOrientationDisplay = it },
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// )
|
||||
//
|
||||
// HomeButton(
|
||||
// text = "Camera Capture",
|
||||
// onClick = {
|
||||
// val orientationId = displayToIdMap[selectedOrientationDisplay] ?: "side"
|
||||
// navController.navigate(Route.CameraScreen(orientation = orientationId, animalId = "home_test"))
|
||||
// }
|
||||
// )
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ class HomeViewModel(
|
|||
|
||||
init {
|
||||
appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
|
||||
if(shouldStartFromHomeScreen){
|
||||
// if(shouldStartFromHomeScreen){
|
||||
_startDestination.value = Route.HomeNavigation
|
||||
}else{
|
||||
_startDestination.value = Route.AppStartNavigation
|
||||
}
|
||||
// }else{
|
||||
// _startDestination.value = Route.AppStartNavigation
|
||||
// }
|
||||
delay(350) //Without this delay, the onBoarding screen will show for a momentum.
|
||||
_splashCondition.value = false
|
||||
}.launchIn(viewModelScope)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ fun NavGraph(
|
|||
animalId = currentId ?: "unknown"
|
||||
))
|
||||
} 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 = {
|
||||
|
|
@ -126,7 +126,7 @@ fun NavGraph(
|
|||
|
||||
composable<Route.CameraScreen> { backStackEntry ->
|
||||
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 ->
|
||||
|
|
@ -172,4 +172,4 @@ fun NavGraph(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ sealed class Route {
|
|||
@Serializable
|
||||
data class RatingScreen(val animalId: String) : Route()
|
||||
@Serializable
|
||||
data class CameraScreen(val orientation: String) : Route()
|
||||
data class CameraScreen(val orientation: String, val animalId: String) : Route()
|
||||
@Serializable
|
||||
data class OldCameraScreen(val orientation: String? = null, val animalId: String) : Route()
|
||||
@Serializable
|
||||
|
|
|
|||
Loading…
Reference in New Issue