fix(field): repair finding creation and simplify mobile UX
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m17s
DH V2 CI / API · typecheck, tests, build (push) Successful in 35s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m7s

This commit is contained in:
DH V2
2026-09-14 23:24:41 -03:00
parent 08f164a209
commit 9eef156197
18 changed files with 216 additions and 91 deletions
@@ -316,7 +316,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
"Inventario listo: ubicación y foto registradas."
} else {
"Fotografía registrada."
}
@@ -353,7 +353,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis: String?,
description: String,
severity: Int?,
correctionDueOn: String?,
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
@@ -386,7 +385,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
),
)
lastCreatedFinding = response.finding
@@ -561,8 +561,10 @@ class DhRepository(context: Context) {
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
if (!message.isNullOrBlank()) return message
return "Error HTTP ${error.code()}"
if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
return "No se pudo procesar la solicitud (${error.code()})."
}
return error.message ?: "Ocurrió un error inesperado"
}
@@ -113,7 +113,6 @@ data class CreateFieldFindingRequest(
val customLegalBasis: String? = null,
val description: String,
val severity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingCreateResponse(
@@ -7,21 +7,27 @@ import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -34,8 +40,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -60,20 +70,23 @@ private data class FindingGeoSnapshot(
val accuracyM: Double?,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
var search by rememberSaveable(asset.id) { mutableStateOf("") }
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
@@ -164,8 +177,8 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = ""
description = ""
severityText = ""
correctionDueOn = ""
search = ""
catalogExpanded = false
}
}
@@ -180,15 +193,15 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver")
}
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text("${asset.code} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -238,74 +251,105 @@ fun FieldFindingScreen(model: MainViewModel) {
HorizontalDivider()
}
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) {
Text(
options.catalog.configurationReason
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
color = MaterialTheme.colorScheme.secondary,
"No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar en catálogo") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filtered.forEach { item ->
val chosen = !other && selectedCatalogId == item.id
Card(
modifier = Modifier.fillMaxWidth().clickable {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
ExposedDropdownMenuBox(
expanded = catalogExpanded && !model.busy,
onExpandedChange = {
if (!model.busy) {
catalogExpanded = it
if (it) search = ""
}
},
) {
OutlinedTextField(
value = if (catalogExpanded) search else when {
other -> "Otro / No está en la lista"
selected != null -> selected.title
else -> ""
},
onValueChange = { search = it; catalogExpanded = true },
label = { Text("Tipo de Hallazgo *") },
placeholder = { Text("Buscar o seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
enabled = !model.busy,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
catalogExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = catalogExpanded && !model.busy,
onDismissRequest = { catalogExpanded = false; search = "" },
modifier = Modifier.heightIn(max = 320.dp),
) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
filtered.forEach { item ->
DropdownMenuItem(
text = {
Column {
Text(item.title, fontWeight = FontWeight.SemiBold)
item.suggestedSeverity?.let {
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
}
}
},
onClick = {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
)
}
DropdownMenuItem(
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
onClick = {
other = true
selectedCatalogId = null
severityText = ""
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
)
}
}
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) {
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = customTitle,
onValueChange = { customTitle = it },
label = { Text("Título del nuevo Hallazgo *") },
label = { Text("Nombre del Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = customLegalBasis,
onValueChange = { customLegalBasis = it },
label = { Text("Base legal / normativa (opcional)") },
label = { Text("Normativa (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
}
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -318,23 +362,20 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción del Hallazgo *") },
label = { Text("Qué observaste *") },
placeholder = { Text("Describí brevemente el problema") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad 1 a 10") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
label = { Text("Gravedad (1 a 10, opcional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
singleLine = true,
)
@@ -348,7 +389,6 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = if (other) customLegalBasis else null,
description = description,
severity = severity,
correctionDueOn = correctionDueOn,
)
},
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
@@ -63,4 +63,14 @@ class FinalFieldFlowContractTest {
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
@Test
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
assertTrue(findings.contains("ExposedDropdownMenuBox"))
assertTrue(findings.contains("Tipo de Hallazgo *"))
assertTrue(findings.contains("Buscar o seleccionar"))
assertTrue(findings.contains("Otro / No está en la lista"))
assertFalse(findings.contains("Fecha de corrección"))
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(34, BuildConfig.VERSION_CODE)
assertEquals("0.19.6-debug", BuildConfig.VERSION_NAME)
assertEquals(35, BuildConfig.VERSION_CODE)
assertEquals("0.19.7-debug", BuildConfig.VERSION_NAME)
}
@Test