diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6c834ea..32993e2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -43,6 +43,8 @@ jobs: - name: Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Android API 36 run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 2a57c6d..93e0d54 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 31 - versionName = "0.19.3" + versionCode = 33 + versionName = "0.19.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index fa8c9a2..3e8caf8 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -244,7 +244,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } if (type.familyRequired && familyId == null) { - error = "Elegí una clasificación técnica o la opción Otro / no catalogado." + error = "Elegí el Tipo de instalación o subinstalación correspondiente." return } launchBusy(mutation = true) { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldAttributePayload.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldAttributePayload.kt new file mode 100644 index 0000000..25316cb --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldAttributePayload.kt @@ -0,0 +1,19 @@ +package com.korexlabs.dhinspeccion.data + +/** Builds the API payload using definition IDs while the form keeps stable field codes. */ +internal fun fieldAttributePayload( + definitions: List, + valuesByCode: Map, +): Map = definitions.mapNotNull { definition -> + val raw = valuesByCode[definition.code]?.trim().orEmpty() + if (raw.isBlank()) return@mapNotNull null + definition.id to coerceFieldAttribute(definition, raw) +}.toMap() + +private fun coerceFieldAttribute(definition: FieldAttributeDefinition, raw: String): Any = + when (definition.dataType.uppercase()) { + "INTEGER", "INT" -> raw.toLongOrNull() ?: raw + "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw + "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") + else -> raw + } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt index 7cc4bd8..9155f0b 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt @@ -60,6 +60,7 @@ import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.fieldAttributePayload import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary import kotlinx.coroutines.launch @@ -521,18 +522,7 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect } private fun buildAttributes(type: FieldType, values: Map): Map = - type.attributes.mapNotNull { definition -> - val raw = values[definition.code]?.trim().orEmpty() - if (raw.isBlank()) return@mapNotNull null - definition.code to coerceAttribute(definition, raw) - }.toMap() - -private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) { - "INTEGER", "INT" -> raw.toLongOrNull() ?: raw - "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw - "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") - else -> raw -} + fieldAttributePayload(type.attributes, values) private fun hasPermission(context: Context, permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt index 1b6f188..d465db3 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt @@ -56,6 +56,7 @@ import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.fieldAttributePayload import com.korexlabs.dhinspeccion.data.VisitDetail import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -693,11 +694,11 @@ private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit } if (selectedType?.familyRequired == true) { - Text("Clasificación técnica *", fontWeight = FontWeight.Bold) + Text("Tipo de instalación *", fontWeight = FontWeight.Bold) OutlinedTextField( value = familySearch, onValueChange = { familySearch = it }, - label = { Text("Buscar clasificación") }, + label = { Text("Buscar tipo de instalación") }, supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -906,18 +907,7 @@ private fun dynamicStatusLabel(status: String): String = when (status) { } private fun buildDynamicAttributes(type: FieldType, values: Map): Map = - type.attributes.mapNotNull { definition -> - val raw = values[definition.code]?.trim().orEmpty() - if (raw.isBlank()) return@mapNotNull null - definition.code to coerceDynamicAttribute(definition, raw) - }.toMap() - -private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) { - "INTEGER", "INT" -> raw.toLongOrNull() ?: raw - "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw - "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") - else -> raw -} + fieldAttributePayload(type.attributes, values) private fun dynamicHasPermission(context: Context, permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt index 7f137d2..c70dc29 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt @@ -57,6 +57,7 @@ import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.fieldAttributePayload import com.korexlabs.dhinspeccion.data.VisitDetail import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -720,18 +721,7 @@ private fun statusLabel(status: String): String = when (status) { } private fun buildF3Attributes(type: FieldType, values: Map): Map = - type.attributes.mapNotNull { definition -> - val raw = values[definition.code]?.trim().orEmpty() - if (raw.isBlank()) return@mapNotNull null - definition.code to coerceF3Attribute(definition, raw) - }.toMap() - -private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) { - "INTEGER", "INT" -> raw.toLongOrNull() ?: raw - "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw - "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") - else -> raw -} + fieldAttributePayload(type.attributes, values) private fun f3HasPermission(context: Context, permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt index 57fc48e..d6cf377 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -36,6 +37,11 @@ import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider @@ -76,6 +82,7 @@ import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.fieldAttributePayload import com.korexlabs.dhinspeccion.data.VisitDetail import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,6 +266,7 @@ private fun ModernChecklistCard(visit: VisitDetail) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { val context = LocalContext.current @@ -279,6 +287,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf(null) } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } + var familyExpanded by remember { mutableStateOf(false) } val attributeValues = remember { mutableStateMapOf() } var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) } var localError by rememberSaveable(visit.id) { mutableStateOf(null) } @@ -295,6 +304,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) selectedTypeId = null selectedFamilyId = null familySearch = "" + familyExpanded = false attributeValues.clear() localError = null } @@ -379,6 +389,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } + val editableAttributes = selectedType?.attributes.orEmpty().filterNot { + selectedType?.familyRequired == true && it.code == "tipo_instalacion" + } val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId } val filteredFamilies = remember(selectedType, familySearch) { val needle = modernNormalize(familySearch) @@ -746,37 +759,51 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } if (selectedType?.familyRequired == true) { - Text("Clasificación técnica", fontWeight = FontWeight.Bold) - OutlinedTextField( - value = familySearch, - onValueChange = { familySearch = it }, - label = { Text("Buscar clasificación") }, - leadingIcon = { Icon(Icons.Filled.Search, null) }, - supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - filteredFamilies.take(12).forEach { family -> - Surface( - onClick = { selectedFamilyId = family.id }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant), + ExposedDropdownMenuBox( + expanded = familyExpanded && !model.busy, + onExpandedChange = { + if (!model.busy) { + familyExpanded = it + familySearch = "" + } + }, + ) { + OutlinedTextField( + value = if (familyExpanded) familySearch else selectedFamily?.name.orEmpty(), + onValueChange = { familySearch = it; familyExpanded = true }, + label = { Text(if (isSubinstallation) "Tipo de subinstalación" else "Tipo de instalación") }, + placeholder = { Text("Buscar por nombre o código") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = familyExpanded) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable), + singleLine = true, + ) + ExposedDropdownMenu( + expanded = familyExpanded && !model.busy, + onDismissRequest = { familyExpanded = false; familySearch = "" }, + modifier = Modifier.heightIn(max = 280.dp), ) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - (if (family.isOther) "Otro · " else "") + family.name, - Modifier.weight(1f), - fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal, + if (filteredFamilies.isEmpty()) { + DropdownMenuItem(text = { Text("Sin coincidencias") }, onClick = {}, enabled = false) + } + filteredFamilies.forEach { family -> + DropdownMenuItem( + text = { Text((if (family.isOther) "Otro · " else "") + family.name) }, + trailingIcon = { + if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, "Seleccionado") + }, + onClick = { + selectedFamilyId = family.id + familyExpanded = false + familySearch = "" + keyboard?.hide() + focusManager.clearFocus() + }, ) - if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) } } } - if (filteredFamilies.size > 12) { - Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall) - } selectedFamily?.let { family -> if (family.isOther) { Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary) @@ -811,7 +838,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) minLines = 2, ) - selectedType?.attributes?.forEach { definition -> + editableAttributes.forEach { definition -> OutlinedTextField( value = attributeValues[definition.code].orEmpty(), onValueChange = { attributeValues[definition.code] = it }, @@ -840,10 +867,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } } - val attributesReady = selectedType?.attributes - ?.filter { it.isRequired } - ?.all { attributeValues[it.code].orEmpty().isNotBlank() } - ?: false + val attributesReady = editableAttributes + .filter { it.isRequired } + .all { attributeValues[it.code].orEmpty().isNotBlank() } val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null Button( onClick = { requestCreate() }, @@ -1074,18 +1100,7 @@ private fun modernNormalize(value: String): String = value.trim().lowercase() private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status) private fun buildModernAttributes(type: FieldType, values: Map): Map = - type.attributes.mapNotNull { definition -> - val raw = values[definition.code]?.trim().orEmpty() - if (raw.isBlank()) return@mapNotNull null - definition.code to coerceModernAttribute(definition, raw) - }.toMap() - -private fun coerceModernAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) { - "INTEGER", "INT" -> raw.toLongOrNull() ?: raw - "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw - "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") - else -> raw -} + fieldAttributePayload(type.attributes, values) private fun modernHasPermission(context: Context, permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/DynamicFieldFlowContractTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/DynamicFieldFlowContractTest.kt index 5e96fd8..9d629f4 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/DynamicFieldFlowContractTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/DynamicFieldFlowContractTest.kt @@ -10,7 +10,7 @@ class DynamicFieldFlowContractTest { @Test fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() { assertTrue(source.contains("Elegí la Instalación padre")) - assertTrue(source.contains("Clasificación técnica *")) + assertTrue(source.contains("Tipo de instalación *")) assertTrue(source.contains("Guardar y tomar foto")) assertTrue(source.contains("model.loadFieldTypes(item.id)")) assertTrue(source.contains("parentId = item.id")) diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldAttributePayloadTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldAttributePayloadTest.kt new file mode 100644 index 0000000..2665f7d --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldAttributePayloadTest.kt @@ -0,0 +1,29 @@ +package com.korexlabs.dhinspeccion + +import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition +import com.korexlabs.dhinspeccion.data.fieldAttributePayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FieldAttributePayloadTest { + @Test + fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() { + val definitions = listOf( + FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"), + FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"), + FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"), + ) + + val payload = fieldAttributePayload( + definitions, + mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "), + ) + + assertEquals("algo", payload["id-marca"]) + assertEquals(37.73, payload["id-capacidad"]) + assertFalse(payload.containsKey("id-serie")) + assertTrue(payload.keys.none { it.startsWith("campo_") }) + } +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index 8e263e2..7bc4636 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(31, BuildConfig.VERSION_CODE) - assertEquals("0.19.3-debug", BuildConfig.VERSION_NAME) + assertEquals(33, BuildConfig.VERSION_CODE) + assertEquals("0.19.5-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index 0b2d424..295b560 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); - assert.match(gradle, /versionCode = 31/); - assert.match(gradle, /versionName = "0\.19\.3"/); + assert.match(gradle, /versionCode = 33/); + assert.match(gradle, /versionName = "0\.19\.5"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); });