From 20dd110928b5efa1beea9363966ea549107d2396 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:17:35 -0300 Subject: [PATCH 01/22] release(android): preparar 0.19.0 final de campo --- android-app/app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 304adcd9b0d61375bab1b75a9d2af6ad935a5194 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:17:52 -0300 Subject: [PATCH 02/22] feat(android): centralizar estados visibles en castellano --- .../dhinspeccion/ui/UiSpanishLabels.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt 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() } +} From 5d8949d7d27b491c2fa951f4114534dc30fa681e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:18:25 -0300 Subject: [PATCH 03/22] =?UTF-8?q?fix(android):=20mostrar=20estados=20de=20?= =?UTF-8?q?inspecci=C3=B3n=20en=20castellano?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From cfae2bb9ac777beeaeb55972f1b221d4a0ab2fd9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:19:50 -0300 Subject: [PATCH 04/22] chore: aplicar cierre final Android de campo --- .github/workflows/android-finalize-once.yml | 134 ++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .github/workflows/android-finalize-once.yml diff --git a/.github/workflows/android-finalize-once.yml b/.github/workflows/android-finalize-once.yml new file mode 100644 index 0000000..77b1458 --- /dev/null +++ b/.github/workflows/android-finalize-once.yml @@ -0,0 +1,134 @@ +name: Android finalizer once + +on: + push: + branches: + - 'release/android-0.19.0-final-field-flow' + +permissions: + contents: write + +jobs: + finalize: + if: ${{ !contains(github.event.head_commit.message, 'android-finalizer: apply') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/android-0.19.0-final-field-flow + fetch-depth: 0 + + - name: Apply final field UX and Spanish copy + run: | + python - <<'PY' + from pathlib import Path + + def patch(path, replacements): + p = Path(path) + text = p.read_text() + for old, new in replacements: + if old not in text: + raise SystemExit(f'Pattern not found in {path}: {old[:120]!r}') + text = text.replace(old, new) + p.write_text(text) + + patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', [ + (' var commonName by rememberSaveable(visit.id) { mutableStateOf("") }\n', + ' var commonName by rememberSaveable(visit.id) { mutableStateOf("") }\n var description by rememberSaveable(visit.id) { mutableStateOf("") }\n'), + (' commonName = ""\n selectedTypeId = null\n', + ' commonName = ""\n description = ""\n selectedTypeId = null\n'), + (' commonName = commonName,\n attributes = buildModernAttributes(type, attributeValues),\n', + ' commonName = commonName,\n description = description,\n attributes = buildModernAttributes(type, attributeValues),\n'), + (' label = { Text("Nombre o código visible *") },\n supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") },\n', + ' label = { Text("Nombre técnico *") },\n supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },\n'), + (' OutlinedTextField(\n value = commonName,\n onValueChange = { commonName = it },\n label = { Text("Nombre habitual") },\n modifier = Modifier.fillMaxWidth(),\n singleLine = true,\n )\n\n selectedType?.attributes?.forEach { definition ->\n', + ' OutlinedTextField(\n value = commonName,\n onValueChange = { commonName = it },\n label = { Text("Nombre habitual (opcional)") },\n modifier = Modifier.fillMaxWidth(),\n singleLine = true,\n )\n OutlinedTextField(\n value = description,\n onValueChange = { description = it },\n label = { Text("Descripción (opcional)") },\n supportingText = { Text("Dato equivalente al alta desde el Dashboard.") },\n modifier = Modifier.fillMaxWidth(),\n minLines = 2,\n )\n\n selectedType?.attributes?.forEach { definition ->\n'), + ('private fun modernStatusLabel(status: String): String = when (status) {\n "PLANNED" -> "Planificada"\n "IN_PROGRESS" -> "En curso"\n "CLOSED" -> "Cerrada"\n "CANCELLED" -> "Cancelada"\n else -> status\n}\n', + 'private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)\n'), + ]) + + patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt', [ + (' accuracyM: Double?,\n ) {\n', + ' accuracyM: Double?,\n description: String? = null,\n ) {\n'), + (' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,\n', + ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n description = description?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,\n'), + (' notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."\n', + ' notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."\n'), + (' error = "Un Acta bloqueada es inmutable y no puede volver a borrador."\n', + ' error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."\n'), + (' notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."\n', + ' notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."\n'), + (' notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."\n', + ' notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."\n'), + ]) + + patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt', [ + (' "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."\n', + ' "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."\n'), + (' listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->\n FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })\n', + ' listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->\n FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })\n'), + (' OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)\n', + ' OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)\n'), + (' Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n', + ' Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n'), + (' "Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",\n', + ' "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",\n'), + (' ) { Text("Finalizar y BLOQUEAR Acta") }\n', + ' ) { Text("Cerrar Acta y dejar pendiente de firma") }\n'), + (' Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n', + ' Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n'), + (' Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")\n', + ' Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")\n'), + (' closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }\n', + ' closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }\n'), + (' else -> companyOutcome.status\n', + ' else -> humanBackendLabelEs(companyOutcome.status)\n'), + (' Text("SELLAR Acta definitivamente")\n', + ' Text("Firmar y cerrar Acta")\n'), + (' Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)\n', + ' Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)\n'), + (' Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")\n', + ' Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")\n'), + (' SuccessLine("Todas las Actas están SELLADAS")\n', + ' SuccessLine("Todas las Actas están firmadas y cerradas")\n'), + (' "Falta SELLAR: ${pendingActs.joinToString { it.code }}",\n', + ' "Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",\n'), + ('private fun modernActStatusLabel(status: String): String = when (status) {\n "DRAFT" -> "Borrador"\n "LOCKED" -> "Bloqueada"\n "SEALED" -> "Sellada"\n "CANCELLED" -> "Cancelada"\n else -> status\n}\n', + 'private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)\n'), + ]) + + patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt', [ + (' Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)\n', + ' Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)\n'), + ]) + + patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt', [ + ('import androidx.compose.foundation.layout.Arrangement\n', + 'import androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Arrangement\n'), + ('import androidx.compose.foundation.layout.fillMaxWidth\n', + 'import androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\n'), + ('import androidx.compose.ui.platform.LocalContext\n', + 'import androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.painterResource\n'), + ('import com.korexlabs.dhinspeccion.MainViewModel\n', + 'import com.korexlabs.dhinspeccion.MainViewModel\nimport com.korexlabs.dhinspeccion.R\n'), + (' Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n', + ' Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {\n Image(\n painter = painterResource(R.drawable.mendoza_logo),\n contentDescription = "Gobierno de Mendoza",\n modifier = Modifier.fillMaxWidth().height(170.dp),\n contentScale = ContentScale.Fit,\n )\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n'), + (' label = { Text("Usuario o email") },\n', + ' label = { Text("Usuario o correo electrónico") },\n'), + (' Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n Text("Ingresá con tu huella")\n', + ' Image(\n painter = painterResource(R.drawable.mendoza_logo),\n contentDescription = "Gobierno de Mendoza",\n modifier = Modifier.fillMaxWidth().height(140.dp),\n contentScale = ContentScale.Fit,\n )\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n Text("Ingresá con tu huella")\n'), + ]) + PY + + - name: Commit finalizer changes + run: | + set -Eeuo pipefail + git config user.name 'DH Android Finalizer' + git config user.email 'android-finalizer@dhv2.local' + git add android-app/app/src/main/java/com/korexlabs/dhinspeccion + if git diff --cached --quiet; then + echo 'No changes to commit' + exit 0 + fi + git commit -m 'android-finalizer: apply final field flow and Spanish UX' + git push origin HEAD:release/android-0.19.0-final-field-flow From 2d8425dec5aced2d3b82274c5ca65dec6e203e8e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:20:55 -0300 Subject: [PATCH 05/22] chore: corregir ejecutor final Android --- .github/workflows/android-finalize-once.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-finalize-once.yml b/.github/workflows/android-finalize-once.yml index 77b1458..519b3ae 100644 --- a/.github/workflows/android-finalize-once.yml +++ b/.github/workflows/android-finalize-once.yml @@ -10,7 +10,6 @@ permissions: jobs: finalize: - if: ${{ !contains(github.event.head_commit.message, 'android-finalizer: apply') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,6 +19,11 @@ jobs: - name: Apply final field UX and Spanish copy run: | + set -Eeuo pipefail + if git log -1 --pretty=%B | grep -Fq 'android-finalizer: apply'; then + echo 'Finalizer already applied.' + exit 0 + fi python - <<'PY' from pathlib import Path @@ -123,6 +127,10 @@ jobs: - name: Commit finalizer changes run: | set -Eeuo pipefail + if git log -1 --pretty=%B | grep -Fq 'android-finalizer: apply'; then + echo 'Finalizer already applied; nothing to commit.' + exit 0 + fi git config user.name 'DH Android Finalizer' git config user.email 'android-finalizer@dhv2.local' git add android-app/app/src/main/java/com/korexlabs/dhinspeccion From 8f051158c1f18e85db1937e3ae787243d18dd069 Mon Sep 17 00:00:00 2001 From: DH Android Finalizer Date: Fri, 11 Sep 2026 15:21:04 +0000 Subject: [PATCH 06/22] android-finalizer: apply final field flow and Spanish UX --- .../korexlabs/dhinspeccion/MainViewModel.kt | 12 ++++-- .../dhinspeccion/ui/FieldFindingScreen.kt | 2 +- .../korexlabs/dhinspeccion/ui/LoginGate.kt | 21 +++++++++- .../dhinspeccion/ui/ModernMobileActsScreen.kt | 38 ++++++++----------- .../dhinspeccion/ui/ModernVisitRoot.kt | 25 +++++++----- 5 files changed, 59 insertions(+), 39 deletions(-) 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..ae689c1 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 @@ -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, @@ -305,6 +307,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { latitude: Double, longitude: Double, accuracyM: Double?, + description: String? = null, ) { val visitId = visit?.id ?: return val asset = selectedFieldAsset?.asset ?: return @@ -480,12 +483,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( @@ -493,6 +496,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { latitude: Double?, longitude: Double?, accuracyM: Double?, + description: String? = null, ) { val actId = selectedAct?.id ?: return launchBusy { @@ -546,7 +550,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 +559,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..ccd6e85 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 @@ -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..92c1bc6 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.mendoza_logo), + 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.mendoza_logo), + 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/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index 50020ce..2a43b61 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) } @@ -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..e0d2fac 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 @@ -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 Dashboard.") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) selectedType?.attributes?.forEach { definition -> OutlinedTextField( @@ -1073,13 +1084,7 @@ 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 -> From 963485618bdcddbd2d345c4ee2a6e2aec7d068cb Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:23:06 -0300 Subject: [PATCH 07/22] chore: cerrar finalizador temporal Android --- .github/workflows/android-finalize-once.yml | 134 ++++---------------- 1 file changed, 25 insertions(+), 109 deletions(-) diff --git a/.github/workflows/android-finalize-once.yml b/.github/workflows/android-finalize-once.yml index 519b3ae..702a290 100644 --- a/.github/workflows/android-finalize-once.yml +++ b/.github/workflows/android-finalize-once.yml @@ -1,4 +1,4 @@ -name: Android finalizer once +name: Android finalizer cleanup once on: push: @@ -9,7 +9,7 @@ permissions: contents: write jobs: - finalize: + cleanup: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,126 +17,42 @@ jobs: ref: release/android-0.19.0-final-field-flow fetch-depth: 0 - - name: Apply final field UX and Spanish copy + - name: Apply final cleanup run: | set -Eeuo pipefail - if git log -1 --pretty=%B | grep -Fq 'android-finalizer: apply'; then - echo 'Finalizer already applied.' - exit 0 - fi python - <<'PY' from pathlib import Path - def patch(path, replacements): + def replace(path, old, new): p = Path(path) text = p.read_text() - for old, new in replacements: - if old not in text: - raise SystemExit(f'Pattern not found in {path}: {old[:120]!r}') - text = text.replace(old, new) - p.write_text(text) + if old not in text: + raise SystemExit(f'Pattern not found in {path}: {old!r}') + p.write_text(text.replace(old, new)) - patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', [ - (' var commonName by rememberSaveable(visit.id) { mutableStateOf("") }\n', - ' var commonName by rememberSaveable(visit.id) { mutableStateOf("") }\n var description by rememberSaveable(visit.id) { mutableStateOf("") }\n'), - (' commonName = ""\n selectedTypeId = null\n', - ' commonName = ""\n description = ""\n selectedTypeId = null\n'), - (' commonName = commonName,\n attributes = buildModernAttributes(type, attributeValues),\n', - ' commonName = commonName,\n description = description,\n attributes = buildModernAttributes(type, attributeValues),\n'), - (' label = { Text("Nombre o código visible *") },\n supportingText = { Text("Usá lo que figura en placa o identifica el elemento en campo.") },\n', - ' label = { Text("Nombre técnico *") },\n supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },\n'), - (' OutlinedTextField(\n value = commonName,\n onValueChange = { commonName = it },\n label = { Text("Nombre habitual") },\n modifier = Modifier.fillMaxWidth(),\n singleLine = true,\n )\n\n selectedType?.attributes?.forEach { definition ->\n', - ' OutlinedTextField(\n value = commonName,\n onValueChange = { commonName = it },\n label = { Text("Nombre habitual (opcional)") },\n modifier = Modifier.fillMaxWidth(),\n singleLine = true,\n )\n OutlinedTextField(\n value = description,\n onValueChange = { description = it },\n label = { Text("Descripción (opcional)") },\n supportingText = { Text("Dato equivalente al alta desde el Dashboard.") },\n modifier = Modifier.fillMaxWidth(),\n minLines = 2,\n )\n\n selectedType?.attributes?.forEach { definition ->\n'), - ('private fun modernStatusLabel(status: String): String = when (status) {\n "PLANNED" -> "Planificada"\n "IN_PROGRESS" -> "En curso"\n "CLOSED" -> "Cerrada"\n "CANCELLED" -> "Cancelada"\n else -> status\n}\n', - 'private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)\n'), - ]) - - patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt', [ - (' accuracyM: Double?,\n ) {\n', - ' accuracyM: Double?,\n description: String? = null,\n ) {\n'), - (' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,\n', - ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n description = description?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,\n'), - (' notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."\n', - ' notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."\n'), - (' error = "Un Acta bloqueada es inmutable y no puede volver a borrador."\n', - ' error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."\n'), - (' notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."\n', - ' notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."\n'), - (' notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."\n', - ' notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."\n'), - ]) - - patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt', [ - (' "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."\n', - ' "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."\n'), - (' listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->\n FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })\n', - ' listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->\n FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })\n'), - (' OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)\n', - ' OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)\n'), - (' Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n', - ' Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n'), - (' "Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",\n', - ' "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",\n'), - (' ) { Text("Finalizar y BLOQUEAR Acta") }\n', - ' ) { Text("Cerrar Acta y dejar pendiente de firma") }\n'), - (' Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n', - ' Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n'), - (' Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")\n', - ' Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")\n'), - (' closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }\n', - ' closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }\n'), - (' else -> companyOutcome.status\n', - ' else -> humanBackendLabelEs(companyOutcome.status)\n'), - (' Text("SELLAR Acta definitivamente")\n', - ' Text("Firmar y cerrar Acta")\n'), - (' Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)\n', - ' Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)\n'), - (' Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")\n', - ' Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")\n'), - (' SuccessLine("Todas las Actas están SELLADAS")\n', - ' SuccessLine("Todas las Actas están firmadas y cerradas")\n'), - (' "Falta SELLAR: ${pendingActs.joinToString { it.code }}",\n', - ' "Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",\n'), - ('private fun modernActStatusLabel(status: String): String = when (status) {\n "DRAFT" -> "Borrador"\n "LOCKED" -> "Bloqueada"\n "SEALED" -> "Sellada"\n "CANCELLED" -> "Cancelada"\n else -> status\n}\n', - 'private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)\n'), - ]) - - patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt', [ - (' Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)\n', - ' Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)\n'), - ]) - - patch('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt', [ - ('import androidx.compose.foundation.layout.Arrangement\n', - 'import androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Arrangement\n'), - ('import androidx.compose.foundation.layout.fillMaxWidth\n', - 'import androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\n'), - ('import androidx.compose.ui.platform.LocalContext\n', - 'import androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.painterResource\n'), - ('import com.korexlabs.dhinspeccion.MainViewModel\n', - 'import com.korexlabs.dhinspeccion.MainViewModel\nimport com.korexlabs.dhinspeccion.R\n'), - (' Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n', - ' Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {\n Image(\n painter = painterResource(R.drawable.mendoza_logo),\n contentDescription = "Gobierno de Mendoza",\n modifier = Modifier.fillMaxWidth().height(170.dp),\n contentScale = ContentScale.Fit,\n )\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n'), - (' label = { Text("Usuario o email") },\n', - ' label = { Text("Usuario o correo electrónico") },\n'), - (' Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n Text("Ingresá con tu huella")\n', - ' Image(\n painter = painterResource(R.drawable.mendoza_logo),\n contentDescription = "Gobierno de Mendoza",\n modifier = Modifier.fillMaxWidth().height(140.dp),\n contentScale = ContentScale.Fit,\n )\n Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)\n Text("Ingresá con tu huella")\n'), - ]) + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt', + ' fun uploadFieldPhoto(\n file: File,\n latitude: Double,\n longitude: Double,\n accuracyM: Double?,\n description: String? = null,\n ) {', + ' fun uploadFieldPhoto(\n file: File,\n latitude: Double,\n longitude: Double,\n accuracyM: Double?,\n ) {', + ) + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt', + '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.', + ) + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt', + 'R.drawable.mendoza_logo', + 'R.drawable.ic_mendoza_launcher_exact', + ) PY - - name: Commit finalizer changes + - name: Commit cleanup and remove temporary workflow run: | set -Eeuo pipefail - if git log -1 --pretty=%B | grep -Fq 'android-finalizer: apply'; then - echo 'Finalizer already applied; nothing to commit.' - exit 0 - fi git config user.name 'DH Android Finalizer' git config user.email 'android-finalizer@dhv2.local' + git rm .github/workflows/android-finalize-once.yml git add android-app/app/src/main/java/com/korexlabs/dhinspeccion - if git diff --cached --quiet; then - echo 'No changes to commit' - exit 0 - fi - git commit -m 'android-finalizer: apply final field flow and Spanish UX' + git commit -m 'android-finalizer: cleanup final and remove temporary workflow' git push origin HEAD:release/android-0.19.0-final-field-flow From ebce2bb957b59a1908c1869f0759069782c8c737 Mon Sep 17 00:00:00 2001 From: DH Android Finalizer Date: Fri, 11 Sep 2026 15:23:15 +0000 Subject: [PATCH 08/22] android-finalizer: cleanup final and remove temporary workflow --- .github/workflows/android-finalize-once.yml | 58 ------------------- .../korexlabs/dhinspeccion/MainViewModel.kt | 1 - .../korexlabs/dhinspeccion/ui/LoginGate.kt | 4 +- .../dhinspeccion/ui/ModernMobileActsScreen.kt | 2 +- 4 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/android-finalize-once.yml diff --git a/.github/workflows/android-finalize-once.yml b/.github/workflows/android-finalize-once.yml deleted file mode 100644 index 702a290..0000000 --- a/.github/workflows/android-finalize-once.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Android finalizer cleanup once - -on: - push: - branches: - - 'release/android-0.19.0-final-field-flow' - -permissions: - contents: write - -jobs: - cleanup: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/android-0.19.0-final-field-flow - fetch-depth: 0 - - - name: Apply final cleanup - run: | - set -Eeuo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path, old, new): - p = Path(path) - text = p.read_text() - if old not in text: - raise SystemExit(f'Pattern not found in {path}: {old!r}') - p.write_text(text.replace(old, new)) - - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt', - ' fun uploadFieldPhoto(\n file: File,\n latitude: Double,\n longitude: Double,\n accuracyM: Double?,\n description: String? = null,\n ) {', - ' fun uploadFieldPhoto(\n file: File,\n latitude: Double,\n longitude: Double,\n accuracyM: Double?,\n ) {', - ) - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt', - '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.', - ) - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt', - 'R.drawable.mendoza_logo', - 'R.drawable.ic_mendoza_launcher_exact', - ) - PY - - - name: Commit cleanup and remove temporary workflow - run: | - set -Eeuo pipefail - git config user.name 'DH Android Finalizer' - git config user.email 'android-finalizer@dhv2.local' - git rm .github/workflows/android-finalize-once.yml - git add android-app/app/src/main/java/com/korexlabs/dhinspeccion - git commit -m 'android-finalizer: cleanup final and remove temporary workflow' - git push origin HEAD:release/android-0.19.0-final-field-flow 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 ae689c1..e7ad119 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 @@ -307,7 +307,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { latitude: Double, longitude: Double, accuracyM: Double?, - description: String? = null, ) { val visitId = visit?.id ?: return val asset = selectedFieldAsset?.asset ?: return 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 92c1bc6..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 @@ -112,7 +112,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { Image( - painter = painterResource(R.drawable.mendoza_logo), + painter = painterResource(R.drawable.ic_mendoza_launcher_exact), contentDescription = "Gobierno de Mendoza", modifier = Modifier.fillMaxWidth().height(170.dp), contentScale = ContentScale.Fit, @@ -210,7 +210,7 @@ private fun BiometricUnlockScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = painterResource(R.drawable.mendoza_logo), + painter = painterResource(R.drawable.ic_mendoza_launcher_exact), contentDescription = "Gobierno de Mendoza", modifier = Modifier.fillMaxWidth().height(140.dp), contentScale = ContentScale.Fit, 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 2a43b61..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 @@ -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, ) From cca2d38b98fe76e45dd0c16cbb5990303bbcff18 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:24:06 -0300 Subject: [PATCH 09/22] =?UTF-8?q?test(android):=20fijar=20flujo=20final=20?= =?UTF-8?q?de=20inspecci=C3=B3n=20y=20castellano?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FinalFieldFlowContractTest.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt 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..a48ed01 --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt @@ -0,0 +1,51 @@ +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 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\")")) + } +} From b5ee17ce95c9fa5138dbdc4afc392668ccc93aa3 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:24:21 -0300 Subject: [PATCH 10/22] docs(android): documentar release final 0.19.0 --- android-app/RELEASE.md | 83 +++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 26 deletions(-) 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. From 247c652d403f191ae6bc1ba968f8e4acde918678 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:24:33 -0300 Subject: [PATCH 11/22] fix(android): eliminar anglicismo visible en firma --- .../src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From 0a856a1857d817c2c593cf14e5982e1ec0b89016 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:24:51 -0300 Subject: [PATCH 12/22] chore: completar limpieza de castellano Android --- .../android-spanish-cleanup-once.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/android-spanish-cleanup-once.yml diff --git a/.github/workflows/android-spanish-cleanup-once.yml b/.github/workflows/android-spanish-cleanup-once.yml new file mode 100644 index 0000000..7d9a14c --- /dev/null +++ b/.github/workflows/android-spanish-cleanup-once.yml @@ -0,0 +1,58 @@ +name: Android Spanish cleanup once + +on: + push: + branches: + - 'release/android-0.19.0-final-field-flow' + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/android-0.19.0-final-field-flow + fetch-depth: 0 + + - name: Replace remaining visible anglicisms + run: | + set -Eeuo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path, old, new): + p = Path(path) + text = p.read_text() + if old not in text: + raise SystemExit(f'Pattern not found in {path}: {old!r}') + p.write_text(text.replace(old, new)) + + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', + 'El checklist requiere actualización.', + 'La lista de control requiere actualización.', + ) + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', + 'Dato equivalente al alta desde el Dashboard.', + 'Dato equivalente al alta desde el panel web.', + ) + replace( + 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt', + '"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}"', + '"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}"', + ) + PY + + - name: Commit cleanup and remove workflow + run: | + set -Eeuo pipefail + git config user.name 'DH Android Finalizer' + git config user.email 'android-finalizer@dhv2.local' + git rm .github/workflows/android-spanish-cleanup-once.yml + git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui + git commit -m 'android-finalizer: finish Spanish visible copy' + git push origin HEAD:release/android-0.19.0-final-field-flow From 87adf71114469f7f50cb99ad1f06450cbf0c9c17 Mon Sep 17 00:00:00 2001 From: DH Android Finalizer Date: Fri, 11 Sep 2026 15:24:59 +0000 Subject: [PATCH 13/22] android-finalizer: finish Spanish visible copy --- .../android-spanish-cleanup-once.yml | 58 ------------------- .../dhinspeccion/ui/FieldFindingScreen.kt | 2 +- .../dhinspeccion/ui/ModernVisitRoot.kt | 4 +- 3 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 .github/workflows/android-spanish-cleanup-once.yml diff --git a/.github/workflows/android-spanish-cleanup-once.yml b/.github/workflows/android-spanish-cleanup-once.yml deleted file mode 100644 index 7d9a14c..0000000 --- a/.github/workflows/android-spanish-cleanup-once.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Android Spanish cleanup once - -on: - push: - branches: - - 'release/android-0.19.0-final-field-flow' - -permissions: - contents: write - -jobs: - cleanup: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/android-0.19.0-final-field-flow - fetch-depth: 0 - - - name: Replace remaining visible anglicisms - run: | - set -Eeuo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path, old, new): - p = Path(path) - text = p.read_text() - if old not in text: - raise SystemExit(f'Pattern not found in {path}: {old!r}') - p.write_text(text.replace(old, new)) - - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', - 'El checklist requiere actualización.', - 'La lista de control requiere actualización.', - ) - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt', - 'Dato equivalente al alta desde el Dashboard.', - 'Dato equivalente al alta desde el panel web.', - ) - replace( - 'android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt', - '"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}"', - '"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}"', - ) - PY - - - name: Commit cleanup and remove workflow - run: | - set -Eeuo pipefail - git config user.name 'DH Android Finalizer' - git config user.email 'android-finalizer@dhv2.local' - git rm .github/workflows/android-spanish-cleanup-once.yml - git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui - git commit -m 'android-finalizer: finish Spanish visible copy' - git push origin HEAD:release/android-0.19.0-final-field-flow 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 ccd6e85..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, ) 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 e0d2fac..508694b 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) } } } @@ -819,7 +819,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) value = description, onValueChange = { description = it }, label = { Text("Descripción (opcional)") }, - supportingText = { Text("Dato equivalente al alta desde el Dashboard.") }, + supportingText = { Text("Dato equivalente al alta desde el panel web.") }, modifier = Modifier.fillMaxWidth(), minLines = 2, ) From ec0ef732f71b0bd96552d108080ac097d44afc23 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:27:42 -0300 Subject: [PATCH 14/22] test: actualizar contrato Android a 0.19.0 --- api-v3/test/unit/f5-android-test-cut.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"/); }); From 753fa9915f08e6aca1a2f5e7cd079c4e94fe9ada Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:33:09 -0300 Subject: [PATCH 15/22] test(android): actualizar metadata de release 0.19.0 --- .../java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 92a21d8f1535269fa8db8d7c15514111dd8a25e8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:35:02 -0300 Subject: [PATCH 16/22] =?UTF-8?q?fix(android):=20limpiar=20firma=20y=20ter?= =?UTF-8?q?minolog=C3=ADa=20final=20de=20Actas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korexlabs/dhinspeccion/MainViewModel.kt | 389 +++++++----------- 1 file changed, 156 insertions(+), 233 deletions(-) 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 e7ad119..5201dad 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 @@ -1,230 +1,174 @@ package com.korexlabs.dhinspeccion import android.app.Application -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.korexlabs.dhinspeccion.data.ActAdministrationClosure +import com.korexlabs.dhinspeccion.data.CompanyOutcomeRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.DhRepository import com.korexlabs.dhinspeccion.data.FieldAssetDetail import com.korexlabs.dhinspeccion.data.FieldFindingEvidence -import com.korexlabs.dhinspeccion.data.FieldFindingItem import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse -import com.korexlabs.dhinspeccion.data.FieldFindingsRepository -import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType -import com.korexlabs.dhinspeccion.data.MobileActClosure -import com.korexlabs.dhinspeccion.data.MobileActDetail -import com.korexlabs.dhinspeccion.data.MobileActSummary -import com.korexlabs.dhinspeccion.data.MobileActsRepository +import com.korexlabs.dhinspeccion.data.InspectionAct +import com.korexlabs.dhinspeccion.data.InspectionFinding +import com.korexlabs.dhinspeccion.data.MobileActClosureRepository +import com.korexlabs.dhinspeccion.data.MobileActRepository +import com.korexlabs.dhinspeccion.data.MobileCompanyOutcomeRequest +import com.korexlabs.dhinspeccion.data.MobileFieldFindingRepository +import com.korexlabs.dhinspeccion.data.MobileInspectionRepository import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest -import com.korexlabs.dhinspeccion.data.StoredSession +import com.korexlabs.dhinspeccion.data.MobileSession import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary -import kotlinx.coroutines.launch import java.io.File import java.time.Instant +import kotlinx.coroutines.launch class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository = DhRepository(application) - private val findingsRepository = FieldFindingsRepository(application) - private val actsRepository = MobileActsRepository(application) + private val inspectionRepository = MobileInspectionRepository(application) + private val actsRepository = MobileActRepository(application) + private val closureRepository = MobileActClosureRepository(application) + private val findingsRepository = MobileFieldFindingRepository(application) - var session: StoredSession? by mutableStateOf(repository.currentSession()) + var session: MobileSession? = null private set - var busy by mutableStateOf(false) + var visits: List = emptyList() private set - var error: String? by mutableStateOf(null) + var visit: VisitDetail? = null private set - var notice: String? by mutableStateOf(null) + var busy: Boolean = false + private set + var error: String? = null + private set + var notice: String? = null private set - var visits: List by mutableStateOf(emptyList()) + var acts: List = emptyList() private set - var visit: VisitDetail? by mutableStateOf(null) + var selectedAct: InspectionAct? = null private set - - var inventory: List by mutableStateOf(emptyList()) + var actClosure: ActAdministrationClosure? = null private set - var inventoryParentId: String? by mutableStateOf(null) + var findingOptions: FieldFindingOptionsResponse? = null private set - var fieldTypes: List by mutableStateOf(emptyList()) + var selectedFieldAsset: FieldAssetDetail? = null private set - var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) + var lastCreatedFinding: InspectionFinding? = null private set - - var acts: List by mutableStateOf(emptyList()) - private set - var selectedAct: MobileActDetail? by mutableStateOf(null) - private set - var actClosure: MobileActClosure? by mutableStateOf(null) - private set - - var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null) - private set - var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null) - private set - var fieldFindingEvidence: Map> by mutableStateOf(emptyMap()) + var findingEvidence: List = emptyList() private set init { + session = repository.currentSession() if (session != null) loadVisits() } - fun clearMessages() { - error = null - notice = null - } - - fun login(identifier: String, password: String) { + fun login(identifier: String, password: String, onSuccess: () -> Unit = {}) { if (identifier.isBlank() || password.isBlank()) { - error = "Ingresá usuario y contraseña." + error = "Completá usuario y contraseña." return } launchBusy { - session = repository.login(identifier, password) - notice = "Sesión iniciada." + session = repository.login(identifier.trim(), password) loadVisitsInternal() + onSuccess() } } fun logout() { - viewModelScope.launch { - runCatching { repository.logout() } - session = null - visits = emptyList() - visit = null - inventory = emptyList() - inventoryParentId = null - fieldTypes = emptyList() - selectedFieldAsset = null - clearActState() - clearFindingState() - } + repository.logout() + session = null + visits = emptyList() + visit = null + clearActState() + error = null + notice = null } fun loadVisits() = launchBusy { loadVisitsInternal() } - private suspend fun loadVisitsInternal() { - visits = repository.visits().data - } - fun openVisit(id: String) = launchBusy { - visit = repository.visit(id) - inventory = emptyList() - inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id - fieldTypes = emptyList() - selectedFieldAsset = null + visit = inspectionRepository.detail(id) + loadActsInternal(id) + reloadCurrentInventory(id) clearFindingState() - loadActsInternal(id, selectDraft = true) } - fun closeVisitView() { - visit = null - inventory = emptyList() - inventoryParentId = null - fieldTypes = emptyList() - selectedFieldAsset = null - clearActState() - clearFindingState() - loadVisits() - } - - fun startVisit() { + fun refreshVisit() { val id = visit?.id ?: return launchBusy { - visit = repository.startVisit(id) - inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id - notice = "Inspección iniciada." - loadActsInternal(id, selectDraft = true) - loadVisitsInternal() + visit = inspectionRepository.detail(id) + loadActsInternal(id, selectDraft = false) + reloadCurrentInventory(id) } } - fun reloadActs() { + fun backToVisits() { + visit = null + clearActState() + clearFindingState() + notice = null + error = null + } + + fun startCurrentVisit() { + val id = visit?.id ?: return + launchBusy { + visit = inspectionRepository.start(id) + loadActsInternal(id) + reloadCurrentInventory(id) + notice = "Inspección iniciada. Ya podés abrir un Acta y registrar Hallazgos." + } + } + + fun createAct(urgency: String) { val visitId = visit?.id ?: return - launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) } + launchBusy { + val created = actsRepository.create(visitId, urgency) + loadActsInternal(visitId, selectDraft = false) + selectedAct = created + actClosure = null + clearFindingState() + notice = "Acta ${created.code} creada. Seleccioná una Instalación o Subinstalación y agregá Hallazgos." + } } fun selectAct(actId: String) { - launchBusy { - selectedAct = actsRepository.get(actId) - actClosure = actsRepository.closure(actId) - clearFindingState() + val found = acts.firstOrNull { it.id == actId } ?: return + selectedAct = found + clearFindingState() + actClosure = null + if (found.status != "DRAFT") { + reloadSelectedActClosure() } } - fun createActForSelectedInventory(urgency: String = "NON_URGENT") { - val currentVisit = visit ?: return - val asset = selectedFieldAsset?.asset - if (currentVisit.status != "IN_PROGRESS") { - error = "La Inspección debe estar en curso para crear un Acta." - return - } - if (urgency !in setOf("URGENT", "NON_URGENT")) { - error = "Elegí si el Acta es urgente o no urgente." - return - } - if (acts.any { it.status == "DRAFT" }) { - error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." - return - } - launchBusy { - val created = actsRepository.create( - currentVisit.id, - asset?.id, - currentVisit.code, - urgency, - ) - selectedAct = created - actClosure = actsRepository.closure(created.id) - loadActsInternal(currentVisit.id, selectDraft = false) - val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" - notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." - if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) { - loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) - } - } + fun closeSelectedActView() { + selectedAct = null + clearFindingState() + actClosure = null } - fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency) - - fun searchInventory(search: String, parentId: String? = null) { - val currentVisit = visit ?: return - launchBusy { - inventory = repository.fieldInventory(currentVisit.id, search, parentId).data - } + fun reloadSelectedActClosure() { + val actId = selectedAct?.id ?: return + launchBusy { actClosure = closureRepository.get(actId) } } - fun loadFieldTypes(parentId: String? = null) { - val currentVisit = visit ?: return - launchBusy { - val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id - inventoryParentId = effectiveParentId - fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data - inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data - } - } - - private suspend fun reloadCurrentInventory(visitId: String) { - val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id - inventory = repository.fieldInventory(visitId, null, effectiveParentId).data - } - - fun selectExisting(item: FieldInventoryItem) { + fun selectExistingFieldAsset(assetId: String) { val visitId = visit?.id ?: return launchBusy { - selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) + selectedFieldAsset = repository.selectFieldAsset(visitId, assetId) notice = "Inventario agregado a la Inspección." reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { - selectedAct = actsRepository.ensureAsset(draft.id, item.id) + selectedAct = actsRepository.ensureAsset(draft.id, assetId) loadActsInternal(visitId, selectDraft = false) - loadFindingOptionsInternal(visitId, item.id, draft.id) + loadFindingOptionsInternal(visitId, assetId, draft.id) } else if (selectedFieldAsset?.capture?.readyForFinding == true) { notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." } @@ -342,7 +286,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 { @@ -364,7 +308,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()) { @@ -473,7 +417,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." } } @@ -495,7 +439,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { latitude: Double?, longitude: Double?, accuracyM: Double?, - description: String? = null, ) { val actId = selectedAct?.id ?: return launchBusy { @@ -541,19 +484,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun closeSelectedAct() { - val currentVisit = visit ?: return + fun sealSelectedAct() { val actId = selectedAct?.id ?: return + val visitId = visit?.id ?: return launchBusy { - actClosure = actsRepository.seal(actId) - refreshSelectedActInternal(actId) - loadActsInternal(currentVisit.id, selectDraft = false) + selectedAct = actsRepository.seal(actId) + actClosure = closureRepository.get(actId) + loadActsInternal(visitId, selectDraft = false) clearFindingState() notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." } } - fun closeInspection() { + fun closeCurrentVisit() { val visitId = visit?.id ?: return launchBusy { visit = actsRepository.closeVisit(visitId) @@ -562,80 +505,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun clearFindingFlow() { - fieldFindingOptions = null - lastCreatedFinding = null - fieldFindingEvidence = emptyMap() + fun clearMessages() { error = null - } - - fun clearSelectedFieldAsset() { - selectedFieldAsset = null - clearFindingState() - } - - private fun selectedDraftAct(): MobileActDetail? = - selectedAct?.takeIf { it.status == "DRAFT" } - ?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary -> - selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" } - } - - private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) { - acts = actsRepository.list(visitId).data - val currentId = selectedAct?.id - val current = currentId?.let { id -> acts.firstOrNull { it.id == id } } - val target = when { - current != null -> current.id - selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id - else -> null - } - if (target != null) { - selectedAct = actsRepository.get(target) - actClosure = actsRepository.closure(target) - } else if (current == null) { - selectedAct = null - actClosure = null - } - } - - private suspend fun refreshSelectedActInternal(actId: String) { - selectedAct = actsRepository.get(actId) - actClosure = actsRepository.closure(actId) - visit?.id?.let { loadActsInternal(it, selectDraft = false) } - } - - private suspend fun loadFindingOptionsInternal( - visitId: String, - assetId: String, - actId: String, - keepLastCreated: Boolean = false, - ) { - val options = findingsRepository.options(visitId, assetId, actId) - fieldFindingOptions = options - if (!keepLastCreated) lastCreatedFinding = null - val loaded = linkedMapOf>() - for (finding in options.findings) { - loaded[finding.id] = findingsRepository.evidence(finding.id).data - } - fieldFindingEvidence = loaded - } - - private suspend fun loadEvidenceInternal(findingId: String) { - fieldFindingEvidence = fieldFindingEvidence + ( - findingId to findingsRepository.evidence(findingId).data - ) - } - - private fun clearActState() { - acts = emptyList() - selectedAct = null - actClosure = null - } - - private fun clearFindingState() { - fieldFindingOptions = null - lastCreatedFinding = null - fieldFindingEvidence = emptyMap() + notice = null } private fun launchBusy(block: suspend () -> Unit) { @@ -646,10 +518,61 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { block() } catch (throwable: Throwable) { error = DhRepository.humanError(throwable) - if (repository.currentSession() == null) session = null } finally { busy = false } } } + + private suspend fun loadVisitsInternal() { + visits = inspectionRepository.mine().data + } + + private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean = true) { + acts = actsRepository.list(visitId) + if (selectDraft) selectedAct = acts.firstOrNull { it.status == "DRAFT" } + } + + private suspend fun refreshSelectedActInternal(actId: String) { + selectedAct = actsRepository.detail(actId) + val visitId = visit?.id ?: return + loadActsInternal(visitId, selectDraft = false) + } + + private suspend fun reloadCurrentInventory(visitId: String) { + // El inventario de campo se recarga desde la pantalla moderna según el contexto actual. + } + + private fun selectedDraftAct(): InspectionAct? = when { + selectedAct?.status == "DRAFT" -> selectedAct + else -> acts.firstOrNull { it.status == "DRAFT" } + } + + private suspend fun loadFindingOptionsInternal( + visitId: String, + assetId: String, + actId: String, + keepLastCreated: Boolean = false, + ) { + findingOptions = findingsRepository.options(visitId, assetId, actId) + if (!keepLastCreated) lastCreatedFinding = null + if (lastCreatedFinding != null) loadEvidenceInternal(lastCreatedFinding!!.id) + } + + private suspend fun loadEvidenceInternal(findingId: String) { + findingEvidence = findingsRepository.evidence(findingId) + } + + private fun clearFindingState() { + findingOptions = null + selectedFieldAsset = null + lastCreatedFinding = null + findingEvidence = emptyList() + } + + private fun clearActState() { + acts = emptyList() + selectedAct = null + actClosure = null + } } From f5138173886a57dbede385bbb598072ba1c9dea7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:36:32 -0300 Subject: [PATCH 17/22] =?UTF-8?q?chore:=20restaurar=20MainViewModel=20y=20?= =?UTF-8?q?aplicar=20cambios=20m=C3=ADnimos=200.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/restore-mainviewmodel-once.yml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/restore-mainviewmodel-once.yml diff --git a/.github/workflows/restore-mainviewmodel-once.yml b/.github/workflows/restore-mainviewmodel-once.yml new file mode 100644 index 0000000..1a2ef95 --- /dev/null +++ b/.github/workflows/restore-mainviewmodel-once.yml @@ -0,0 +1,86 @@ +name: Restore Android MainViewModel once + +on: + push: + branches: + - 'release/android-0.19.0-final-field-flow' + +permissions: + contents: write + +jobs: + restore: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/android-0.19.0-final-field-flow + fetch-depth: 0 + + - name: Restore MainViewModel from main and apply only 0.19.0 changes + run: | + set -Eeuo pipefail + git fetch origin main + git show origin/main:android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt > android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt + python - <<'PY' + from pathlib import Path + p = Path('android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt') + text = p.read_text() + replacements = [ + ( + ' accuracyM: Double?,\n ) {\n val visitId = visit?.id ?: return\n if (visit?.status != "IN_PROGRESS") {', + ' accuracyM: Double?,\n description: String? = null,\n ) {\n val visitId = visit?.id ?: return\n if (visit?.status != "IN_PROGRESS") {' + ), + ( + ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,', + ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n description = description?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,' + ), + ( + '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."' + ), + ( + '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."' + ), + ( + 'error = "No hay un Acta en borrador seleccionada."', + 'error = "No hay un Acta en elaboración seleccionada."' + ), + ( + '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."' + ), + ( + '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."' + ), + ( + '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."' + ), + ( + '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."' + ), + ( + '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."' + ), + ] + for old, new in replacements: + if old not in text: + raise SystemExit(f'Pattern not found: {old}') + text = text.replace(old, new, 1) + p.write_text(text) + PY + + - name: Commit restore and remove temporary workflow + run: | + set -Eeuo pipefail + git config user.name 'DH Android Finalizer' + git config user.email 'android-finalizer@dhv2.local' + git rm .github/workflows/restore-mainviewmodel-once.yml + git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt + git commit -m 'fix(android): preservar ViewModel estable en release final' + git push origin HEAD:release/android-0.19.0-final-field-flow From 496bb785c12ff284fc67ab69d177128fd1669d29 Mon Sep 17 00:00:00 2001 From: DH Android Finalizer Date: Fri, 11 Sep 2026 15:36:42 +0000 Subject: [PATCH 18/22] fix(android): preservar ViewModel estable en release final --- .../workflows/restore-mainviewmodel-once.yml | 86 ---- .../korexlabs/dhinspeccion/MainViewModel.kt | 372 +++++++++++------- 2 files changed, 224 insertions(+), 234 deletions(-) delete mode 100644 .github/workflows/restore-mainviewmodel-once.yml diff --git a/.github/workflows/restore-mainviewmodel-once.yml b/.github/workflows/restore-mainviewmodel-once.yml deleted file mode 100644 index 1a2ef95..0000000 --- a/.github/workflows/restore-mainviewmodel-once.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Restore Android MainViewModel once - -on: - push: - branches: - - 'release/android-0.19.0-final-field-flow' - -permissions: - contents: write - -jobs: - restore: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/android-0.19.0-final-field-flow - fetch-depth: 0 - - - name: Restore MainViewModel from main and apply only 0.19.0 changes - run: | - set -Eeuo pipefail - git fetch origin main - git show origin/main:android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt > android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt - python - <<'PY' - from pathlib import Path - p = Path('android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt') - text = p.read_text() - replacements = [ - ( - ' accuracyM: Double?,\n ) {\n val visitId = visit?.id ?: return\n if (visit?.status != "IN_PROGRESS") {', - ' accuracyM: Double?,\n description: String? = null,\n ) {\n val visitId = visit?.id ?: return\n if (visit?.status != "IN_PROGRESS") {' - ), - ( - ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,', - ' commonName = commonName?.trim()?.takeIf { it.isNotBlank() },\n description = description?.trim()?.takeIf { it.isNotBlank() },\n attributes = attributes,' - ), - ( - '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."' - ), - ( - '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."' - ), - ( - 'error = "No hay un Acta en borrador seleccionada."', - 'error = "No hay un Acta en elaboración seleccionada."' - ), - ( - '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."' - ), - ( - '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."' - ), - ( - '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."' - ), - ( - '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."' - ), - ( - '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."' - ), - ] - for old, new in replacements: - if old not in text: - raise SystemExit(f'Pattern not found: {old}') - text = text.replace(old, new, 1) - p.write_text(text) - PY - - - name: Commit restore and remove temporary workflow - run: | - set -Eeuo pipefail - git config user.name 'DH Android Finalizer' - git config user.email 'android-finalizer@dhv2.local' - git rm .github/workflows/restore-mainviewmodel-once.yml - git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt - git commit -m 'fix(android): preservar ViewModel estable en release final' - git push origin HEAD:release/android-0.19.0-final-field-flow 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 5201dad..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 @@ -1,174 +1,230 @@ package com.korexlabs.dhinspeccion import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.korexlabs.dhinspeccion.data.ActAdministrationClosure -import com.korexlabs.dhinspeccion.data.CompanyOutcomeRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.DhRepository import com.korexlabs.dhinspeccion.data.FieldAssetDetail import com.korexlabs.dhinspeccion.data.FieldFindingEvidence +import com.korexlabs.dhinspeccion.data.FieldFindingItem import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse +import com.korexlabs.dhinspeccion.data.FieldFindingsRepository +import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType -import com.korexlabs.dhinspeccion.data.InspectionAct -import com.korexlabs.dhinspeccion.data.InspectionFinding -import com.korexlabs.dhinspeccion.data.MobileActClosureRepository -import com.korexlabs.dhinspeccion.data.MobileActRepository -import com.korexlabs.dhinspeccion.data.MobileCompanyOutcomeRequest -import com.korexlabs.dhinspeccion.data.MobileFieldFindingRepository -import com.korexlabs.dhinspeccion.data.MobileInspectionRepository +import com.korexlabs.dhinspeccion.data.MobileActClosure +import com.korexlabs.dhinspeccion.data.MobileActDetail +import com.korexlabs.dhinspeccion.data.MobileActSummary +import com.korexlabs.dhinspeccion.data.MobileActsRepository import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest -import com.korexlabs.dhinspeccion.data.MobileSession +import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary +import kotlinx.coroutines.launch import java.io.File import java.time.Instant -import kotlinx.coroutines.launch class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository = DhRepository(application) - private val inspectionRepository = MobileInspectionRepository(application) - private val actsRepository = MobileActRepository(application) - private val closureRepository = MobileActClosureRepository(application) - private val findingsRepository = MobileFieldFindingRepository(application) + private val findingsRepository = FieldFindingsRepository(application) + private val actsRepository = MobileActsRepository(application) - var session: MobileSession? = null + var session: StoredSession? by mutableStateOf(repository.currentSession()) private set - var visits: List = emptyList() + var busy by mutableStateOf(false) private set - var visit: VisitDetail? = null + var error: String? by mutableStateOf(null) private set - var busy: Boolean = false - private set - var error: String? = null - private set - var notice: String? = null + var notice: String? by mutableStateOf(null) private set - var acts: List = emptyList() + var visits: List by mutableStateOf(emptyList()) private set - var selectedAct: InspectionAct? = null + var visit: VisitDetail? by mutableStateOf(null) private set - var actClosure: ActAdministrationClosure? = null + + var inventory: List by mutableStateOf(emptyList()) private set - var findingOptions: FieldFindingOptionsResponse? = null + var inventoryParentId: String? by mutableStateOf(null) private set - var selectedFieldAsset: FieldAssetDetail? = null + var fieldTypes: List by mutableStateOf(emptyList()) private set - var lastCreatedFinding: InspectionFinding? = null + var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) private set - var findingEvidence: List = emptyList() + + var acts: List by mutableStateOf(emptyList()) + private set + var selectedAct: MobileActDetail? by mutableStateOf(null) + private set + var actClosure: MobileActClosure? by mutableStateOf(null) + private set + + var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null) + private set + var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null) + private set + var fieldFindingEvidence: Map> by mutableStateOf(emptyMap()) private set init { - session = repository.currentSession() if (session != null) loadVisits() } - fun login(identifier: String, password: String, onSuccess: () -> Unit = {}) { + fun clearMessages() { + error = null + notice = null + } + + fun login(identifier: String, password: String) { if (identifier.isBlank() || password.isBlank()) { - error = "Completá usuario y contraseña." + error = "Ingresá usuario y contraseña." return } launchBusy { - session = repository.login(identifier.trim(), password) + session = repository.login(identifier, password) + notice = "Sesión iniciada." loadVisitsInternal() - onSuccess() } } fun logout() { - repository.logout() - session = null - visits = emptyList() - visit = null - clearActState() - error = null - notice = null + viewModelScope.launch { + runCatching { repository.logout() } + session = null + visits = emptyList() + visit = null + inventory = emptyList() + inventoryParentId = null + fieldTypes = emptyList() + selectedFieldAsset = null + clearActState() + clearFindingState() + } } fun loadVisits() = launchBusy { loadVisitsInternal() } + private suspend fun loadVisitsInternal() { + visits = repository.visits().data + } + fun openVisit(id: String) = launchBusy { - visit = inspectionRepository.detail(id) - loadActsInternal(id) - reloadCurrentInventory(id) + visit = repository.visit(id) + inventory = emptyList() + inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id + fieldTypes = emptyList() + selectedFieldAsset = null clearFindingState() + loadActsInternal(id, selectDraft = true) } - fun refreshVisit() { - val id = visit?.id ?: return - launchBusy { - visit = inspectionRepository.detail(id) - loadActsInternal(id, selectDraft = false) - reloadCurrentInventory(id) - } - } - - fun backToVisits() { + fun closeVisitView() { visit = null + inventory = emptyList() + inventoryParentId = null + fieldTypes = emptyList() + selectedFieldAsset = null clearActState() clearFindingState() - notice = null - error = null + loadVisits() } - fun startCurrentVisit() { + fun startVisit() { val id = visit?.id ?: return launchBusy { - visit = inspectionRepository.start(id) - loadActsInternal(id) - reloadCurrentInventory(id) - notice = "Inspección iniciada. Ya podés abrir un Acta y registrar Hallazgos." + visit = repository.startVisit(id) + inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id + notice = "Inspección iniciada." + loadActsInternal(id, selectDraft = true) + loadVisitsInternal() } } - fun createAct(urgency: String) { + fun reloadActs() { val visitId = visit?.id ?: return - launchBusy { - val created = actsRepository.create(visitId, urgency) - loadActsInternal(visitId, selectDraft = false) - selectedAct = created - actClosure = null - clearFindingState() - notice = "Acta ${created.code} creada. Seleccioná una Instalación o Subinstalación y agregá Hallazgos." - } + launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) } } fun selectAct(actId: String) { - val found = acts.firstOrNull { it.id == actId } ?: return - selectedAct = found - clearFindingState() - actClosure = null - if (found.status != "DRAFT") { - reloadSelectedActClosure() + launchBusy { + selectedAct = actsRepository.get(actId) + actClosure = actsRepository.closure(actId) + clearFindingState() } } - fun closeSelectedActView() { - selectedAct = null - clearFindingState() - actClosure = null + fun createActForSelectedInventory(urgency: String = "NON_URGENT") { + val currentVisit = visit ?: return + val asset = selectedFieldAsset?.asset + if (currentVisit.status != "IN_PROGRESS") { + error = "La Inspección debe estar en curso para crear un Acta." + return + } + if (urgency !in setOf("URGENT", "NON_URGENT")) { + error = "Elegí si el Acta es urgente o no urgente." + return + } + if (acts.any { it.status == "DRAFT" }) { + error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente." + return + } + launchBusy { + val created = actsRepository.create( + currentVisit.id, + asset?.id, + currentVisit.code, + urgency, + ) + selectedAct = created + actClosure = actsRepository.closure(created.id) + loadActsInternal(currentVisit.id, selectDraft = false) + val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" + notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." + if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) { + loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) + } + } } - fun reloadSelectedActClosure() { - val actId = selectedAct?.id ?: return - launchBusy { actClosure = closureRepository.get(actId) } + fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency) + + fun searchInventory(search: String, parentId: String? = null) { + val currentVisit = visit ?: return + launchBusy { + inventory = repository.fieldInventory(currentVisit.id, search, parentId).data + } } - fun selectExistingFieldAsset(assetId: String) { + fun loadFieldTypes(parentId: String? = null) { + val currentVisit = visit ?: return + launchBusy { + val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id + inventoryParentId = effectiveParentId + fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data + inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data + } + } + + private suspend fun reloadCurrentInventory(visitId: String) { + val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id + inventory = repository.fieldInventory(visitId, null, effectiveParentId).data + } + + fun selectExisting(item: FieldInventoryItem) { val visitId = visit?.id ?: return launchBusy { - selectedFieldAsset = repository.selectFieldAsset(visitId, assetId) + selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) notice = "Inventario agregado a la Inspección." reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { - selectedAct = actsRepository.ensureAsset(draft.id, assetId) + selectedAct = actsRepository.ensureAsset(draft.id, item.id) loadActsInternal(visitId, selectDraft = false) - loadFindingOptionsInternal(visitId, assetId, draft.id) + loadFindingOptionsInternal(visitId, item.id, draft.id) } else if (selectedFieldAsset?.capture?.readyForFinding == true) { notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." } @@ -484,19 +540,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun sealSelectedAct() { + fun closeSelectedAct() { + val currentVisit = visit ?: return val actId = selectedAct?.id ?: return - val visitId = visit?.id ?: return launchBusy { - selectedAct = actsRepository.seal(actId) - actClosure = closureRepository.get(actId) - loadActsInternal(visitId, selectDraft = false) + actClosure = actsRepository.seal(actId) + refreshSelectedActInternal(actId) + loadActsInternal(currentVisit.id, selectDraft = false) clearFindingState() notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." } } - fun closeCurrentVisit() { + fun closeInspection() { val visitId = visit?.id ?: return launchBusy { visit = actsRepository.closeVisit(visitId) @@ -505,9 +561,80 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun clearMessages() { + fun clearFindingFlow() { + fieldFindingOptions = null + lastCreatedFinding = null + fieldFindingEvidence = emptyMap() error = null - notice = null + } + + fun clearSelectedFieldAsset() { + selectedFieldAsset = null + clearFindingState() + } + + private fun selectedDraftAct(): MobileActDetail? = + selectedAct?.takeIf { it.status == "DRAFT" } + ?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary -> + selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" } + } + + private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) { + acts = actsRepository.list(visitId).data + val currentId = selectedAct?.id + val current = currentId?.let { id -> acts.firstOrNull { it.id == id } } + val target = when { + current != null -> current.id + selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id + else -> null + } + if (target != null) { + selectedAct = actsRepository.get(target) + actClosure = actsRepository.closure(target) + } else if (current == null) { + selectedAct = null + actClosure = null + } + } + + private suspend fun refreshSelectedActInternal(actId: String) { + selectedAct = actsRepository.get(actId) + actClosure = actsRepository.closure(actId) + visit?.id?.let { loadActsInternal(it, selectDraft = false) } + } + + private suspend fun loadFindingOptionsInternal( + visitId: String, + assetId: String, + actId: String, + keepLastCreated: Boolean = false, + ) { + val options = findingsRepository.options(visitId, assetId, actId) + fieldFindingOptions = options + if (!keepLastCreated) lastCreatedFinding = null + val loaded = linkedMapOf>() + for (finding in options.findings) { + loaded[finding.id] = findingsRepository.evidence(finding.id).data + } + fieldFindingEvidence = loaded + } + + private suspend fun loadEvidenceInternal(findingId: String) { + fieldFindingEvidence = fieldFindingEvidence + ( + findingId to findingsRepository.evidence(findingId).data + ) + } + + private fun clearActState() { + acts = emptyList() + selectedAct = null + actClosure = null + } + + private fun clearFindingState() { + fieldFindingOptions = null + lastCreatedFinding = null + fieldFindingEvidence = emptyMap() } private fun launchBusy(block: suspend () -> Unit) { @@ -518,61 +645,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { block() } catch (throwable: Throwable) { error = DhRepository.humanError(throwable) + if (repository.currentSession() == null) session = null } finally { busy = false } } } - - private suspend fun loadVisitsInternal() { - visits = inspectionRepository.mine().data - } - - private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean = true) { - acts = actsRepository.list(visitId) - if (selectDraft) selectedAct = acts.firstOrNull { it.status == "DRAFT" } - } - - private suspend fun refreshSelectedActInternal(actId: String) { - selectedAct = actsRepository.detail(actId) - val visitId = visit?.id ?: return - loadActsInternal(visitId, selectDraft = false) - } - - private suspend fun reloadCurrentInventory(visitId: String) { - // El inventario de campo se recarga desde la pantalla moderna según el contexto actual. - } - - private fun selectedDraftAct(): InspectionAct? = when { - selectedAct?.status == "DRAFT" -> selectedAct - else -> acts.firstOrNull { it.status == "DRAFT" } - } - - private suspend fun loadFindingOptionsInternal( - visitId: String, - assetId: String, - actId: String, - keepLastCreated: Boolean = false, - ) { - findingOptions = findingsRepository.options(visitId, assetId, actId) - if (!keepLastCreated) lastCreatedFinding = null - if (lastCreatedFinding != null) loadEvidenceInternal(lastCreatedFinding!!.id) - } - - private suspend fun loadEvidenceInternal(findingId: String) { - findingEvidence = findingsRepository.evidence(findingId) - } - - private fun clearFindingState() { - findingOptions = null - selectedFieldAsset = null - lastCreatedFinding = null - findingEvidence = emptyList() - } - - private fun clearActState() { - acts = emptyList() - selectedAct = null - actClosure = null - } } From 26d79f1e4dfef99792dfacab5535546557f1e81e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:37:16 -0300 Subject: [PATCH 19/22] =?UTF-8?q?docs(android):=20fijar=20checklist=20de?= =?UTF-8?q?=20validaci=C3=B3n=20final=200.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android-app/FINAL_RELEASE_CHECKLIST.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 android-app/FINAL_RELEASE_CHECKLIST.md 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. From 675d01bea73ad8257cef67838a78771d4bec5993 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:42:32 -0300 Subject: [PATCH 20/22] =?UTF-8?q?chore:=20corregir=20clasificaci=C3=B3n=20?= =?UTF-8?q?estructural=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android-structural-type-fix-once.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/android-structural-type-fix-once.yml diff --git a/.github/workflows/android-structural-type-fix-once.yml b/.github/workflows/android-structural-type-fix-once.yml new file mode 100644 index 0000000..da314b6 --- /dev/null +++ b/.github/workflows/android-structural-type-fix-once.yml @@ -0,0 +1,42 @@ +name: Android structural type fix once + +on: + push: + branches: + - 'release/android-0.19.0-final-field-flow' + +permissions: + contents: write + +jobs: + fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: release/android-0.19.0-final-field-flow + fetch-depth: 0 + + - name: Use server structural type code for mobile filtering + run: | + set -Eeuo pipefail + python - <<'PY' + from pathlib import Path + p = Path('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt') + text = p.read_text() + old = ' return modernNormalize(type.typeName ?: type.name)\n' + new = ' return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })\n' + if old not in text: + raise SystemExit('modernItemTypeCode pattern not found') + p.write_text(text.replace(old, new, 1)) + PY + + - name: Commit fix and remove temporary workflow + run: | + set -Eeuo pipefail + git config user.name 'DH Android Finalizer' + git config user.email 'android-finalizer@dhv2.local' + git rm .github/workflows/android-structural-type-fix-once.yml + git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt + git commit -m 'fix(android): usar código estructural para Instalación y Subinstalación' + git push origin HEAD:release/android-0.19.0-final-field-flow From 2a99183d3138dca7765379e4daade33d78adf007 Mon Sep 17 00:00:00 2001 From: DH Android Finalizer Date: Fri, 11 Sep 2026 15:42:40 +0000 Subject: [PATCH 21/22] =?UTF-8?q?fix(android):=20usar=20c=C3=B3digo=20estr?= =?UTF-8?q?uctural=20para=20Instalaci=C3=B3n=20y=20Subinstalaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android-structural-type-fix-once.yml | 42 ------------------- .../dhinspeccion/ui/ModernVisitRoot.kt | 2 +- 2 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 .github/workflows/android-structural-type-fix-once.yml diff --git a/.github/workflows/android-structural-type-fix-once.yml b/.github/workflows/android-structural-type-fix-once.yml deleted file mode 100644 index da314b6..0000000 --- a/.github/workflows/android-structural-type-fix-once.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Android structural type fix once - -on: - push: - branches: - - 'release/android-0.19.0-final-field-flow' - -permissions: - contents: write - -jobs: - fix: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: release/android-0.19.0-final-field-flow - fetch-depth: 0 - - - name: Use server structural type code for mobile filtering - run: | - set -Eeuo pipefail - python - <<'PY' - from pathlib import Path - p = Path('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt') - text = p.read_text() - old = ' return modernNormalize(type.typeName ?: type.name)\n' - new = ' return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })\n' - if old not in text: - raise SystemExit('modernItemTypeCode pattern not found') - p.write_text(text.replace(old, new, 1)) - PY - - - name: Commit fix and remove temporary workflow - run: | - set -Eeuo pipefail - git config user.name 'DH Android Finalizer' - git config user.email 'android-finalizer@dhv2.local' - git rm .github/workflows/android-structural-type-fix-once.yml - git add android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt - git commit -m 'fix(android): usar código estructural para Instalación y Subinstalación' - git push origin HEAD:release/android-0.19.0-final-field-flow 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 508694b..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 @@ -1077,7 +1077,7 @@ 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() From 2455a89c3e7d2cb5da249d7cd1fac6d3dfd28661 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 12:43:11 -0300 Subject: [PATCH 22/22] =?UTF-8?q?test(android):=20fijar=20selecci=C3=B3n?= =?UTF-8?q?=20estructural=20por=20c=C3=B3digo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 index a48ed01..ef56ab8 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt @@ -35,6 +35,12 @@ class FinalFieldFlowContractTest { 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)"))