Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a5367920 | ||
|
|
ff30462259 | ||
|
|
975f9ee13e | ||
|
|
39dcd6bd4a | ||
|
|
ffeae96530 |
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+19
@@ -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<FieldAttributeDefinition>,
|
||||
valuesByCode: Map<String, String>,
|
||||
): Map<String, Any?> = 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
|
||||
}
|
||||
@@ -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<String, String>): Map<String, Any?> =
|
||||
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
|
||||
|
||||
@@ -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<String, String>): Map<String, Any?> =
|
||||
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
|
||||
|
||||
@@ -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<String, String>): Map<String, Any?> =
|
||||
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
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var familyExpanded by remember { mutableStateOf(false) }
|
||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(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<String, String>): Map<String, Any?> =
|
||||
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
|
||||
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
@@ -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_") })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user