diff --git a/android-app/FINAL_RELEASE_CHECKLIST.md b/android-app/FINAL_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..7f21011 --- /dev/null +++ b/android-app/FINAL_RELEASE_CHECKLIST.md @@ -0,0 +1,15 @@ +# Checklist final Android 0.19.0 + +La candidata sólo se promueve si el mismo SHA cumple: + +- iniciar Inspección; +- crear/abrir Acta; +- seleccionar o crear Instalación/Subinstalación; +- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos; +- registrar Hallazgos sobre el elemento seleccionado; +- cerrar contenido del Acta y mostrar **Pendiente de firma**; +- completar firma del Inspector y manifestación/firma o negativa de la empresa; +- mostrar el Acta como **Firmada y cerrada**; +- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas; +- no exponer estados internos en inglés en la interfaz de campo; +- Android CI / RC, DH V2 CI y auditoría de dependencias en verde. diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 3e5acfa..feef42a 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,40 +1,71 @@ -# DH Inspección Android · release Campo Moderno 0.18.0 +# DH Inspección Android · release final de campo 0.19.0 ## Candidata vigente -- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**. -- `versionName`: **0.18.0**. -- `versionCode`: **27**. +- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. +- `versionName`: **0.19.0**. +- `versionCode`: **28**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. -## Novedades de campo 0.18.0 +## Procedimiento operativo validado -La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario. +La APK 0.19.0 fija como recorrido principal de campo: -La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos: +1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo. +2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. +3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada. +4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección. +5. Si no existe, se da de alta desde campo sin abandonar el Acta. +6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos. +7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. +8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**. +9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. -1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe; -2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación; -3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida; -4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente. +La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano. -Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía. +## Alta de Instalaciones y Subinstalaciones -## Actas en Android +El alta móvil queda alineada con el Dashboard y con las validaciones del servidor. -El espacio de Actas fue rediseñado para mostrar con claridad: +### Instalación -- contexto de la Inspección y Yacimiento; -- estado de cada Acta y cantidad de Hallazgos; -- urgencia mediante selección explícita; -- responsable de empresa, bloqueo, firmas, manifestación y sellado; -- condición necesaria para cerrar la Inspección. +- contexto territorial: Yacimiento/Área de la Inspección; +- clasificación técnica compatible; +- **Nombre técnico** obligatorio; +- Nombre habitual opcional; +- Descripción opcional; +- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado; +- código DH generado por el sistema cuando corresponde; +- GPS de la tablet; +- fotografía obligatoria antes de crear Hallazgos. -El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO. +### Subinstalación + +Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta. + +La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas. + +## Actas y firma + +La terminología visible se simplifica: + +- `DRAFT` → **En elaboración**; +- `LOCKED` → **Pendiente de firma**; +- `SEALED` → **Firmada y cerrada**. + +Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable. + +Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina. + +## Castellano e identidad visual + +Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario. + +La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto. ## Barrera obligatoria @@ -42,16 +73,16 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And 1. validación de identidad, HTTPS y políticas básicas del manifest; 2. Android lint; -3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada; +3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada; 4. `assembleDebug`; 5. `assembleRelease` para comprobar que la variante productiva compile; 6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado. -Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover el cambio. +Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor. ## Firma release -La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`. +La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica. No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior. @@ -60,7 +91,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí Antes de distribuir una APK productiva: - todas las barreras de CI del SHA exacto deben estar verdes; -- comprobar certificado/huella de firma contra la versión histórica; -- realizar actualización sobre al menos una tablet con la versión productiva anterior; -- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre; +- comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva; +- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda; +- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección; - registrar el SHA Git y SHA-256 de la APK distribuida. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 8488eb8..7db8c79 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 = 27 - versionName = "0.18.0" + versionCode = 28 + versionName = "0.19.0" 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 006ea1d..4d08601 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 @@ -169,7 +169,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } if (acts.any { it.status == "DRAFT" }) { - error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." + error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente." return } launchBusy { @@ -241,6 +241,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { latitude: Double, longitude: Double, accuracyM: Double?, + description: String? = null, ) { val visitId = visit?.id ?: return if (visit?.status != "IN_PROGRESS") { @@ -258,6 +259,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { familyId = familyId, name = name.trim(), commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, + description = description?.trim()?.takeIf { it.isNotBlank() }, attributes = attributes, deviceLatitude = latitude, deviceLongitude = longitude, @@ -340,7 +342,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val assetId = selectedFieldAsset?.asset?.id ?: return val draft = selectedDraftAct() if (draft == null) { - error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos." + error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos." return } launchBusy { @@ -362,7 +364,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val assetId = selectedFieldAsset?.asset?.id ?: return val actId = selectedDraftAct()?.id if (actId == null) { - error = "No hay un Acta en borrador seleccionada." + error = "No hay un Acta en elaboración seleccionada." return } if (description.isBlank()) { @@ -471,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { absenceReason = reason.trim(), ), ) - notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta." + notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta." } } @@ -480,12 +482,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { actClosure = actsRepository.lock(actId) refreshSelectedActInternal(actId) - notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones." + notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación." } } fun reopenSelectedAct() { - error = "Un Acta bloqueada es inmutable y no puede volver a borrador." + error = "Un Acta pendiente de firma es inmutable y no puede volver a edición." } fun signSelectedActAsInspector( @@ -546,7 +548,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { refreshSelectedActInternal(actId) loadActsInternal(currentVisit.id, selectDraft = false) clearFindingState() - notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección." + notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." } } @@ -555,7 +557,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { visit = actsRepository.closeVisit(visitId) loadVisitsInternal() - notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina." + notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt index 9b4b882..a0cf697 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt @@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) { Text(asset.name, fontWeight = FontWeight.Bold) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text( - "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", + "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}", color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) @@ -210,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) - Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall) + Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall) Text( "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", style = MaterialTheme.typography.bodySmall, diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt index b6fefa6..96b8090 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt @@ -3,11 +3,13 @@ package com.korexlabs.dhinspeccion.ui import android.content.Context import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility @@ -29,7 +31,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -37,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import com.korexlabs.dhinspeccion.MainViewModel +import com.korexlabs.dhinspeccion.R private const val BIOMETRIC_PREFS = "dh_v2_biometric" private const val BIOMETRIC_ENABLED = "enabled" @@ -105,7 +110,13 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni var passwordVisible by rememberSaveable { mutableStateOf(false) } Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Image( + painter = painterResource(R.drawable.ic_mendoza_launcher_exact), + contentDescription = "Gobierno de Mendoza", + modifier = Modifier.fillMaxWidth().height(170.dp), + contentScale = ContentScale.Fit, + ) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("Aplicación de campo · Dirección de Hidrocarburos") model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } @@ -113,7 +124,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni OutlinedTextField( value = identifier, onValueChange = { identifier = it }, - label = { Text("Usuario o email") }, + label = { Text("Usuario o correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -198,6 +209,12 @@ private fun BiometricUnlockScreen( verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + Image( + painter = painterResource(R.drawable.ic_mendoza_launcher_exact), + contentDescription = "Gobierno de Mendoza", + modifier = Modifier.fillMaxWidth().height(140.dp), + contentScale = ContentScale.Fit, + ) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("Ingresá con tu huella") error?.let { Text(it, color = MaterialTheme.colorScheme.error) } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt index 3caec53..8f6d5f7 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt @@ -215,7 +215,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(visit.code, fontWeight = FontWeight.Bold) - Text(visit.status) + Text(visitStatusLabelEs(visit.status)) } Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index 50020ce..01b63a6 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -245,7 +245,7 @@ fun ModernMobileActsScreen( ) { Text( if (newActUrgency == "URGENT") { - "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla." + "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido." } else { "El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta." }, @@ -319,12 +319,12 @@ fun ModernMobileActsScreen( OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> - FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) }) + FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) }) } } OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true) Button( onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, @@ -353,17 +353,17 @@ fun ModernMobileActsScreen( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error) Spacer(Modifier.width(8.dp)) - Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } Text( - "Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.", + "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button( onClick = { model.prepareSelectedAct() }, enabled = !model.busy && closure?.responsible != null, modifier = Modifier.fillMaxWidth(), - ) { Text("Finalizar y BLOQUEAR Acta") } + ) { Text("Cerrar Acta y dejar pendiente de firma") } } } } @@ -379,10 +379,10 @@ fun ModernMobileActsScreen( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary) Spacer(Modifier.width(8.dp)) - Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } - Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.") - closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) } + Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.") + closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) } HorizontalDivider() Text("Firma del inspector", fontWeight = FontWeight.Bold) @@ -403,7 +403,7 @@ fun ModernMobileActsScreen( val detail = when (companyOutcome.status) { "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "REFUSED" -> "Negativa a firmar" - else -> companyOutcome.status + else -> humanBackendLabelEs(companyOutcome.status) } SuccessLine(detail) companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } @@ -413,7 +413,7 @@ fun ModernMobileActsScreen( Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error) Text( - "La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.", + "La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, ) @@ -462,7 +462,7 @@ fun ModernMobileActsScreen( ) { Icon(Icons.Filled.CheckCircle, null) Spacer(Modifier.width(6.dp)) - Text("SELLAR Acta definitivamente") + Text("Firmar y cerrar Acta") } } else { Text( @@ -480,8 +480,8 @@ fun ModernMobileActsScreen( Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold) - Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.") + Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold) + Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.") closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) } } } @@ -503,10 +503,10 @@ fun ModernMobileActsScreen( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) if (pendingActs.isEmpty()) { - SuccessLine("Todas las Actas están SELLADAS") + SuccessLine("Todas las Actas están firmadas y cerradas") } else { Text( - "Falta SELLAR: ${pendingActs.joinToString { it.code }}", + "Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -531,13 +531,7 @@ private fun SuccessLine(text: String) { } } -private fun modernActStatusLabel(status: String): String = when (status) { - "DRAFT" -> "Borrador" - "LOCKED" -> "Bloqueada" - "SEALED" -> "Sellada" - "CANCELLED" -> "Cancelada" - else -> status -} +private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status) private fun hasModernActLocation(context: Context): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == 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 c624720..7cb02f1 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 @@ -274,7 +274,7 @@ private fun ModernChecklistCard(visit: VisitDetail) { ) } if (checklist.stale) { - Text("El checklist requiere actualización.", color = MaterialTheme.colorScheme.error) + Text("La lista de control requiere actualización.", color = MaterialTheme.colorScheme.error) } } } @@ -296,6 +296,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") } + var description by rememberSaveable(visit.id) { mutableStateOf("") } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf(null) } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } @@ -311,6 +312,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) fun resetForm() { name = "" commonName = "" + description = "" selectedTypeId = null selectedFamilyId = null familySearch = "" @@ -444,6 +446,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) familyId = selectedFamilyId, name = name, commonName = commonName, + description = description, attributes = buildModernAttributes(type, attributeValues), latitude = geo.latitude, longitude = geo.longitude, @@ -800,18 +803,26 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Nombre o código visible *") }, - supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") }, + label = { Text("Nombre técnico *") }, + supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) OutlinedTextField( value = commonName, onValueChange = { commonName = it }, - label = { Text("Nombre habitual") }, + label = { Text("Nombre habitual (opcional)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Descripción (opcional)") }, + supportingText = { Text("Dato equivalente al alta desde el panel web.") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) selectedType?.attributes?.forEach { definition -> OutlinedTextField( @@ -1066,20 +1077,14 @@ private fun ModernStepHeader(step: Int, title: String) { private fun modernItemTypeCode(item: FieldInventoryItem): String { val type = item.type ?: return "" - return modernNormalize(type.typeName ?: type.name) + return modernNormalize(type.code.ifBlank { type.typeName ?: type.name }) } private fun modernNormalize(value: String): String = value.trim().lowercase() .replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u') .replace("_", "").replace("-", "").replace(" ", "") -private fun modernStatusLabel(status: String): String = when (status) { - "PLANNED" -> "Planificada" - "IN_PROGRESS" -> "En curso" - "CLOSED" -> "Cerrada" - "CANCELLED" -> "Cancelada" - else -> status -} +private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status) private fun buildModernAttributes(type: FieldType, values: Map): Map = type.attributes.mapNotNull { definition -> diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt index 97b7318..6efb1ca 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt @@ -49,7 +49,7 @@ fun SignaturePad( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) Text( - "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.", + "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.", style = MaterialTheme.typography.bodySmall, ) Canvas( diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt new file mode 100644 index 0000000..e84e22d --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt @@ -0,0 +1,50 @@ +package com.korexlabs.dhinspeccion.ui + +import java.util.Locale + +internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) { + "PLANNED" -> "Planificada" + "IN_PROGRESS" -> "En curso" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + null, "" -> "Sin estado" + else -> humanBackendLabelEs(status) +} + +internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) { + "DRAFT" -> "En elaboración" + "LOCKED" -> "Pendiente de firma" + "SEALED" -> "Firmada y cerrada" + "CLOSED" -> "Cerrada" + "RECTIFIED" -> "Rectificada" + "CANCELLED" -> "Cancelada" + null, "" -> "Sin estado" + else -> humanBackendLabelEs(status) +} + +internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) { + "OPEN" -> "Abierto" + "PENDING" -> "Pendiente" + "COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa" + "COMPANY_RESPONDED" -> "Respondido por la empresa" + "VERIFICATION_PENDING" -> "Pendiente de verificación" + "VERIFICATION_SCHEDULED" -> "Verificación programada" + "READY_TO_CLOSE" -> "Listo para cierre" + "CLOSED" -> "Cerrado" + "CANCELLED" -> "Cancelado" + null, "" -> "Sin estado" + else -> humanBackendLabelEs(status) +} + +internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) { + "DNI" -> "DNI" + "CUIL" -> "CUIL" + "PASSPORT" -> "Pasaporte" + "OTHER" -> "Otro" + else -> humanBackendLabelEs(type) +} + +internal fun humanBackendLabelEs(value: String): String { + val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR")) + return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() } +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt new file mode 100644 index 0000000..ef56ab8 --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt @@ -0,0 +1,57 @@ +package com.korexlabs.dhinspeccion + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FinalFieldFlowContractTest { + private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText() + private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText() + private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText() + private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText() + private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText() + + @Test + fun inspectionActFindingFlowMatchesFieldProcedure() { + assertTrue(visit.contains("Iniciar inspección")) + assertTrue(acts.contains("Crear nueva Acta")) + assertTrue(acts.contains("Agregar Hallazgo")) + assertTrue(visit.contains("Instalación")) + assertTrue(visit.contains("Subinstalación")) + assertTrue(visit.contains("Guardar y tomar foto")) + assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma")) + assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\"")) + } + + @Test + fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() { + assertTrue(visit.contains("Nombre técnico *")) + assertTrue(visit.contains("Nombre habitual (opcional)")) + assertTrue(visit.contains("Descripción (opcional)")) + assertTrue(visit.contains("familyReady")) + assertTrue(visit.contains("attributesReady")) + assertTrue(visit.contains("GPS + foto")) + assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos")) + } + + @Test + fun structuralSelectionUsesTheServerTypeCode() { + assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }")) + assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")")) + } + + @Test + fun currentFieldUiDoesNotExposeBackendEnglishStates() { + assertTrue(home.contains("visitStatusLabelEs(visit.status)")) + assertTrue(findings.contains("findingStatusLabelEs(finding.status)")) + assertTrue(acts.contains("documentTypeLabelEs(kind)")) + assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\"")) + assertTrue(labels.contains("\"OTHER\" -> \"Otro\"")) + assertFalse(acts.contains("Finalizar y BLOQUEAR Acta")) + assertFalse(acts.contains("Acta BLOQUEADA")) + assertFalse(acts.contains("SELLAR Acta")) + assertFalse(acts.contains("Acta SELLADA")) + assertFalse(acts.contains("Text(\"Email\")")) + } +} 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 213f389..9ee72cf 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(27, BuildConfig.VERSION_CODE) - assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME) + assertEquals(28, BuildConfig.VERSION_CODE) + assertEquals("0.19.0-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 6188404..892fd9f 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 = 27/); - assert.match(gradle, /versionName = "0\.18\.0"/); + assert.match(gradle, /versionCode = 28/); + assert.match(gradle, /versionName = "0\.19\.0"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); });