diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt index ac9f8dc983..472e59c831 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt @@ -27,6 +27,7 @@ interface ComputerVisionRepository { suspend fun generateXml( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt index 631fb282a1..991d4b7792 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt @@ -56,6 +56,7 @@ class ComputerVisionRepositoryImpl( override suspend fun generateXml( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -65,6 +66,7 @@ class ComputerVisionRepositoryImpl( YoloToXmlConverter.generateXmlLayout( detections = detections, annotations = annotations, + selectedImagesByPlaceholderId = selectedImagesByPlaceholderId, sourceImageWidth = sourceImageWidth, sourceImageHeight = sourceImageHeight, targetDpWidth = targetDpWidth, diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt new file mode 100644 index 0000000000..fd78361327 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt @@ -0,0 +1,112 @@ +package org.appdevforall.codeonthego.computervision.data.repository + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale + +class DrawableImportHelper( + private val contentResolver: ContentResolver +) { + + suspend fun importDrawable( + sourceUri: Uri, + layoutFilePath: String?, + fallbackName: String + ): Result = withContext(Dispatchers.IO) { + runCatching { + requireNotNull(layoutFilePath) { "Layout file path is not available." } + val layoutFile = File(layoutFilePath) + + val drawableDir = resolveDrawableDir(layoutFile) + check(drawableDir.exists() || drawableDir.mkdirs()) { + "Could not create drawable directory: ${drawableDir.absolutePath}" + } + + val extension = resolveSupportedExtension(sourceUri) + val baseName = sanitizeResourceName(resolveDisplayName(sourceUri) ?: fallbackName) + val destinationFile = resolveAvailableFile(drawableDir, baseName, extension) + + contentResolver.openInputStream(sourceUri)?.use { input -> + destinationFile.outputStream().use(input::copyTo) + } ?: error("Could not open selected image.") + + ImportedDrawable( + resourceName = destinationFile.nameWithoutExtension, + drawableReference = "@drawable/${destinationFile.nameWithoutExtension}", + file = destinationFile + ) + } + } + + private fun resolveDrawableDir(layoutFile: File): File { + val resDir = generateSequence(layoutFile.parentFile) { it.parentFile } + .firstOrNull { it.name == "res" } + ?: throw IllegalStateException("Could not resolve res directory from: ${layoutFile.absolutePath}") + + return File(resDir, "drawable") + } + + private fun resolveDisplayName(uri: Uri): String? { + return contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + } + + private fun resolveSupportedExtension(uri: Uri): String { + val displayName = resolveDisplayName(uri) + val extension = displayName + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.lowercase(Locale.US) + .orEmpty() + + return when (extension) { + "png", "jpg", "jpeg", "webp" -> extension + else -> throw IllegalArgumentException("Unsupported image format. Use PNG, JPG, JPEG, or WEBP.") + } + } + + private fun sanitizeResourceName(rawName: String): String { + val nameWithoutExtension = rawName.substringBeforeLast('.') + val normalized = nameWithoutExtension + .lowercase(Locale.US) + .replace(Regex("[^a-z0-9_]"), "_") + .replace(Regex("_+"), "_") + .trim('_') + + val safeName = normalized.ifBlank { "imported_image" } + + return if (safeName.first().isDigit()) { + "img_$safeName" + } else { + safeName + } + } + + private fun resolveAvailableFile( + drawableDir: File, + baseName: String, + extension: String + ): File { + var candidate = File(drawableDir, "$baseName.$extension") + var index = 1 + + while (candidate.exists()) { + candidate = File(drawableDir, "${baseName}_$index.$extension") + index++ + } + + return candidate + } +} + +data class ImportedDrawable( + val resourceName: String, + val drawableReference: String, + val file: File +) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt index bc0583d16d..65842dfdb0 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt @@ -2,6 +2,7 @@ package org.appdevforall.codeonthego.computervision.di import org.appdevforall.codeonthego.computervision.data.repository.ComputerVisionRepository import org.appdevforall.codeonthego.computervision.data.repository.ComputerVisionRepositoryImpl +import org.appdevforall.codeonthego.computervision.data.repository.DrawableImportHelper import org.appdevforall.codeonthego.computervision.data.source.OcrSource import org.appdevforall.codeonthego.computervision.data.source.YoloModelSource import org.appdevforall.codeonthego.computervision.domain.RegionOcrProcessor @@ -26,9 +27,16 @@ val computerVisionModule = module { ) } + single { + DrawableImportHelper( + contentResolver = androidContext().contentResolver + ) + } + viewModel { (layoutFilePath: String?, layoutFileName: String?) -> ComputerVisionViewModel( repository = get(), + drawableImportHelper = get(), contentResolver = androidContext().contentResolver, layoutFilePath = layoutFilePath, layoutFileName = layoutFileName diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/MarginAnnotationParser.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/MarginAnnotationParser.kt index 7977ea0678..66cdd234a2 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/MarginAnnotationParser.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/MarginAnnotationParser.kt @@ -113,9 +113,7 @@ object MarginAnnotationParser { for (block in explicitBlocks) { val tag = block.tag ?: continue - if (canvasTags.isEmpty() || canvasTags.any { (canvasTag, _) -> canvasTag == tag }) { - annotationMap[tag] = block.annotationText - } + annotationMap[tag] = block.annotationText } if (canvasTags.isEmpty()) return annotationMap diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/WidgetAnnotationMatcher.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/WidgetAnnotationMatcher.kt index e4bf910e75..d5ed53d955 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/WidgetAnnotationMatcher.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/WidgetAnnotationMatcher.kt @@ -5,7 +5,7 @@ import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox class WidgetAnnotationMatcher { companion object { private val TAG_REGEX = Regex("^(?i)(B|P|D|T|C|R|SW|S)-\\d+$") - private val TAG_EXTRACT_REGEX = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)$") + private val TAG_EXTRACT_REGEX = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)(?:\\s+(.+))?$") } internal fun matchAnnotationsToElements( diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt index b887c60205..2dfeaf37e4 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt @@ -1,7 +1,10 @@ package org.appdevforall.codeonthego.computervision.domain import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox import org.appdevforall.codeonthego.computervision.domain.xml.AndroidXmlGenerator +import org.appdevforall.codeonthego.computervision.utils.TextCleaner +import org.appdevforall.codeonthego.computervision.utils.getSortedScaledPlaceholders import kotlin.comparisons.compareBy class YoloToXmlConverter( @@ -13,6 +16,7 @@ class YoloToXmlConverter( fun generateXmlLayout( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -21,6 +25,7 @@ class YoloToXmlConverter( ): String { val uiCandidates = detections .filter { (it.isYolo || it.label == "text") && it.label != "widget_tag" } + .filter { it.isYolo || !TextCleaner.isCanvasMetadata(it.text) } .distinctBy { if (it.label.startsWith("switch")) { "${((it.boundingBox.top + it.boundingBox.bottom) / 2f).toInt() / 50}" @@ -46,13 +51,38 @@ class YoloToXmlConverter( val sortedBoxes = uiElements.sortedWith(compareBy({ it.y }, { it.x })) - return xmlGenerator.buildXml(sortedBoxes, finalAnnotations, targetDpHeight, wrapInScroll) + val selectedImageOverrides = buildSelectedImageOverrides( + uiElements = uiElements, + selectedImagesByPlaceholderId = selectedImagesByPlaceholderId + ) + + return xmlGenerator.buildXml( + boxes = sortedBoxes, + annotations = finalAnnotations, + selectedImageOverrides = selectedImageOverrides, + targetDpHeight = targetDpHeight, + wrapInScroll = wrapInScroll + ) + } + + private fun buildSelectedImageOverrides( + uiElements: List, + selectedImagesByPlaceholderId: Map + ): Map { + val placeholders = uiElements.getSortedScaledPlaceholders() + + return placeholders.mapIndexedNotNull { index, box -> + val drawableReference = selectedImagesByPlaceholderId["ph_$index"] + ?: return@mapIndexedNotNull null + box to drawableReference + }.toMap() } companion object { fun generateXmlLayout( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map = emptyMap(), sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -66,13 +96,14 @@ class YoloToXmlConverter( val converter = YoloToXmlConverter(geometry, matcher, generator) return converter.generateXmlLayout( - detections, - annotations, - sourceImageWidth, - sourceImageHeight, - targetDpWidth, - targetDpHeight, - wrapInScroll + detections = detections, + annotations = annotations, + selectedImagesByPlaceholderId = selectedImagesByPlaceholderId, + sourceImageWidth = sourceImageWidth, + sourceImageHeight = sourceImageHeight, + targetDpWidth = targetDpWidth, + targetDpHeight = targetDpHeight, + wrapInScroll = wrapInScroll ) } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt index cc547b1f5f..c198b0b15f 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt @@ -57,8 +57,10 @@ object EntriesValidator : AttributeValidator { val trimmed = rawValue.trim() if (trimmed.startsWith("@")) return trimmed - val hasBrackets = isEnclosedInBrackets(trimmed) - val content = trimmed.removeSurrounding("[", "]") + val content = trimmed + .replace(Regex("^[({\\[<]"), "") + .replace(Regex("[)}\\]>]$"), "") + .trim() val rawItems = content.split(",") @@ -74,11 +76,7 @@ object EntriesValidator : AttributeValidator { } val finalString = cleanedItems.joinToString(", ") - return if (hasBrackets) "[$finalString]" else finalString - } - - private fun isEnclosedInBrackets(text: String): Boolean { - return text.startsWith("[") && text.endsWith("]") + return "[$finalString]" } private fun isEntireArrayLikelyNumeric(items: List): Boolean { diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt index eb77c2cecc..9de9691fd7 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt @@ -25,7 +25,9 @@ object ImageViewGrammar : WidgetGrammar { "android:layout_height" to DimensionValidator, "android:id" to PassThroughValidator, "android:src" to PassThroughValidator, - "android:layout_gravity" to CategoricalValidator(gravityValues) + "android:layout_gravity" to CategoricalValidator(gravityValues), + "android:background" to PassThroughValidator, + "app:backgroundTint" to PassThroughValidator ) } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt index a8043824fa..1c4d8e95fb 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt @@ -9,6 +9,7 @@ class AndroidXmlGenerator( internal fun buildXml( boxes: List, annotations: Map, + selectedImageOverrides: Map, targetDpHeight: Int, wrapInScroll: Boolean ): String { @@ -19,7 +20,7 @@ class AndroidXmlGenerator( appendHeaders(context, needScroll) val layoutItems = geometryProcessor.buildLayoutTree(boxes) - val renderer = LayoutRenderer(context, annotations) + val renderer = LayoutRenderer(context, annotations, selectedImageOverrides = selectedImageOverrides) layoutItems.forEach { item -> renderer.render(item, " ") diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt index 300698e64a..e38331fc59 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -6,7 +6,8 @@ import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser class LayoutRenderer( private val context: XmlContext, - private val annotations: Map + private val annotations: Map, + private val selectedImageOverrides: Map = emptyMap() ) { private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$") private val radioChildGroupIdPatterns = listOf( @@ -33,8 +34,13 @@ class LayoutRenderer( ) { val tag = AndroidWidget.getTagFor(box.label) val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag) + val finalParsedAttrs = parsedAttrs.toMutableMap() - val widget = AndroidWidget.create(box, parsedAttrs) + selectedImageOverrides[box]?.let { drawableReference -> + finalParsedAttrs["android:src"] = drawableReference + } + + val widget = AndroidWidget.create(box, finalParsedAttrs) widget.render(context, indent, extraAttrs, idOverride) } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt index 73f9567085..28634e3ff6 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt @@ -87,6 +87,13 @@ class ComputerVisionActivity : AppCompatActivity() { else Toast.makeText(this, R.string.msg_camera_permission_required, Toast.LENGTH_LONG).show() } + private val pickPlaceholderImageLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + viewModel.onEvent(ComputerVisionEvent.PlaceholderImageSelected(it)) + } ?: Toast.makeText(this, R.string.msg_no_image_selected, Toast.LENGTH_SHORT).show() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityComputerVisionBinding.inflate(layoutInflater) @@ -118,6 +125,17 @@ class ComputerVisionActivity : AppCompatActivity() { binding.saveButton.setOnClickListener { viewModel.onEvent(ComputerVisionEvent.SaveToDownloads) } + binding.imageView.onImageTapListener = imageTap@{ imageX, imageY -> + if (!viewModel.isImagePlaceholderAt(imageX, imageY)) return@imageTap false + + viewModel.onEvent( + ComputerVisionEvent.ImagePlaceholderTapped( + imageX = imageX, + imageY = imageY + ) + ) + true + } } private fun observeViewModel() { @@ -164,7 +182,6 @@ class ComputerVisionActivity : AppCompatActivity() { } binding.guidelinesView.updateGuidelines(state.leftGuidePct, state.rightGuidePct) - val isIdle = state.currentOperation == CvOperation.Idle binding.detectButton.isEnabled = state.canRunDetection binding.updateButton.isEnabled = state.canGenerateXml binding.saveButton.isEnabled = state.canGenerateXml @@ -188,6 +205,8 @@ class ComputerVisionActivity : AppCompatActivity() { is ComputerVisionEffect.ReturnXmlResult -> returnXmlResult(effect.xml) is ComputerVisionEffect.FileSaved -> saveXmlToFile(effect.fileName) ComputerVisionEffect.NavigateBack -> finish() + ComputerVisionEffect.OpenPlaceholderImagePicker -> + pickPlaceholderImageLauncher.launch("image/*") } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt index dbac173ae9..fd52311408 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt @@ -12,4 +12,6 @@ sealed class ComputerVisionEvent { object OpenImagePicker : ComputerVisionEvent() object RequestCameraPermission : ComputerVisionEvent() data class UpdateGuides(val leftPct: Float, val rightPct: Float) : ComputerVisionEvent() + data class ImagePlaceholderTapped(val imageX: Float, val imageY: Float) : ComputerVisionEvent() + data class PlaceholderImageSelected(val uri: Uri) : ComputerVisionEvent() } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt index 03be87f955..1d45e42e6b 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt @@ -15,7 +15,9 @@ data class ComputerVisionUiState( val currentOperation: CvOperation = CvOperation.Idle, val leftGuidePct: Float = 0.2f, val rightGuidePct: Float = 0.8f, - val parsedAnnotations: Map = emptyMap() // Replaced old marginAnnotations + val parsedAnnotations: Map = emptyMap(), // Replaced old marginAnnotations + val pendingImagePlaceholderId: String? = null, + val selectedImagesByPlaceholderId: Map = emptyMap() ) { val hasImage: Boolean get() = currentBitmap != null @@ -50,4 +52,10 @@ sealed class ComputerVisionEffect { data class ReturnXmlResult(val xml: String) : ComputerVisionEffect() data class FileSaved(val fileName: String) : ComputerVisionEffect() object NavigateBack : ComputerVisionEffect() + object OpenPlaceholderImagePicker : ComputerVisionEffect() } + +data class SelectedImportedImage( + val resourceName: String, + val drawableReference: String +) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt index b99f12707a..0f1b0aab69 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt @@ -9,6 +9,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.ScaleGestureDetector import androidx.appcompat.widget.AppCompatImageView +import kotlin.math.abs class ZoomableImageView @JvmOverloads constructor( context: Context, @@ -26,6 +27,7 @@ class ZoomableImageView @JvmOverloads constructor( private var mode = NONE var onMatrixChangeListener: ((Matrix) -> Unit)? = null + var onImageTapListener: ((Float, Float) -> Boolean)? = null init { super.setClickable(true) @@ -54,10 +56,17 @@ class ZoomableImageView @JvmOverloads constructor( } MotionEvent.ACTION_UP -> { mode = NONE - val xDiff = Math.abs(curr.x - start.x).toInt() - val yDiff = Math.abs(curr.y - start.y).toInt() + val xDiff = abs(curr.x - start.x).toInt() + val yDiff = abs(curr.y - start.y).toInt() if (xDiff < CLICK && yDiff < CLICK) { - performClick() + val mappedPoint = mapViewPointToImage(event.x, event.y) + val consumed = mappedPoint?.let { + onImageTapListener?.invoke(it.x, it.y) + } ?: false + + if (!consumed) { + performClick() + } } } MotionEvent.ACTION_POINTER_UP -> mode = NONE @@ -67,6 +76,28 @@ class ZoomableImageView @JvmOverloads constructor( return true } + fun mapViewPointToImage(x: Float, y: Float): PointF? { + val drawable = drawable ?: return null + val points = floatArrayOf(x, y) + val inverseMatrix = Matrix() + + if (!currentMatrix.invert(inverseMatrix)) return null + inverseMatrix.mapPoints(points) + + val imageX = points[0] + val imageY = points[1] + + return PointF(imageX, imageY).takeIf { + it.x in 0f..drawable.intrinsicWidth.toFloat() && + it.y in 0f..drawable.intrinsicHeight.toFloat() + } + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + override fun setImageDrawable(drawable: Drawable?) { super.setImageDrawable(drawable) fitToScreen() diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt index b41c6fd9d4..da614d1046 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt @@ -25,11 +25,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.appdevforall.codeonthego.computervision.R +import org.appdevforall.codeonthego.computervision.data.repository.DrawableImportHelper +import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.ui.SelectedImportedImage import org.appdevforall.codeonthego.computervision.utils.CvAnalyticsUtil import org.appdevforall.codeonthego.computervision.utils.SmartBoundaryDetector +import org.appdevforall.codeonthego.computervision.utils.getSortedPlaceholders class ComputerVisionViewModel( private val repository: ComputerVisionRepository, + private val drawableImportHelper: DrawableImportHelper, private val contentResolver: ContentResolver, layoutFilePath: String?, layoutFileName: String? @@ -78,6 +83,13 @@ class ComputerVisionViewModel( ) } } + is ComputerVisionEvent.ImagePlaceholderTapped -> { + handleImagePlaceholderTap(event.imageX, event.imageY) + } + + is ComputerVisionEvent.PlaceholderImageSelected -> { + handlePlaceholderImageSelected(event.uri) + } } } @@ -137,7 +149,9 @@ class ComputerVisionViewModel( visualizedBitmap = null, leftGuidePct = leftPct, rightGuidePct = rightPct, - parsedAnnotations = emptyMap() // Reset on new image + parsedAnnotations = emptyMap(), // Reset on new image + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = emptyMap() ) } } catch (e: Exception) { @@ -166,7 +180,7 @@ class ComputerVisionViewModel( ExifInterface.ORIENTATION_NORMAL ) } ?: ExifInterface.ORIENTATION_NORMAL - } catch (e: Exception) { + } catch (_: Exception) { ExifInterface.ORIENTATION_NORMAL } @@ -187,7 +201,7 @@ class ComputerVisionViewModel( bitmap.recycle() } rotatedBitmap - } catch (e: OutOfMemoryError) { + } catch (_: OutOfMemoryError) { bitmap } } @@ -264,7 +278,9 @@ class ComputerVisionViewModel( it.copy( detections = canvasDetections, parsedAnnotations = annotationMap, - currentOperation = CvOperation.Idle + currentOperation = CvOperation.Idle, + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = emptyMap(), ) } } @@ -349,6 +365,8 @@ class ComputerVisionViewModel( return repository.generateXml( detections = state.detections, annotations = state.parsedAnnotations, + selectedImagesByPlaceholderId = state.selectedImagesByPlaceholderId + .mapValues { it.value.drawableReference }, sourceImageWidth = bitmap.width, sourceImageHeight = bitmap.height, targetDpWidth = TARGET_DP_WIDTH, @@ -373,6 +391,71 @@ class ComputerVisionViewModel( } } + private fun handleImagePlaceholderTap(imageX: Float, imageY: Float) { + val placeholder = findImagePlaceholderAt(imageX, imageY) ?: return + val placeholderId = resolvePlaceholderId(placeholder) + + _uiState.update { it.copy(pendingImagePlaceholderId = placeholderId) } + viewModelScope.launch { + _uiEffect.send(ComputerVisionEffect.OpenPlaceholderImagePicker) + } + } + + private fun handlePlaceholderImageSelected(uri: Uri) { + val state = _uiState.value + val placeholderId = state.pendingImagePlaceholderId ?: return + + viewModelScope.launch { + drawableImportHelper.importDrawable( + sourceUri = uri, + layoutFilePath = state.layoutFilePath, + fallbackName = "imported_image_$placeholderId" + ).onSuccess { importedDrawable -> + _uiState.update { currentState -> + currentState.copy( + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = currentState.selectedImagesByPlaceholderId + + ( + placeholderId to SelectedImportedImage( + resourceName = importedDrawable.resourceName, + drawableReference = importedDrawable.drawableReference + ) + ) + ) + } + + _uiEffect.send( + ComputerVisionEffect.ShowToast(R.string.msg_placeholder_image_selected) + ) + }.onFailure { exception -> + _uiState.update { + it.copy(pendingImagePlaceholderId = null) + } + + _uiEffect.send( + ComputerVisionEffect.ShowError( + "Image import failed: ${exception.message}" + ) + ) + } + } + } + + private fun resolvePlaceholderId(detection: DetectionResult): String { + val index = _uiState.value.detections.getSortedPlaceholders().indexOf(detection) + return "ph_${index.coerceAtLeast(0)}" + } + + fun isImagePlaceholderAt(imageX: Float, imageY: Float): Boolean { + return findImagePlaceholderAt(imageX, imageY) != null + } + + private fun findImagePlaceholderAt(imageX: Float, imageY: Float): DetectionResult? { + return _uiState.value.detections + .getSortedPlaceholders() + .firstOrNull { it.boundingBox.contains(imageX, imageY) } + } + override fun onCleared() { super.onCleared() repository.releaseResources() diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt new file mode 100644 index 0000000000..2f0d18949b --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt @@ -0,0 +1,16 @@ +package org.appdevforall.codeonthego.computervision.utils + +import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox + +const val IMAGE_PLACEHOLDER_LABEL = "image_placeholder" + +fun List.getSortedPlaceholders(): List { + return this.filter { it.label == IMAGE_PLACEHOLDER_LABEL } + .sortedWith(compareBy({ it.boundingBox.top }, { it.boundingBox.left })) +} + +fun List.getSortedScaledPlaceholders(): List { + return this.filter { it.label == IMAGE_PLACEHOLDER_LABEL } + .sortedWith(compareBy({ it.y }, { it.x })) +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt index 7f00a7635a..218f915db3 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt @@ -3,6 +3,12 @@ package org.appdevforall.codeonthego.computervision.utils object TextCleaner { private val nonAlphanumericRegex = Regex("[^a-zA-Z0-9 ]") + private val METADATA_KEYWORDS = listOf( + "layout_width", + "layout_height", + "match_parent", + "wrap_content" + ) fun cleanText(text: String): String { return text.replace("\n", " ") @@ -27,4 +33,9 @@ object TextCleaner { return cleanedText.ifEmpty { text } } + + fun isCanvasMetadata(text: String): Boolean { + val lowerText = text.lowercase() + return METADATA_KEYWORDS.any { keyword -> lowerText.contains(keyword) } + } } diff --git a/cv-image-to-xml/src/main/res/values/strings.xml b/cv-image-to-xml/src/main/res/values/strings.xml index d61a78061e..e1f2f0ec1d 100644 --- a/cv-image-to-xml/src/main/res/values/strings.xml +++ b/cv-image-to-xml/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Saved to Downloads/%s Error saving file: %s Share XML Layout + Image selected for placeholder. layout Image placeholder for object detection