diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt index 12a693e5a2..989f4c9bb7 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt @@ -461,11 +461,12 @@ object FuzzyAttributeParser { } private fun cleanSpDimension(value: String): String { - val fixedUnit = value.lowercase() + val normalized = value.lowercase() .replace(" ", "") - .replace(Regex("5p$"), "sp") + val numericCandidate = normalized + .replace(Regex("(sp|5p)$"), "") - val numericString = fixedUnit.replace("_", "") + val numericString = numericCandidate.replace("_", "") val numericPart = extractOcrNumber(numericString) if (numericPart != null) return "${numericPart}sp" 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 6d3324175b..a19f828633 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 @@ -16,6 +16,14 @@ object PassThroughValidator : AttributeValidator { override fun validate(rawValue: String): String = rawValue.trim() } +object BooleanValidator : AttributeValidator { + private val allowedValues = listOf("true", "false") + + override fun validate(rawValue: String): String? { + return matchCategoricalValue(rawValue.trim().lowercase(), allowedValues, threshold = 85) + } +} + object DimensionValidator : AttributeValidator { private val dimensionValues = listOf("match_parent", "wrap_content") @@ -28,6 +36,22 @@ object DimensionValidator : AttributeValidator { } } +class SpDimensionRangeValidator( + private val minSp: Int, + private val maxSp: Int +) : AttributeValidator { + private val spRegex = Regex("^(\\d+(?:\\.\\d+)?)sp$") + + override fun validate(rawValue: String): String? { + val trimmed = rawValue.trim() + + val match = spRegex.matchEntire(trimmed) ?: return null + val value = match.groupValues[1].toFloatOrNull() ?: return null + + return trimmed.takeIf { value >= minSp && value <= maxSp } + } +} + class CategoricalValidator(private val allowedValues: List) : AttributeValidator { override fun validate(rawValue: String): String? { return matchCategoricalValue(rawValue.trim(), allowedValues) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/UiGrammarValidator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/UiGrammarValidator.kt index 8a648551dd..54affbbe73 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/UiGrammarValidator.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/UiGrammarValidator.kt @@ -5,6 +5,10 @@ class UiGrammarValidator { SpinnerGrammar, ImageViewGrammar, EditTextGrammar, + RadioButtonGrammar, + CheckBoxGrammar, + SwitchGrammar, + RadioGroupGrammar, SliderGrammar ).associateBy { it.tag } 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 48f3b614a5..fff8c7a0da 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 @@ -2,57 +2,106 @@ package org.appdevforall.codeonthego.computervision.domain.grammar import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser.AttributeKey + interface WidgetGrammar { val tag: String val attributes: Map + get() = mapOf( + AttributeKey.WIDTH.xmlName to DimensionValidator, + AttributeKey.HEIGHT.xmlName to DimensionValidator, + AttributeKey.ID.xmlName to PassThroughValidator + ) +} + +interface LayoutGrammar : WidgetGrammar { + override val attributes: Map + get() = super.attributes + mapOf( + AttributeKey.LAYOUT_MARGIN.xmlName to DimensionValidator, + AttributeKey.LAYOUT_MARGIN_TOP.xmlName to DimensionValidator, + AttributeKey.LAYOUT_MARGIN_BOTTOM.xmlName to DimensionValidator, + AttributeKey.LAYOUT_MARGIN_START.xmlName to DimensionValidator, + AttributeKey.LAYOUT_MARGIN_END.xmlName to DimensionValidator, + AttributeKey.LAYOUT_GRAVITY.xmlName to PassThroughValidator, + AttributeKey.LAYOUT_WEIGHT.xmlName to PassThroughValidator, + AttributeKey.PADDING.xmlName to DimensionValidator, + AttributeKey.VISIBILITY.xmlName to CategoricalValidator(listOf("visible", "invisible", "gone")) + ) +} + +interface TextGrammar : LayoutGrammar { + override val attributes: Map + get() = super.attributes + mapOf( + AttributeKey.TEXT_COLOR.xmlName to PassThroughValidator, + AttributeKey.TEXT_SIZE.xmlName to PassThroughValidator, + AttributeKey.TEXT_STYLE.xmlName to PassThroughValidator, + AttributeKey.TEXT_ALIGNMENT.xmlName to PassThroughValidator, + AttributeKey.FONT_FAMILY.xmlName to PassThroughValidator + ) } -object SpinnerGrammar : WidgetGrammar { +interface CompoundButtonGrammar : TextGrammar { + override val attributes: Map + get() = super.attributes + mapOf( + AttributeKey.TEXT.xmlName to PassThroughValidator, + AttributeKey.CHECKED.xmlName to BooleanValidator, + AttributeKey.TEXT_SIZE.xmlName to SpDimensionRangeValidator(minSp = 8, maxSp = 32) + ) +} + + +object SpinnerGrammar : LayoutGrammar { override val tag = "Spinner" - override val attributes = mapOf( - AttributeKey.WIDTH.xmlName to DimensionValidator, - AttributeKey.HEIGHT.xmlName to DimensionValidator, - AttributeKey.ID.xmlName to PassThroughValidator, + override val attributes = super.attributes + mapOf( AttributeKey.TEXT.xmlName to PassThroughValidator, AttributeKey.ENTRIES.xmlName to EntriesValidator ) } -object ImageViewGrammar : WidgetGrammar { +object ImageViewGrammar : LayoutGrammar { override val tag = "ImageView" private val gravityValues = listOf("top", "bottom", "left", "right", "center", "center_vertical", "center_horizontal", "start", "end") - override val attributes = mapOf( - AttributeKey.WIDTH.xmlName to DimensionValidator, - AttributeKey.HEIGHT.xmlName to DimensionValidator, - AttributeKey.ID.xmlName to PassThroughValidator, + override val attributes = super.attributes + mapOf( AttributeKey.SRC.xmlName to PassThroughValidator, AttributeKey.LAYOUT_GRAVITY.xmlName to CategoricalValidator(gravityValues) ) } -object EditTextGrammar : WidgetGrammar { +object EditTextGrammar : TextGrammar { override val tag = "EditText" private val inputTypeValues = listOf("text", "textPassword", "number", "numberDecimal", "textEmailAddress", "textUri", "phone") - override val attributes = mapOf( - AttributeKey.WIDTH.xmlName to DimensionValidator, - AttributeKey.HEIGHT.xmlName to DimensionValidator, - AttributeKey.ID.xmlName to PassThroughValidator, + override val attributes = super.attributes + mapOf( AttributeKey.TEXT.xmlName to PassThroughValidator, AttributeKey.INPUT_TYPE.xmlName to CategoricalValidator(inputTypeValues), AttributeKey.HINT.xmlName to PassThroughValidator ) } -object SliderGrammar : WidgetGrammar { +object RadioButtonGrammar : CompoundButtonGrammar { + override val tag = "RadioButton" +} + +object CheckBoxGrammar : CompoundButtonGrammar { + override val tag = "CheckBox" +} + +object SwitchGrammar : CompoundButtonGrammar { + override val tag = "Switch" +} + +object RadioGroupGrammar : TextGrammar { + override val tag = "RadioGroup" + override val attributes = super.attributes + mapOf( + AttributeKey.ORIENTATION.xmlName to CategoricalValidator(listOf("horizontal", "vertical")), + AttributeKey.TEXT_SIZE.xmlName to SpDimensionRangeValidator(minSp = 8, maxSp = 32) + ) +} + +object SliderGrammar : LayoutGrammar { override val tag = "com.google.android.material.slider.Slider" - override val attributes = mapOf( - AttributeKey.WIDTH.xmlName to DimensionValidator, - AttributeKey.HEIGHT.xmlName to DimensionValidator, - AttributeKey.ID.xmlName to PassThroughValidator, + override val attributes = super.attributes + mapOf( AttributeKey.TEXT.xmlName to PassThroughValidator, - AttributeKey.LAYOUT_WEIGHT.xmlName to PassThroughValidator, AttributeKey.STYLE.xmlName to SliderStyleValidator ) } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidConstants.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidConstants.kt new file mode 100644 index 0000000000..9cf33fca19 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidConstants.kt @@ -0,0 +1,30 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + + +object AndroidConstants { + const val MATCH_PARENT = "match_parent" + const val WRAP_CONTENT = "wrap_content" + + const val ORIENTATION_HORIZONTAL = "horizontal" + + const val TRUE = "true" + const val FALSE = "false" + + const val DEFAULT_TEXT_SIZE = "16sp" +} + +object AndroidWidgetTags { + const val LINEAR_LAYOUT = "LinearLayout" + const val RADIO_GROUP = "RadioGroup" + + const val TEXT_VIEW = "TextView" + const val BUTTON = "Button" + const val IMAGE_VIEW = "ImageView" + const val CHECK_BOX = "CheckBox" + const val RADIO_BUTTON = "RadioButton" + const val SWITCH = "Switch" + const val EDIT_TEXT = "EditText" + const val SPINNER = "Spinner" + const val SEEK_BAR = "SeekBar" + const val VIEW = "View" +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index 0c28f67adb..93a18165ed 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -2,52 +2,77 @@ package org.appdevforall.codeonthego.computervision.domain.xml import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser.AttributeKey -import kotlin.text.substringAfterLast sealed class AndroidWidget( - protected val box: ScaledBox, + protected open val box: ScaledBox?, protected val parsedAttrs: Map ) { abstract val tag: String + var idOverride: String? = null + var extraAttrs: Map = emptyMap() - protected open fun fallbackIdLabel(): String = box.label + protected open fun fallbackIdLabel() = box?.label ?: tag.lowercase() + protected open fun defaultWidth() = AndroidConstants.WRAP_CONTENT + protected open fun defaultHeight() = AndroidConstants.WRAP_CONTENT + protected open fun getChildren(): List = emptyList() protected abstract fun specificAttributes(): Map + protected open fun processAttributes(context: XmlContext, id: String, attrs: Map): Map { return attrs.mapValues { it.value.escapeXmlAttr() } } - fun render( - context: XmlContext, - indent: String, - extraAttrs: Map = emptyMap(), - idOverride: String? = null - ) { + fun render(context: XmlContext, indent: String) { + val resolvedId = resolveWidgetId(context) + val finalAttributes = assembleAttributes(context, resolvedId) + writeXml(context, indent, finalAttributes) + } + + private fun resolveWidgetId(context: XmlContext): String { val requestedId = idOverride ?: parsedAttrs[AttributeKey.ID.xmlName]?.substringAfterLast('/') - val id = context.resolveId(requestedId, fallbackIdLabel()) - val width = parsedAttrs[AttributeKey.WIDTH.xmlName] ?: extraAttrs[AttributeKey.WIDTH.xmlName] ?: "wrap_content" - val height = parsedAttrs[AttributeKey.HEIGHT.xmlName] ?: extraAttrs[AttributeKey.HEIGHT.xmlName] ?: "wrap_content" + return context.resolveId(requestedId, fallbackIdLabel()) + } - val finalAttrs = mutableMapOf( - AttributeKey.ID.xmlName to "@+id/${id.escapeXmlAttr()}", + private fun assembleAttributes(context: XmlContext, resolvedId: String): Map { + val width = parsedAttrs[AttributeKey.WIDTH.xmlName] ?: extraAttrs[AttributeKey.WIDTH.xmlName] ?: defaultWidth() + val height = parsedAttrs[AttributeKey.HEIGHT.xmlName] ?: extraAttrs[AttributeKey.HEIGHT.xmlName] ?: defaultHeight() + + val assembledAttrs = mutableMapOf( + AttributeKey.ID.xmlName to "@+id/${resolvedId.escapeXmlAttr()}", AttributeKey.WIDTH.xmlName to width.escapeXmlAttr(), AttributeKey.HEIGHT.xmlName to height.escapeXmlAttr() ) - specificAttributes().forEach { (k, v) -> finalAttrs[k] = v.escapeXmlAttr() } + specificAttributes().forEach { (k, v) -> assembledAttrs[k] = v.escapeXmlAttr() } val mergedAttrs = parsedAttrs + extraAttrs - val processedAttrs = processAttributes(context, id, mergedAttrs) + val processedAttrs = processAttributes(context, resolvedId, mergedAttrs) processedAttrs.forEach { (key, value) -> - finalAttrs.putIfAbsent(key, value) + assembledAttrs.putIfAbsent(key, value) } + return assembledAttrs + } + + private fun writeXml(context: XmlContext, indent: String, attributes: Map) { context.append("$indent<$tag\n") - finalAttrs.forEach { (key, value) -> + + attributes.forEach { (key, value) -> context.append("$indent $key=\"$value\"\n") } - context.append("$indent/>") + + val childWidgets = getChildren() + if (childWidgets.isEmpty()) { + context.append("$indent/>") + } else { + context.append("$indent>\n") + childWidgets.forEach { child -> + child.render(context, "$indent ") + context.appendLine() + } + context.append("$indent") + } } companion object { @@ -65,24 +90,24 @@ sealed class AndroidWidget( } fun getTagFor(label: String): String = when (label) { - "text" -> "TextView" - "button" -> "Button" - "image_placeholder", "icon" -> "ImageView" - "checkbox_unchecked", "checkbox_checked" -> "CheckBox" - "radio_button_unchecked", "radio_button_checked" -> "RadioButton" - "switch_off", "switch_on" -> "Switch" - "text_entry_box" -> "EditText" - "dropdown" -> "Spinner" - "slider" -> "SeekBar" - else -> "View" + "text" -> AndroidWidgetTags.TEXT_VIEW + "button" -> AndroidWidgetTags.BUTTON + "image_placeholder", "icon" -> AndroidWidgetTags.IMAGE_VIEW + "checkbox_unchecked", "checkbox_checked" -> AndroidWidgetTags.CHECK_BOX + "radio_button_unchecked", "radio_button_checked" -> AndroidWidgetTags.RADIO_BUTTON + "switch_off", "switch_on" -> AndroidWidgetTags.SWITCH + "text_entry_box" -> AndroidWidgetTags.EDIT_TEXT + "dropdown" -> AndroidWidgetTags.SPINNER + "slider" -> AndroidWidgetTags.SEEK_BAR + else -> AndroidWidgetTags.VIEW } } } class SpinnerWidget( - box: ScaledBox, parsedAttrs: Map + override val box: ScaledBox, parsedAttrs: Map ) : AndroidWidget(box, parsedAttrs) { - override val tag = "Spinner" + override val tag = AndroidWidgetTags.SPINNER override fun fallbackIdLabel(): String = "spinner" override fun specificAttributes() = emptyMap() @@ -136,12 +161,12 @@ class SpinnerWidget( } class TextBasedWidget( - box: ScaledBox, parsedAttrs: Map, - override val tag: String + override val box: ScaledBox, parsedAttrs: Map, override val tag: String ) : AndroidWidget(box, parsedAttrs) { + private val widgetTags = setOf(AndroidWidgetTags.SWITCH, AndroidWidgetTags.CHECK_BOX, AndroidWidgetTags.RADIO_BUTTON) + override fun specificAttributes(): Map { val attrs = mutableMapOf() - val widgetTags = setOf("Switch", "CheckBox", "RadioButton") val rawViewText = parsedAttrs[AttributeKey.TEXT.xmlName] ?: box.text.takeIf { it.isNotEmpty() && it != box.label } ?: if (tag in widgetTags) tag else box.label @@ -149,65 +174,67 @@ class TextBasedWidget( attrs[AttributeKey.TEXT.xmlName] = rawViewText attrs["tools:ignore"] = "HardcodedText" - if (tag == "TextView") { - attrs[AttributeKey.TEXT_SIZE.xmlName] = parsedAttrs[AttributeKey.TEXT_SIZE.xmlName] ?: "16sp" + if (tag == AndroidWidgetTags.TEXT_VIEW || tag in widgetTags) { + attrs[AttributeKey.TEXT_SIZE.xmlName] = parsedAttrs[AttributeKey.TEXT_SIZE.xmlName] ?: AndroidConstants.DEFAULT_TEXT_SIZE } if (box.label.contains("_checked") || box.label.contains("_on")) { - attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: AndroidConstants.TRUE } return attrs } + + override fun fallbackIdLabel(): String { + return if (tag == AndroidWidgetTags.RADIO_BUTTON) "radio_button" else super.fallbackIdLabel() + } } class CheckBoxWidget( - box: ScaledBox, parsedAttrs: Map + override val box: ScaledBox, parsedAttrs: Map ) : AndroidWidget(box, parsedAttrs) { - override val tag = "CheckBox" + override val tag = AndroidWidgetTags.CHECK_BOX override fun specificAttributes(): Map { val attrs = mutableMapOf() - val rawViewText = box.text.takeIf { it.isNotEmpty() && it != box.label } ?: parsedAttrs[AttributeKey.TEXT.xmlName] - ?: "CheckBox" + ?: AndroidWidgetTags.CHECK_BOX attrs[AttributeKey.TEXT.xmlName] = rawViewText attrs["tools:ignore"] = "HardcodedText" if (box.label.contains("_checked")) { - attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: AndroidConstants.TRUE } return attrs } + + override fun fallbackIdLabel(): String = "checkbox" } class SwitchWidget( - box: ScaledBox, - parsedAttrs: Map + override val box: ScaledBox, parsedAttrs: Map ) : AndroidWidget(box, parsedAttrs) { - override val tag = "Switch" - + override val tag = AndroidWidgetTags.SWITCH override fun fallbackIdLabel(): String = "switch" override fun specificAttributes(): Map { val attrs = mutableMapOf() - val switchText = parsedAttrs[AttributeKey.TEXT.xmlName] ?: box.text.trim().takeIf { it.isNotEmpty() && it != box.label } ?: "Switch" + val switchText = parsedAttrs[AttributeKey.TEXT.xmlName] ?: box.text.trim().takeIf { it.isNotEmpty() && it != box.label } ?: AndroidWidgetTags.SWITCH attrs[AttributeKey.TEXT.xmlName] = switchText attrs["tools:ignore"] = "HardcodedText" if (box.label.contains("_on")) { - attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: AndroidConstants.TRUE } - return attrs } } class InputWidget( - box: ScaledBox, parsedAttrs: Map + override val box: ScaledBox, parsedAttrs: Map ) : AndroidWidget(box, parsedAttrs) { - override val tag = "EditText" + override val tag = AndroidWidgetTags.EDIT_TEXT override fun specificAttributes(): Map = mapOf( AttributeKey.HINT.xmlName to (parsedAttrs[AttributeKey.HINT.xmlName] ?: box.text.ifEmpty { "Enter text..." }), AttributeKey.INPUT_TYPE.xmlName to (parsedAttrs[AttributeKey.INPUT_TYPE.xmlName] ?: "text"), @@ -216,14 +243,54 @@ class InputWidget( } class ImageWidget( - box: ScaledBox, parsedAttrs: Map + override val box: ScaledBox, parsedAttrs: Map ) : AndroidWidget(box, parsedAttrs) { - override val tag = "ImageView" + override val tag = AndroidWidgetTags.IMAGE_VIEW override fun specificAttributes(): Map = mapOf( AttributeKey.CONTENT_DESCRIPTION.xmlName to (parsedAttrs[AttributeKey.CONTENT_DESCRIPTION.xmlName] ?: box.label), ) } -class GenericWidget(box: ScaledBox, parsedAttrs: Map, override val tag: String) : AndroidWidget(box, parsedAttrs) { +class GenericWidget( + override val box: ScaledBox, parsedAttrs: Map, override val tag: String +) : AndroidWidget(box, parsedAttrs) { override fun specificAttributes() = emptyMap() } + +abstract class AndroidViewGroup( + parsedAttrs: Map, + protected val childWidgets: List +) : AndroidWidget(null, parsedAttrs) { + override fun getChildren() = childWidgets +} + +class HorizontalRowWidget( + childWidgets: List +) : AndroidViewGroup(emptyMap(), childWidgets) { + override val tag = AndroidWidgetTags.LINEAR_LAYOUT + override fun fallbackIdLabel() = "linear_layout" + override fun defaultWidth() = AndroidConstants.MATCH_PARENT + override fun specificAttributes() = mapOf( + "android:orientation" to AndroidConstants.ORIENTATION_HORIZONTAL, + "android:baselineAligned" to AndroidConstants.FALSE + ) +} + +class RadioGroupWidget( + parsedAttrs: Map, + childWidgets: List, + private val orientation: String, + private val checkedId: String? +) : AndroidViewGroup(parsedAttrs, childWidgets) { + override val tag = AndroidWidgetTags.RADIO_GROUP + override fun fallbackIdLabel() = "radio_group" + override fun defaultWidth() = AndroidConstants.MATCH_PARENT + + override fun specificAttributes(): Map { + val attrs = mutableMapOf("android:orientation" to orientation) + if (checkedId != null) { + attrs["android:checkedButton"] = "@id/$checkedId" + } + return attrs + } +} 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..285a51d158 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 @@ -2,152 +2,18 @@ package org.appdevforall.codeonthego.computervision.domain.xml import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox -import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser class LayoutRenderer( private val context: XmlContext, - private val annotations: Map + annotations: Map ) { - private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$") - private val radioChildGroupIdPatterns = listOf( - Regex("^rb_group_\\d+(?:_|$).*"), - Regex("^radio_group_\\d+(?:_|$).*") - ) + private val widgetFactory = WidgetFactory(context, annotations) fun render(item: LayoutItem, indent: String = " ") { - when (item) { - is LayoutItem.SimpleView -> renderSimpleView(item.box, indent) - is LayoutItem.HorizontalRow -> renderHorizontalRow(item.row, indent) - is LayoutItem.RadioGroup -> renderRadioGroup(item.boxes, item.orientation, indent) - is LayoutItem.CheckboxGroup -> renderCheckboxGroup(item.boxes, item.orientation, indent) - } - context.appendLine() - } - - private fun renderSimpleView( - box: ScaledBox, - indent: String, - extraAttrs: Map = emptyMap(), - idOverride: String? = null, - parsedAttrsOverride: Map? = null - ) { - val tag = AndroidWidget.getTagFor(box.label) - val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag) - - val widget = AndroidWidget.create(box, parsedAttrs) - widget.render(context, indent, extraAttrs, idOverride) - } - - private fun renderHorizontalRow(row: List, indent: String) { - context.appendLine( - """ - |$indent - """.trimMargin() - ) - - row.forEachIndexed { index, box -> - val extraAttrs = if (index < row.lastIndex) { - val nextBox = row[index + 1] - val gap = maxOf(0, nextBox.x - (box.x + box.w)) - mapOf("android:layout_marginEnd" to "${gap}dp") - } else emptyMap() - - renderSimpleView(box, "$indent ", extraAttrs) - context.appendLine() - } - context.append("$indent") - } - - private fun renderRadioGroup(boxes: List, orientation: String, indent: String) { - val groupId = context.nextId("radio_group") - - val radios = boxes.mapIndexed { index, box -> - val parsedAttrs = FuzzyAttributeParser.parse(annotations[box], "RadioButton") - - val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') - val id = if (requestedId != null && radioChildGroupIdPatterns.any { it.matches(requestedId) }) { - context.nextId(box.label) - } else { - context.resolveId(requestedId, box.label) - } - - val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { - val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) - mapOf("android:layout_marginEnd" to "${gap}dp") - } else emptyMap() - - val isChecked = box.label == "radio_button_checked" || - parsedAttrs["android:checked"]?.equals("true", ignoreCase = true) == true - - object { val box = box; val attrs = parsedAttrs; val id = id; val extra = extraAttrs; val checked = isChecked } - } - - val checkedId = radios.firstOrNull { it.checked }?.id - - context.appendLine("$indent") - - radios.forEach { radio -> - val safeAttrs = radio.attrs.toMutableMap() - if (radio.id == checkedId) { - safeAttrs["android:checked"] = "true" - } else { - safeAttrs["android:checked"] = "false" - } - - renderSimpleView( - box = radio.box, - indent = "$indent ", - extraAttrs = radio.extra, - idOverride = radio.id, - parsedAttrsOverride = safeAttrs - ) - context.appendLine() - } - context.append("$indent") - } - - private fun renderCheckboxGroup(boxes: List, orientation: String, indent: String) { - val groupAnnotation = boxes.firstNotNullOfOrNull { annotations[it] } - val parsedAttrs = FuzzyAttributeParser.parse(groupAnnotation, "CheckBox") - - val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') - val baseId = if (requestedId != null && checkboxGroupIdPattern.matches(requestedId)) { - context.resolveId(requestedId, "cb_group") - } else { - context.nextId("cb_group", initialIndex = 1) - } - - boxes.forEachIndexed { index, box -> - val suffix = ('a' + index).toString() - val childId = "${baseId}_$suffix" - - val safeAttrs = parsedAttrs.toMutableMap() - safeAttrs.remove("android:id") - - val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { - val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) - mapOf("android:layout_marginEnd" to "${gap}dp") - } else emptyMap() + val widgets = widgetFactory.createWidgets(item) - renderSimpleView( - box = box, - indent = indent, - extraAttrs = extraAttrs, - idOverride = childId, - parsedAttrsOverride = safeAttrs - ) + widgets.forEach { widget -> + widget.render(context, indent) context.appendLine() } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt new file mode 100644 index 0000000000..5e85c6d945 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt @@ -0,0 +1,138 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser + +class WidgetFactory( + private val context: XmlContext, + private val annotations: Map +) { + private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$") + private val radioChildGroupIdPatterns = listOf( + Regex("^rb_group(?:_\\d+)?(?:_|$).*"), + Regex("^radio_group(?:_\\d+)?(?:_|$).*") + ) + + fun createWidgets(item: LayoutItem): List = when (item) { + is LayoutItem.SimpleView -> listOf(createSimpleWidget(item.box)) + is LayoutItem.HorizontalRow -> createHorizontalRow(item) + is LayoutItem.RadioGroup -> createRadioGroup(item) + is LayoutItem.CheckboxGroup -> createCheckboxGroup(item) + } + + private fun createHorizontalRow(item: LayoutItem.HorizontalRow): List { + val children = item.row.mapIndexed { index, box -> + val extraAttrs = getMarginEndForHorizontalGap(item.row, index) + createSimpleWidget(box, extraAttrs = extraAttrs) + } + return listOf(HorizontalRowWidget(children)) + } + + private fun createRadioGroup(item: LayoutItem.RadioGroup): List { + val groupAnnotation = item.boxes.firstNotNullOfOrNull { annotations[it] } + val fullGroupAttrs = FuzzyAttributeParser.parse(groupAnnotation, "RadioGroup") + + val groupId = resolveRadioGroupId(fullGroupAttrs["android:id"]?.substringAfterLast('/')) + + val groupStructuralAttrs = setOf("android:id", "android:layout_width", "android:layout_height", "android:orientation") + val sharedAttrs = fullGroupAttrs.filterKeys { it !in groupStructuralAttrs } + + var checkedId: String? = null + + val children = item.boxes.mapIndexed { index, box -> + val parsedAttrs = (sharedAttrs + FuzzyAttributeParser.parse(annotations[box], "RadioButton")).toMutableMap() + + if (parsedAttrs["android:id"] == fullGroupAttrs["android:id"]) { + parsedAttrs.remove("android:id") + } + + val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') + val childId = if (requestedId != null && radioChildGroupIdPatterns.any { it.matches(requestedId) }) { + context.nextId("radio_button") + } else { + context.resolveId(requestedId, "radio_button") + } + + val isChecked = box.label == "radio_button_checked" || parsedAttrs["android:checked"]?.equals("true", ignoreCase = true) == true + if (isChecked) { + checkedId = childId + parsedAttrs["android:checked"] = "true" + } else { + parsedAttrs["android:checked"] = "false" + } + + val extraAttrs = if (item.orientation == "horizontal") { + getMarginEndForHorizontalGap(item.boxes, index) + } else emptyMap() + + createSimpleWidget(box, parsedAttrsOverride = parsedAttrs, idOverride = childId, extraAttrs = extraAttrs) + } + + val textStyleAttrs = setOf("android:textColor", "android:textSize", "android:textStyle", "android:fontFamily") + val groupFinalAttrs = fullGroupAttrs.filterKeys { it !in textStyleAttrs }.toMutableMap() + groupFinalAttrs["android:id"] = groupId + + return listOf(RadioGroupWidget(groupFinalAttrs, children, item.orientation, checkedId)) + } + + private fun createCheckboxGroup(item: LayoutItem.CheckboxGroup): List { + val groupAnnotation = item.boxes.firstNotNullOfOrNull { annotations[it] } + val parsedAttrs = FuzzyAttributeParser.parse(groupAnnotation, "CheckBox") + + val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') + val baseId = if (requestedId != null && checkboxGroupIdPattern.matches(requestedId)) { + context.resolveId(requestedId, "cb_group") + } else { + context.nextId("cb_group", initialIndex = 1) + } + + return item.boxes.mapIndexed { index, box -> + val suffix = ('a' + index).toString() + val childId = "${baseId}_$suffix" + + val safeAttrs = parsedAttrs.toMutableMap() + safeAttrs.remove("android:id") + + val extraAttrs = if (item.orientation == "horizontal") { + getMarginEndForHorizontalGap(item.boxes, index) + } else emptyMap() + + createSimpleWidget(box, parsedAttrsOverride = safeAttrs, idOverride = childId, extraAttrs = extraAttrs) + } + } + + private fun createSimpleWidget( + box: ScaledBox, + extraAttrs: Map = emptyMap(), + idOverride: String? = null, + parsedAttrsOverride: Map? = null + ): AndroidWidget { + val tag = AndroidWidget.getTagFor(box.label) + val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag) + return AndroidWidget.create(box, parsedAttrs).apply { + this.idOverride = idOverride + this.extraAttrs = extraAttrs + } + } + + private fun getMarginEndForHorizontalGap(boxes: List, currentIndex: Int): Map { + if (currentIndex >= boxes.lastIndex) return emptyMap() + val currentBox = boxes[currentIndex] + val nextBox = boxes[currentIndex + 1] + val gap = maxOf(0, nextBox.x - (currentBox.x + currentBox.w)) + return mapOf("android:layout_marginEnd" to "${gap}dp") + } + + private fun resolveRadioGroupId(requestedId: String?): String { + var cleanId = requestedId + if (requestedId != null) { + val normalizedId = requestedId.lowercase() + when { + normalizedId.startsWith("radio_grou") -> cleanId = "radio_group" + normalizedId.startsWith("rb_grou") || normalizedId.startsWith("rb_group") -> cleanId = "rb_group" + } + } + return context.resolveId(cleanId, "radio_group") + } +} 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..495744a78c 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,9 @@ package org.appdevforall.codeonthego.computervision.utils object TextCleaner { private val nonAlphanumericRegex = Regex("[^a-zA-Z0-9 ]") + private val leadingMarkerRegex = Regex("^[\\[\\]()●○□☑✓-]+\\s*") + private val leadingStandaloneCircleRegex = Regex("^[O0o]\\s+") + private val duplicatedLeadingCircleRegex = Regex("^[O0o](?=[oO][a-z])") fun cleanText(text: String): String { return text.replace("\n", " ") @@ -12,7 +15,9 @@ object TextCleaner { fun cleanTextStrippingLeadingO(text: String): String { val cleanedText = text.trim() - .replace(Regex("^[O0o\\[\\]()●○□☑✓-]+\\s*"), "") + .replace(leadingMarkerRegex, "") + .replace(leadingStandaloneCircleRegex, "") + .replace(duplicatedLeadingCircleRegex, "") return cleanedText.ifEmpty { text } }