diff --git a/.kotlin/sessions/kotlin-compiler-4779573445479273624.salive b/.kotlin/sessions/kotlin-compiler-4779573445479273624.salive deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/assets/midas_v2_1_small.tflite b/app/src/main/assets/midas_v2_1_small.tflite deleted file mode 100644 index f1384b2..0000000 Binary files a/app/src/main/assets/midas_v2_1_small.tflite and /dev/null differ diff --git a/app/src/main/java/com/example/livingai/data/ml/MidasDepthEstimator.kt b/app/src/main/java/com/example/livingai/data/ml/MidasDepthEstimator.kt index 25a277f..3d2fead 100644 --- a/app/src/main/java/com/example/livingai/data/ml/MidasDepthEstimator.kt +++ b/app/src/main/java/com/example/livingai/data/ml/MidasDepthEstimator.kt @@ -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) diff --git a/app/src/main/java/com/example/livingai/di/AppModule.kt b/app/src/main/java/com/example/livingai/di/AppModule.kt index 463fb5f..3441aa9 100644 --- a/app/src/main/java/com/example/livingai/di/AppModule.kt +++ b/app/src/main/java/com/example/livingai/di/AppModule.kt @@ -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() } diff --git a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt index b14a9e0..61331d3 100644 --- a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt @@ -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, diff --git a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt index 3b43c20..6ce96a1 100644 --- a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt @@ -41,6 +41,18 @@ class AddProfileViewModel( var calvingNumber = mutableStateOf("") var reproductiveStatus = mutableStateOf(null) var description = mutableStateOf("") + + // Errors + var ageError = mutableStateOf(null) + var milkYieldError = mutableStateOf(null) + var calvingNumberError = mutableStateOf(null) + var speciesError = mutableStateOf(null) + var breedError = mutableStateOf(null) + var reproductiveStatusError = mutableStateOf(null) + + // Save state + private val _saveSuccess = mutableStateOf(false) + val saveSuccess: State = _saveSuccess // State for photos and video val photos = mutableStateMapOf() @@ -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 diff --git a/app/src/main/java/com/example/livingai/pages/camera/CameraCaptureScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/CameraCaptureScreen.kt index 428fce4..5894f78 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/CameraCaptureScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/CameraCaptureScreen.kt @@ -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(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") } } } diff --git a/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt b/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt index c31be4f..a2bf7a2 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt @@ -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 ) diff --git a/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt index 9d1b257..7deec47 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt @@ -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(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() diff --git a/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt b/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt index 90fd141..eb33421 100644 --- a/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt +++ b/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt @@ -27,7 +27,9 @@ fun LabeledDropdown( options: List, 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( diff --git a/app/src/main/java/com/example/livingai/pages/components/LabeledTextField.kt b/app/src/main/java/com/example/livingai/pages/components/LabeledTextField.kt index d01acb5..e41171f 100644 --- a/app/src/main/java/com/example/livingai/pages/components/LabeledTextField.kt +++ b/app/src/main/java/com/example/livingai/pages/components/LabeledTextField.kt @@ -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 ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/pages/components/RadioGroup.kt b/app/src/main/java/com/example/livingai/pages/components/RadioGroup.kt index d4bb633..42ee49e 100644 --- a/app/src/main/java/com/example/livingai/pages/components/RadioGroup.kt +++ b/app/src/main/java/com/example/livingai/pages/components/RadioGroup.kt @@ -20,13 +20,14 @@ fun RadioGroup( @StringRes titleRes: Int, options: List, 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( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt b/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt index 27c444a..a71970b 100644 --- a/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt @@ -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")) +// } +// ) } } diff --git a/app/src/main/java/com/example/livingai/pages/home/HomeViewModel.kt b/app/src/main/java/com/example/livingai/pages/home/HomeViewModel.kt index 5c384ba..7fc3078 100644 --- a/app/src/main/java/com/example/livingai/pages/home/HomeViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/home/HomeViewModel.kt @@ -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) diff --git a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt index 4228dff..d73df8c 100644 --- a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt @@ -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 { backStackEntry -> val route: Route.CameraScreen = backStackEntry.toRoute() - CameraCaptureScreen(navController = navController, orientation = route.orientation) + CameraCaptureScreen(navController = navController, orientation = route.orientation, animalId = route.animalId) } composable { backStackEntry -> @@ -172,4 +172,4 @@ fun NavGraph( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/livingai/pages/navigation/Route.kt b/app/src/main/java/com/example/livingai/pages/navigation/Route.kt index 69239b6..83945a1 100644 --- a/app/src/main/java/com/example/livingai/pages/navigation/Route.kt +++ b/app/src/main/java/com/example/livingai/pages/navigation/Route.kt @@ -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