From ed3c2c295a035b7cc96082d3a326c768984783f7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:46:51 -0300 Subject: [PATCH 01/14] feat(android): add modern field theme --- .../com/korexlabs/dhinspeccion/ui/DhTheme.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt new file mode 100644 index 0000000..4c3421f --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt @@ -0,0 +1,73 @@ +package com.korexlabs.dhinspeccion.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +private val DhLightColors = lightColorScheme( + primary = Color(0xFF075E63), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD6F0F0), + onPrimaryContainer = Color(0xFF073F43), + secondary = Color(0xFF445E74), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFDCE7F1), + onSecondaryContainer = Color(0xFF243F54), + tertiary = Color(0xFF6A5A42), + background = Color(0xFFF6F8FA), + onBackground = Color(0xFF182025), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF182025), + surfaceVariant = Color(0xFFEDF1F4), + onSurfaceVariant = Color(0xFF455158), + outline = Color(0xFF77848B), + outlineVariant = Color(0xFFD7DEE2), + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF6F1713), +) + +private val DhDarkColors = darkColorScheme( + primary = Color(0xFF88D4D7), + onPrimary = Color(0xFF00373A), + primaryContainer = Color(0xFF074F53), + onPrimaryContainer = Color(0xFFB7EBED), + secondary = Color(0xFFB6CBE0), + onSecondary = Color(0xFF203646), + secondaryContainer = Color(0xFF344C60), + onSecondaryContainer = Color(0xFFDCE7F1), + background = Color(0xFF101416), + onBackground = Color(0xFFE0E4E6), + surface = Color(0xFF171C1F), + onSurface = Color(0xFFE0E4E6), + surfaceVariant = Color(0xFF263035), + onSurfaceVariant = Color(0xFFC3CDD2), + outline = Color(0xFF8C989E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), +) + +private val DhShapes = androidx.compose.material3.Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp), +) + +@Composable +fun DhTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors, + shapes = DhShapes, + content = content, + ) +} From ca36140255914e8255f6c1728f51453eab50df41 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:47:25 -0300 Subject: [PATCH 02/14] fix(api): decouple mobile Act list from document workflow --- .../mobile-inspection-acts.service.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 api-v3/src/inspection-acts/mobile-inspection-acts.service.ts diff --git a/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts b/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts new file mode 100644 index 0000000..2197f9c --- /dev/null +++ b/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts @@ -0,0 +1,102 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; + +/** + * Read model intentionally kept small for the field client. + * + * The Android app only needs the Act identity/lifecycle, deadlines and counters to + * open an Inspection. It must not fail because an office-document/report module is + * unavailable or temporarily schema-drifted. The canonical office endpoints keep + * their richer projection in InspectionActsService. + */ +@Injectable() +export class MobileInspectionActsService { + constructor(private readonly dataSource: DataSource) {} + + async listForVisit(visitId: string, query: ListInspectionActsQueryDto) { + const [visit] = (await this.dataSource.query( + 'SELECT id FROM inspection_visits WHERE id = $1::uuid', + [visitId], + )) as Array<{ id: string }>; + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); + } + + const conditions = ['act.visit_id = $1::uuid']; + const parameters: unknown[] = [visitId]; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`); + } + if (query.status) conditions.push(`act.status = ${add(query.status)}`); + if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + + const where = `WHERE ${conditions.join(' AND ')}`; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = await this.dataSource.query(` + SELECT + act.id, + act.visit_id AS "visitId", + act.code, + act.status, + act.occurred_at AS "occurredAt", + act.title, + act.summary, + act.observations, + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", + act.current_version AS "currentVersion", + act.closed_at AS "closedAt", + act.closure_sha256 AS "closureSha256", + COALESCE(( + SELECT COUNT(*)::integer + FROM inspection_act_assets link + WHERE link.act_id = act.id AND link.included = true + ), 0)::integer AS "assetCount", + COALESCE(( + SELECT COUNT(*)::integer + FROM inspection_findings finding + WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + ), 0)::integer AS "findingCount" + FROM inspection_acts act + ${where} + ORDER BY act.act_year DESC, act.act_number DESC + LIMIT ${limit} OFFSET ${offset} + `, parameters); + + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } +} From 95c5f4d0cab7ba5190d1ae4b97dfc59402d317ee Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:47:39 -0300 Subject: [PATCH 03/14] fix(api): expose lightweight mobile Act list --- .../mobile-inspection-acts.controller.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 api-v3/src/inspection-acts/mobile-inspection-acts.controller.ts diff --git a/api-v3/src/inspection-acts/mobile-inspection-acts.controller.ts b/api-v3/src/inspection-acts/mobile-inspection-acts.controller.ts new file mode 100644 index 0000000..1e8106d --- /dev/null +++ b/api-v3/src/inspection-acts/mobile-inspection-acts.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; +import { MobileInspectionActsService } from './mobile-inspection-acts.service'; + +@Controller('inspection-visits/:visitId/acts/mobile') +export class MobileInspectionActsController { + constructor(private readonly acts: MobileInspectionActsService) {} + + @Get() + @RequirePermissions('inspection_acts.read') + list( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Query() query: ListInspectionActsQueryDto, + ) { + return this.acts.listForVisit(visitId, query); + } +} From fc8c3ba7ef2df27c985a1d601a713078d2701e51 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:47:48 -0300 Subject: [PATCH 04/14] fix(api): register mobile Act read model --- api-v3/src/inspection-acts/inspection-acts.module.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-acts/inspection-acts.module.ts b/api-v3/src/inspection-acts/inspection-acts.module.ts index 3b9d039..1d714a9 100644 --- a/api-v3/src/inspection-acts/inspection-acts.module.ts +++ b/api-v3/src/inspection-acts/inspection-acts.module.ts @@ -5,10 +5,16 @@ import { InspectionVisitActsController, } from './inspection-acts.controller'; import { InspectionActsService } from './inspection-acts.service'; +import { MobileInspectionActsController } from './mobile-inspection-acts.controller'; +import { MobileInspectionActsService } from './mobile-inspection-acts.service'; @Module({ imports: [AuditModule], - controllers: [InspectionVisitActsController, InspectionActsController], - providers: [InspectionActsService], + controllers: [ + InspectionVisitActsController, + MobileInspectionActsController, + InspectionActsController, + ], + providers: [InspectionActsService, MobileInspectionActsService], }) export class InspectionActsModule {} From 95012cf373c931c22781b61ea0a89c207a290844 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:52:56 -0300 Subject: [PATCH 05/14] feat(android): modernize Actas field workspace --- .../dhinspeccion/ui/ModernMobileActsScreen.kt | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt 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 new file mode 100644 index 0000000..50020ce --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -0,0 +1,565 @@ +package com.korexlabs.dhinspeccion.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Assignment +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.korexlabs.dhinspeccion.MainViewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private data class ModernActSignatureGeo( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +@Composable +fun ModernMobileActsScreen( + model: MainViewModel, + onBack: () -> Unit, + onGoInventory: () -> Unit, +) { + val visit = model.visit ?: return + val selected = model.selectedAct + val closure = model.actClosure + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } + var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } + var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) } + var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") } + var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) } + var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) } + var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) } + var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) } + var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) } + var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") } + var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") } + var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") } + + LaunchedEffect(visit.id) { model.reloadActs() } + + fun signWithGeo(file: File, company: Boolean) { + scope.launch { + val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull() + if (company) { + model.signSelectedActAsCompany( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + manifestation = manifestation, + statement = dissentStatement.takeIf { manifestation == "DISSENT" }, + ) + } else { + model.signSelectedActAsInspector( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + ) + } + } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + tonalElevation = 1.dp, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + visit.scopeAsset?.name ?: "Yacimiento", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + ModernMessage(model) + + if (model.acts.isEmpty()) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary) + Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + model.acts.forEach { act -> + val active = selected?.id == act.id + Surface( + onClick = { model.selectAct(act.id) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + tonalElevation = if (active) 1.dp else 0.dp, + border = BorderStroke( + if (active) 2.dp else 1.dp, + if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Row( + Modifier.padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + when (act.status) { + "LOCKED" -> Icons.Filled.Lock + "SEALED" -> Icons.Filled.CheckCircle + else -> Icons.Filled.EditNote + }, + contentDescription = null, + tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(act.code, fontWeight = FontWeight.Bold) + Text( + "${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary) + } + } + } + } + + val hasDraftAct = model.acts.any { it.status == "DRAFT" } + if (visit.status == "IN_PROGRESS" && !hasDraftAct) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) { + Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.width(10.dp)) + Column { + Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Text("Urgencia", fontWeight = FontWeight.SemiBold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = newActUrgency == "NON_URGENT", + onClick = { newActUrgency = "NON_URGENT" }, + label = { Text("No urgente") }, + ) + FilterChip( + selected = newActUrgency == "URGENT", + onClick = { newActUrgency = "URGENT" }, + label = { Text("Urgente") }, + ) + } + Surface( + Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + if (newActUrgency == "URGENT") { + "El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla." + } else { + "El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta." + }, + Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + onClick = { model.createAct(newActUrgency) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(7.dp)) + Text(if (model.busy) "Creando…" else "Crear nueva Acta") + } + } + } + } + + if (selected != null) { + HorizontalDivider() + Surface( + Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + StatusPill(modernActStatusLabel(selected.status)) + } + Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall) + selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) } + if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { + Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall) + } + } + } + + when (selected.status) { + "DRAFT" -> { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(6.dp)) + Text("Agregar Hallazgo") + } + } + } + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") }) + FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") }) + } + if (attendance == "PRESENT") { + 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) }) + } + } + 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(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Button( + onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, + enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar responsable") } + } else { + OutlinedTextField( + absenceReason, + { absenceReason = it }, + label = { Text("Motivo de ausencia *") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { model.setCompanyResponsibleAbsent(absenceReason) }, + enabled = !model.busy && absenceReason.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar ausencia") } + } + } + } + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + 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( + "Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { model.prepareSelectedAct() }, + enabled = !model.busy && closure?.responsible != null, + modifier = Modifier.fillMaxWidth(), + ) { Text("Finalizar y BLOQUEAR Acta") } + } + } + } + + "LOCKED" -> { + val signatures = closure?.signatures.orEmpty() + val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } + val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } + val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + 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("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.") + closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) } + + HorizontalDivider() + Text("Firma del inspector", fontWeight = FontWeight.Bold) + if (inspectorSigned) { + SuccessLine("Firma del inspector registrada") + } else { + Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall) + SignaturePad( + label = "Firmá como inspector/a", + enabled = !model.busy, + onCaptured = { file -> signWithGeo(file, company = false) }, + ) + } + + HorizontalDivider() + Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) + if (companyOutcome != null && companyResolved) { + val detail = when (companyOutcome.status) { + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" + "REFUSED" -> "Negativa a firmar" + else -> companyOutcome.status + } + SuccessLine(detail) + companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } else if (closure?.responsible?.attendanceStatus == "ABSENT") { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) { + 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.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } else { + Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) + FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") }) + } + if (manifestation == "DISSENT") { + OutlinedTextField( + dissentStatement, + { dissentStatement = it }, + label = { Text("Manifestación de disidencia *") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + } + SignaturePad( + label = "Firma del responsable de empresa", + enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), + onCaptured = { file -> signWithGeo(file, company = true) }, + ) + Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + refusalReason, + { refusalReason = it }, + label = { Text("Motivo de negativa") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedButton( + onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, + enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Registrar negativa a firmar") } + } + + if (inspectorSigned && companyResolved) { + Button( + onClick = { model.closeSelectedAct() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.CheckCircle, null) + Spacer(Modifier.width(6.dp)) + Text("SELLAR Acta definitivamente") + } + } else { + Text( + "La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + "SEALED" -> { + ElevatedCard(Modifier.fillMaxWidth()) { + 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.") + closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) } + } + } + } + } + + "CANCELLED" -> { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) { + Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp)) + } + } + } + } + + if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { + val activeActs = model.acts.filter { it.status != "CANCELLED" } + val pendingActs = activeActs.filter { it.status != "SEALED" } + ElevatedCard(Modifier.fillMaxWidth()) { + 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") + } else { + Text( + "Falta SELLAR: ${pendingActs.joinToString { it.code }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Button( + onClick = { model.closeInspection() }, + enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { Text("Cerrar Inspección y salir") } + } + } + } + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun SuccessLine(text: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } +} + +private fun modernActStatusLabel(status: String): String = when (status) { + "DRAFT" -> "Borrador" + "LOCKED" -> "Bloqueada" + "SEALED" -> "Sellada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun hasModernActLocation(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation -> + if (!hasModernActLocation(context)) { + continuation.resumeWithException(SecurityException("Ubicación no autorizada")) + return@suspendCancellableCoroutine + } + val source = CancellationTokenSource() + try { + LocationServices.getFusedLocationProviderClient(context) + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) + else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } + continuation.invokeOnCancellation { source.cancel() } +} From 7217c11e8b3b84843223a1528696f2d080c0aea4 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:54:10 -0300 Subject: [PATCH 06/14] feat(android): modernize field inventory and live Installation picker --- .../dhinspeccion/ui/ModernVisitRoot.kt | 1144 +++++++++++++++++ 1 file changed, 1144 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt 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 new file mode 100644 index 0000000..c624720 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt @@ -0,0 +1,1144 @@ +package com.korexlabs.dhinspeccion.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Environment +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Factory +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.exifinterface.media.ExifInterface +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.korexlabs.dhinspeccion.MainViewModel +import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition +import com.korexlabs.dhinspeccion.data.FieldInventoryItem +import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.VisitDetail +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private enum class ModernVisitScreen { OVERVIEW, ACTS, INVENTORY } +private enum class ModernInventoryMode { BROWSE, PICK_PARENT, CREATE_INSTALLATION, CREATE_SUBINSTALLATION } + +private data class ModernGeoSnapshot( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +@Composable +fun ModernVisitRoot(model: MainViewModel) { + val visit = model.visit ?: return + var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) } + val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW) + + when (screen) { + ModernVisitScreen.OVERVIEW -> ModernVisitOverview( + model = model, + onActs = { screenName = ModernVisitScreen.ACTS.name }, + onInventory = { screenName = ModernVisitScreen.INVENTORY.name }, + ) + ModernVisitScreen.ACTS -> ModernMobileActsScreen( + model = model, + onBack = { screenName = ModernVisitScreen.OVERVIEW.name }, + onGoInventory = { screenName = ModernVisitScreen.INVENTORY.name }, + ) + ModernVisitScreen.INVENTORY -> ModernFieldInventoryScreen( + model = model, + onBack = { screenName = ModernVisitScreen.ACTS.name }, + ) + } +} + +@Composable +private fun ModernVisitOverview( + model: MainViewModel, + onActs: () -> Unit, + onInventory: () -> Unit, +) { + val visit = model.visit ?: return + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ModernHeader( + title = "Inspección en campo", + subtitle = visit.code, + onBack = { model.closeVisitView() }, + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + tonalElevation = 1.dp, + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + visit.scopeAsset?.name ?: "Yacimiento", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + StatusPill(modernStatusLabel(visit.status)) + } + visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + visit.operationalArea?.name?.takeIf { it.isNotBlank() }?.let { + Text("Área · $it", style = MaterialTheme.typography.bodyMedium) + } + visit.plannedStartAt?.let { + Text("Planificada · ${modernShortDate(it)}", style = MaterialTheme.typography.bodySmall) + } + } + } + + ModernMessage(model) + + visit.instructions?.takeIf { it.isNotBlank() }?.let { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Instrucciones", fontWeight = FontWeight.Bold) + Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + if (visit.status == "PLANNED") { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { model.startVisit(); onActs() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") } + } + } + } + + if (visit.status == "IN_PROGRESS") { + Text("Acciones de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + ElevatedCard(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) { + Icon(Icons.Filled.Inventory2, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.primary) + } + Column(Modifier.weight(1f)) { + Text("Actas y Hallazgos", fontWeight = FontWeight.Bold) + val drafts = model.acts.count { it.status == "DRAFT" } + Text( + "${model.acts.size} Acta${if (model.acts.size == 1) "" else "s"}${if (drafts > 0) " · $drafts en borrador" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { + Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary) + } + Column(Modifier.weight(1f)) { + Text("Inventario de campo", fontWeight = FontWeight.Bold) + Text( + "Buscar existente o cargar Instalación / Subinstalación", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else if (visit.status == "CLOSED") { + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") } + } + + ModernChecklistCard(visit) + + if (visit.planningAssets.any { it.included }) { + Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + visit.planningAssets.filter { it.included }.take(8).forEach { asset -> + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) { + Column(Modifier.padding(14.dp)) { + Text(asset.name, fontWeight = FontWeight.SemiBold) + Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall) + } + } + } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun ModernChecklistCard(visit: VisitDetail) { + val checklist = visit.checklist + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Antecedentes", fontWeight = FontWeight.Bold) + Text( + "${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (checklist.companyOverdue > 0 || checklist.verificationOverdue > 0) { + Text( + "Vencidos: empresa ${checklist.companyOverdue} · verificaciones ${checklist.verificationOverdue}", + color = MaterialTheme.colorScheme.error, + ) + } + if (checklist.stale) { + Text("El checklist requiere actualización.", color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val visit = model.visit ?: return + + var modeName by rememberSaveable(visit.id) { mutableStateOf(ModernInventoryMode.BROWSE.name) } + val mode = runCatching { ModernInventoryMode.valueOf(modeName) }.getOrDefault(ModernInventoryMode.BROWSE) + var search by rememberSaveable(visit.id) { mutableStateOf("") } + var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") } + var parentId by rememberSaveable(visit.id) { mutableStateOf(null) } + var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") } + var name by rememberSaveable(visit.id) { mutableStateOf("") } + var commonName 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("") } + val attributeValues = remember { mutableStateMapOf() } + var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) } + var localError by rememberSaveable(visit.id) { mutableStateOf(null) } + var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") } + + var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") } + var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf(null) } + var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") } + + fun resetForm() { + name = "" + commonName = "" + selectedTypeId = null + selectedFamilyId = null + familySearch = "" + attributeValues.clear() + localError = null + } + + fun backToBrowse() { + modeName = ModernInventoryMode.BROWSE.name + parentId = null + parentLabel = "" + parentSearch = "" + resetForm() + model.loadFieldTypes(null) + } + + fun startInstallation() { + model.clearSelectedFieldAsset() + modeName = ModernInventoryMode.CREATE_INSTALLATION.name + parentId = null + parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" + resetForm() + model.loadFieldTypes(null) + } + + fun startSubinstallation() { + model.clearSelectedFieldAsset() + modeName = ModernInventoryMode.PICK_PARENT.name + parentId = null + parentLabel = "" + parentSearch = "" + resetForm() + model.searchInventory("", visit.scopeAsset?.id) + } + + fun chooseParent(item: FieldInventoryItem) { + keyboard?.hide() + focusManager.clearFocus() + parentId = item.id + parentLabel = "${item.name} · ${item.code}" + modeName = ModernInventoryMode.CREATE_SUBINSTALLATION.name + resetForm() + model.loadFieldTypes(item.id) + } + + LaunchedEffect(visit.id) { + model.searchInventory("", visit.scopeAsset?.id) + } + + LaunchedEffect(modeName, parentSearch) { + if (mode == ModernInventoryMode.PICK_PARENT) { + delay(280) + model.searchInventory(parentSearch.trim(), visit.scopeAsset?.id) + } + } + + LaunchedEffect(modeName, search) { + if (mode == ModernInventoryMode.BROWSE) { + delay(320) + model.searchInventory(search.trim()) + } + } + + LaunchedEffect(model.fieldTypes, modeName) { + if (mode == ModernInventoryMode.CREATE_INSTALLATION || mode == ModernInventoryMode.CREATE_SUBINSTALLATION) { + val preferred = model.fieldTypes.firstOrNull { type -> + val normalized = modernNormalize(type.structuralKind ?: type.code.ifBlank { type.name }) + when (mode) { + ModernInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion") + ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion") + else -> false + } + } ?: model.fieldTypes.firstOrNull() + if (model.fieldTypes.none { it.id == selectedTypeId }) { + selectedTypeId = preferred?.id + selectedFamilyId = null + familySearch = "" + attributeValues.clear() + } + } + } + + val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } + val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId } + val filteredFamilies = remember(selectedType, familySearch) { + val needle = modernNormalize(familySearch) + selectedType?.families.orEmpty().filter { family -> + needle.isBlank() || modernNormalize("${family.code} ${family.name}").contains(needle) + } + } + + var pendingPhotoFile by remember { mutableStateOf(null) } + var pendingPhotoGeo by remember { mutableStateOf(null) } + + val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + val file = pendingPhotoFile + val geo = pendingPhotoGeo + if (success && file != null && geo != null) { + runCatching { writeModernExif(file, geo) } + model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM) + } else if (!success) { + localError = "La foto quedó pendiente. Podés tomarla cuando estés listo." + } + pendingPhotoFile = null + pendingPhotoGeo = null + } + + val beginPhoto: () -> Unit = { + scope.launch { + runCatching { currentModernGeo(context) } + .onSuccess { geo -> + val (file, uri) = newModernPhoto(context) + pendingPhotoFile = file + pendingPhotoGeo = geo + takePicture.launch(uri) + } + .onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." } + } + } + + fun createWithLocation() { + val type = selectedType ?: return + val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null + scope.launch { + runCatching { currentModernGeo(context) } + .onSuccess { geo -> + pendingAutoPhoto = true + model.createFieldAsset( + type = type, + parentId = effectiveParent, + familyId = selectedFamilyId, + name = name, + commonName = commonName, + attributes = buildModernAttributes(type, attributeValues), + latitude = geo.latitude, + longitude = geo.longitude, + accuracyM = geo.accuracyM, + ) + } + .onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." } + } + } + + val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val cameraAllowed = result[Manifest.permission.CAMERA] == true || modernHasPermission(context, Manifest.permission.CAMERA) + val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || + result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || modernHasLocation(context) + if (permissionAction == "CREATE") { + if (cameraAllowed && locationAllowed) createWithLocation() + else localError = "Para el alta rápida se necesitan cámara y ubicación." + } else { + if (cameraAllowed && locationAllowed) beginPhoto() + else localError = "Para completar el alta se necesitan cámara y ubicación." + } + } + + fun requestCreate() { + permissionAction = "CREATE" + if (modernHasPermission(context, Manifest.permission.CAMERA) && modernHasLocation(context)) { + createWithLocation() + } else { + permissionsLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + } + + fun requestPhoto() { + permissionAction = "PHOTO" + if (modernHasPermission(context, Manifest.permission.CAMERA) && modernHasLocation(context)) { + beginPhoto() + } else { + permissionsLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + } + + val selectedCapture = model.selectedFieldAsset + LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) { + val capture = selectedCapture?.capture + if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) { + pendingAutoPhoto = false + requestPhoto() + } + } + + val mergeSource = selectedCapture?.takeIf { + it.capture.captureRequired && modernItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion") + } + val mergeCandidates = remember(model.inventory, mergeSource) { + if (mergeSource == null) emptyList() else model.inventory.filter { candidate -> + candidate.id != mergeSource.asset.id && + candidate.informationStatus != "INACTIVE" && + candidate.type?.id == mergeSource.asset.type?.id && + candidate.parent?.id == mergeSource.asset.parent?.id && + !candidate.captureRequired + } + } + + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(horizontal = 18.dp, vertical = 16.dp)) { + ModernHeader( + title = when (mode) { + ModernInventoryMode.PICK_PARENT -> "Elegir Instalación" + ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación" + ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación" + ModernInventoryMode.BROWSE -> "Inventario de campo" + }, + subtitle = visit.scopeAsset?.name ?: visit.code, + onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() }, + ) + if (model.busy) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + Spacer(Modifier.height(10.dp)) + ModernMessage(model) + localError?.let { ModernLocalError(it) { localError = null } } + } + + if (selectedCapture != null) { + ModernCaptureCard( + item = selectedCapture.asset, + gps = selectedCapture.capture.creationGpsCaptured, + photos = selectedCapture.capture.fieldPhotoCount, + ready = selectedCapture.capture.readyForFinding, + onPhoto = { requestPhoto() }, + onClose = { model.clearSelectedFieldAsset() }, + onCreateAnother = { + if (modernItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) { + chooseParent( + FieldInventoryItem( + id = selectedCapture.asset.parent.id, + code = selectedCapture.asset.parent.code, + name = selectedCapture.asset.parent.name, + type = null, + ), + ) + } else startSubinstallation() + }, + ) + } + + if (mergeSource != null && !mergeSource.capture.readyForFinding) { + ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("¿Este elemento ya existía?", fontWeight = FontWeight.Bold) + Text("Podés fusionar el alta con un registro compatible sin perder historial.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = mergeSearch, + onValueChange = { mergeSearch = it }, + label = { Text("Buscar posible duplicado") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedButton( + onClick = { + mergeCandidateId = null + model.searchInventory(mergeSearch, mergeSource.asset.parent?.id) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Buscar coincidencias") } + mergeCandidates.take(6).forEach { candidate -> + Surface( + onClick = { mergeCandidateId = candidate.id }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + border = BorderStroke( + if (mergeCandidateId == candidate.id) 2.dp else 1.dp, + if (mergeCandidateId == candidate.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Text("${candidate.code} · ${candidate.name}", Modifier.padding(12.dp)) + } + } + AnimatedVisibility(mergeCandidateId != null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = mergeReason, + onValueChange = { mergeReason = it }, + label = { Text("Motivo de la fusión") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + Button( + onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) }, + enabled = mergeReason.trim().length >= 8 && !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Fusionar con el existente") } + } + } + } + } + } + + when (mode) { + ModernInventoryMode.BROWSE -> ModernInventoryBrowse( + model = model, + search = search, + onSearchChange = { search = it }, + onStartSubinstallation = { startSubinstallation() }, + onStartInstallation = { startInstallation() }, + onChoose = { model.selectExisting(it) }, + onAddChild = { chooseParent(it) }, + ) + + ModernInventoryMode.PICK_PARENT -> { + val installations = model.inventory.filter { modernItemTypeCode(it) == "instalacion" } + Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) { + ModernStepHeader(1, "Elegí la Instalación padre") + Spacer(Modifier.height(6.dp)) + Text( + "Escribí nombre o código. Los resultados aparecen mientras buscás y podés tocarlos directamente.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = parentSearch, + onValueChange = { parentSearch = it }, + label = { Text("Buscar Instalación") }, + placeholder = { Text("Ej. agua, batería, planta…") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + trailingIcon = { + if (parentSearch.isNotBlank()) { + IconButton(onClick = { parentSearch = "" }) { + Icon(Icons.Filled.Close, contentDescription = "Limpiar búsqueda") + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + keyboard?.hide() + focusManager.clearFocus() + }), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Text( + if (installations.isEmpty() && !model.busy) "Sin coincidencias" else "${installations.size} Instalación${if (installations.size == 1) "" else "es"}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + if (installations.isEmpty() && !model.busy) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Search, null, tint = MaterialTheme.colorScheme.secondary) + Text("No encontramos una Instalación con ese texto.", fontWeight = FontWeight.SemiBold) + Text("Probá con parte del nombre o con el código.", style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = { startInstallation() }) { Text("Crear nueva Instalación") } + } + } + } else { + LazyColumn( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + items(installations, key = { it.id }) { item -> + ElevatedCard(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer) { + Icon(Icons.Filled.Factory, null, Modifier.padding(9.dp), tint = MaterialTheme.colorScheme.primary) + } + Column(Modifier.weight(1f)) { + Text(item.name, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + item.commonName?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + Icon(Icons.Filled.CheckCircle, contentDescription = "Seleccionar", tint = MaterialTheme.colorScheme.primary) + } + } + } + item { Spacer(Modifier.height(24.dp)) } + } + } + } + } + + ModernInventoryMode.CREATE_INSTALLATION, + ModernInventoryMode.CREATE_SUBINSTALLATION -> { + val isSubinstallation = mode == ModernInventoryMode.CREATE_SUBINSTALLATION + Column( + Modifier.fillMaxSize().padding(horizontal = 18.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + ModernStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación") + Surface( + Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.LocationOn, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(parentLabel, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + } + if (isSubinstallation) { + OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) { + Text("Cambiar Instalación padre") + } + } + + if (model.fieldTypes.isEmpty() && !model.busy) { + ModernLocalError("No hay un tipo habilitado para esta ubicación.") {} + } + + if (model.fieldTypes.size > 1) { + Text("Nivel de Inventario", fontWeight = FontWeight.Bold) + model.fieldTypes.forEach { type -> + Surface( + onClick = { + selectedTypeId = type.id + selectedFamilyId = null + attributeValues.clear() + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + border = BorderStroke( + if (selectedTypeId == type.id) 2.dp else 1.dp, + if (selectedTypeId == type.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + ) { Text(type.name, Modifier.padding(12.dp), fontWeight = if (selectedTypeId == type.id) FontWeight.Bold else FontWeight.Normal) } + } + } + + if (selectedType?.familyRequired == true) { + Text("Clasificación técnica", fontWeight = FontWeight.Bold) + OutlinedTextField( + value = familySearch, + onValueChange = { familySearch = it }, + label = { Text("Buscar clasificación") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + filteredFamilies.take(12).forEach { family -> + Surface( + onClick = { selectedFamilyId = family.id }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + (if (family.isOther) "Otro · " else "") + family.name, + Modifier.weight(1f), + fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal, + ) + if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + } + } + } + if (filteredFamilies.size > 12) { + Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall) + } + selectedFamily?.let { family -> + if (family.isOther) { + Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary) + } + if (family.informationLabels.isNotEmpty()) { + Text("Datos esperados · ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall) + } + } + } + + 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.") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = commonName, + onValueChange = { commonName = it }, + label = { Text("Nombre habitual") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + selectedType?.attributes?.forEach { definition -> + OutlinedTextField( + value = attributeValues[definition.code].orEmpty(), + onValueChange = { attributeValues[definition.code] = it }, + label = { Text(definition.name + if (definition.isRequired) " *" else "") }, + supportingText = { + val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ") + if (details.isNotBlank()) Text(details) + }, + keyboardOptions = KeyboardOptions( + keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + HorizontalDivider() + ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto") + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { + Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary) + Text( + "Al guardar capturamos el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + } + } + + val attributesReady = selectedType?.attributes + ?.filter { it.isRequired } + ?.all { attributeValues[it.code].orEmpty().isNotBlank() } + ?: false + val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null + Button( + onClick = { requestCreate() }, + enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.CameraAlt, null) + Spacer(Modifier.width(8.dp)) + Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") + } + Spacer(Modifier.height(32.dp)) + } + } + } + } +} + +@Composable +private fun ModernInventoryBrowse( + model: MainViewModel, + search: String, + onSearchChange: (String) -> Unit, + onStartSubinstallation: () -> Unit, + onStartInstallation: () -> Unit, + onChoose: (FieldInventoryItem) -> Unit, + onAddChild: (FieldInventoryItem) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") } + Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(5.dp)) + Text("Subinstalación") + } + FilledTonalButton(onClick = onStartInstallation, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(5.dp)) + Text("Instalación") + } + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = search, + onValueChange = onSearchChange, + label = { Text("Buscar Inventario") }, + placeholder = { Text("Nombre, código o dato técnico") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboard?.hide(); focusManager.clearFocus() }), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(10.dp)) + Text("${rows.size} resultados", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(9.dp)) { + items(rows, key = { it.id }) { item -> + ModernInventoryCard(item = item, onInspect = { onChoose(item) }, onAddChild = { onAddChild(item) }) + } + item { Spacer(Modifier.height(28.dp)) } + } + } +} + +@Composable +private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit, onAddChild: () -> Unit) { + val typeCode = modernItemTypeCode(item) + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text(item.name, fontWeight = FontWeight.Bold) + Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación") + } + item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + Text( + if (item.readyForFinding) "Listo para Hallazgos" else "Falta GPS / foto", + color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { + Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo") + } + if (typeCode == "instalacion") { + OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(6.dp)) + Text("Agregar Subinstalación") + } + } + } + } +} + +@Composable +private fun ModernCaptureCard( + item: FieldInventoryItem, + gps: Boolean, + photos: Int, + ready: Boolean, + onPhoto: () -> Unit, + onClose: () -> Unit, + onCreateAnother: () -> Unit, +) { + ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text(item.name, fontWeight = FontWeight.Bold) + Text(item.code, style = MaterialTheme.typography.bodySmall) + } + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(if (gps) "✓ GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error) + Text("Fotos · $photos") + } + if (!ready) { + Text("Completá la fotografía para habilitar Hallazgos.", color = MaterialTheme.colorScheme.error) + Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.CameraAlt, null) + Spacer(Modifier.width(6.dp)) + Text("Tomar foto ahora") + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(7.dp)) + Text("Alta completa", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + } + OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) { + Text("Crear otra Subinstalación") + } + } + } + } +} + +@Composable +internal fun ModernHeader(title: String, subtitle: String? = null, onBack: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Atrás") } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + subtitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +internal fun StatusPill(text: String) { + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceVariant) { + Text(text, Modifier.padding(horizontal = 10.dp, vertical = 5.dp), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +internal fun ModernMessage(model: MainViewModel) { + val error = model.error + val notice = model.notice + if (error != null || notice != null) { + Surface( + onClick = { model.clearMessages() }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = if (error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer, + ) { + Row(Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + if (error != null) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle, + contentDescription = null, + tint = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary, + ) + Text(error ?: notice.orEmpty(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + Icon(Icons.Filled.Close, contentDescription = "Cerrar mensaje") + } + } + } +} + +@Composable +private fun ModernLocalError(message: String, onDismiss: () -> Unit) { + Surface( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row(Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + Icon(Icons.Filled.WarningAmber, null, tint = MaterialTheme.colorScheme.error) + Text(message, Modifier.weight(1f), color = MaterialTheme.colorScheme.onErrorContainer) + Icon(Icons.Filled.Close, "Cerrar") + } + } +} + +@Composable +private fun ModernStepHeader(step: Int, title: String) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.primaryContainer) { + Text(step.toString(), Modifier.padding(horizontal = 11.dp, vertical = 7.dp), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.width(9.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } +} + +private fun modernItemTypeCode(item: FieldInventoryItem): String { + val type = item.type ?: return "" + return modernNormalize(type.typeName ?: type.name) +} + +private fun modernNormalize(value: String): String = value.trim().lowercase() + .replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u') + .replace("_", "").replace("-", "").replace(" ", "") + +private fun modernStatusLabel(status: String): String = when (status) { + "PLANNED" -> "Planificada" + "IN_PROGRESS" -> "En curso" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun buildModernAttributes(type: FieldType, values: Map): Map = + type.attributes.mapNotNull { definition -> + val raw = values[definition.code]?.trim().orEmpty() + if (raw.isBlank()) return@mapNotNull null + definition.code to coerceModernAttribute(definition, raw) + }.toMap() + +private fun coerceModernAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) { + "INTEGER", "INT" -> raw.toLongOrNull() ?: raw + "NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw + "BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes") + else -> raw +} + +private fun modernHasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +private fun modernHasLocation(context: Context): Boolean = + modernHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || modernHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + +private suspend fun currentModernGeo(context: Context): ModernGeoSnapshot = suspendCancellableCoroutine { continuation -> + if (!modernHasLocation(context)) { + continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) + return@suspendCancellableCoroutine + } + val source = CancellationTokenSource() + val client = LocationServices.getFusedLocationProviderClient(context) + try { + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) + else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } + continuation.invokeOnCancellation { source.cancel() } +} + +private fun newModernPhoto(context: Context): Pair { + val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + ?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.") + directory.mkdirs() + val file = File.createTempFile("DH_FAST_${System.currentTimeMillis()}_", ".jpg", directory) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file) + return file to uri +} + +private fun writeModernExif(file: File, geo: ModernGeoSnapshot) { + val now = Instant.now() + val exif = ExifInterface(file) + exif.setLatLong(geo.latitude, geo.longitude) + val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault()) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now)) + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now)) + exif.saveAttributes() +} + +private fun modernShortDate(value: String): String = value.replace('T', ' ').take(16) From c10248be2ca8a0029d9c4564041939d1a28a056a Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:54:29 -0300 Subject: [PATCH 07/14] feat(android): route field app through modern theme --- .../src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2f7c61a..b6fefa6 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 @@ -74,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) { if (model.session == null) unlocked = false } - MaterialTheme { + DhTheme { Surface(Modifier.fillMaxSize()) { when { model.session == null -> EnhancedLoginScreen(model) { @@ -91,7 +91,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) { }, ) model.fieldFindingOptions != null -> FieldFindingScreen(model) - model.visit != null -> DynamicVisitRoot(model) + model.visit != null -> ModernVisitRoot(model) else -> MobileHomeScreen(model) } } From c14582e92c66feef398ff87e484d3d9ec0b5a20f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:55:17 -0300 Subject: [PATCH 08/14] fix(android): use lightweight mobile Act list --- .../src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index c88ef3d..68d6f85 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -207,7 +207,7 @@ data class MobileCloseVisitRequest( ) private interface MobileActsApi { - @GET("inspection-visits/{visitId}/acts") + @GET("inspection-visits/{visitId}/acts/mobile") suspend fun listActs( @Header("Authorization") authorization: String, @Path("visitId") visitId: String, From d412399d13fbcb67010288fd8a753a9460e3154e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:55:51 -0300 Subject: [PATCH 09/14] chore(android): bump modern field release to 0.18.0 --- 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 3198fe5..8488eb8 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 = 26 - versionName = "0.17.0" + versionCode = 27 + versionName = "0.18.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true From 3089dd3724860e2f372f3138865e62740db49fb1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:56:00 -0300 Subject: [PATCH 10/14] test(android): lock 0.18.0 release metadata --- .../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 5e77606..213f389 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(26, BuildConfig.VERSION_CODE) - assertEquals("0.17.0-debug", BuildConfig.VERSION_NAME) + assertEquals(27, BuildConfig.VERSION_CODE) + assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME) } @Test From 2aa4fd21b1e0231e967b730786a293bb271e8dd2 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:56:13 -0300 Subject: [PATCH 11/14] test(api): follow Android 0.18.0 test cut --- api-v3/test/unit/f5-android-test-cut.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 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 a311f95..6188404 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 = 26/); - assert.match(gradle, /versionName = "0\.17\.0"/); + assert.match(gradle, /versionCode = 27/); + assert.match(gradle, /versionName = "0\.18\.0"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); @@ -25,4 +25,4 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An assert.match(service, /F5:SYSTEM:OTHER:%/); assert.match(service, /AS "isOther"/); assert.match(service, /isOtherFamily: family\.isOther/); -}); \ No newline at end of file +}); From 88906e827b6ce7e32975ce331dc2686da51d2527 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:56:26 -0300 Subject: [PATCH 12/14] test(api): lock modern Android picker and mobile Act read model --- .../unit/f6-4-android-modern-field.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 api-v3/test/unit/f6-4-android-modern-field.test.ts diff --git a/api-v3/test/unit/f6-4-android-modern-field.test.ts b/api-v3/test/unit/f6-4-android-modern-field.test.ts new file mode 100644 index 0000000..38e48cc --- /dev/null +++ b/api-v3/test/unit/f6-4-android-modern-field.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function apiFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +function repoFile(path: string): string { + return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); +} + +test('F6.4 Android Installation picker searches live and selects a concrete parent', () => { + const screen = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt'); + + assert.match(screen, /LaunchedEffect\(modeName, parentSearch\)/); + assert.match(screen, /delay\(280\)/); + assert.match(screen, /model\.searchInventory\(parentSearch\.trim\(\), visit\.scopeAsset\?\.id\)/); + assert.match(screen, /ElevatedCard\(onClick = \{ chooseParent\(item\) \}/); + assert.match(screen, /model\.loadFieldTypes\(item\.id\)/); +}); + +test('F6.4 Android field shell uses the modern theme and workspace', () => { + const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt'); + const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt'); + + assert.match(gate, /DhTheme \{/); + assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/); + assert.match(theme, /RoundedCornerShape\(16\.dp\)/); +}); + +test('F6.4 Android Act list uses a mobile read model independent from office reports', () => { + const android = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt'); + const controller = apiFile('src/inspection-acts/mobile-inspection-acts.controller.ts'); + const service = apiFile('src/inspection-acts/mobile-inspection-acts.service.ts'); + + assert.match(android, /inspection-visits\/\{visitId\}\/acts\/mobile/); + assert.match(controller, /inspection-visits\/:visitId\/acts\/mobile/); + assert.match(service, /FROM inspection_acts act/); + assert.doesNotMatch(service, /inspection_reports/); + assert.doesNotMatch(service, /inspection_document/); +}); From 7e87306c9d8671f50d3fff2fa1e7e09426bdc940 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:56:59 -0300 Subject: [PATCH 13/14] docs(android): document modern field release 0.18.0 --- android-app/RELEASE.md | 46 ++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 584d1df..3e5acfa 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,28 +1,40 @@ -# DH Inspección Android · release Campo Dinámico 0.17.0 +# DH Inspección Android · release Campo Moderno 0.18.0 ## Candidata vigente -- Fase funcional: **Campo Dinámico / alta rápida de Inventario**. -- `versionName`: **0.17.0**. -- `versionCode`: **26**. +- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**. +- `versionName`: **0.18.0**. +- `versionCode`: **27**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. -- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema. 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.17.0 +## Novedades de campo 0.18.0 -La pantalla de una Inspección incorpora un acceso destacado a **Modo campo rápido**. La carga de una Subinstalación se resuelve como flujo guiado: +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. -1. elegir la **Instalación padre** dentro del Yacimiento de la Inspección; -2. buscar y seleccionar la **clasificación técnica** compatible, identificar el elemento y completar únicamente los datos dinámicos que correspondan; -3. tocar **Guardar y tomar foto**: la APK captura GPS y abre la cámara automáticamente. +La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos: -Se mantienen las reglas de integridad existentes: la fotografía sigue siendo obligatoria antes de habilitar Hallazgos, el alta queda ligada al contexto de la Inspección, se conserva la posibilidad de buscar Inventario existente y el mecanismo de fusión de duplicados continúa disponible para altas de campo. +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 búsqueda de clasificación técnica está preparada para catálogos extensos de Subinstalaciones sin obligar al inspector a recorrer una fila horizontal completa. +Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía. + +## Actas en Android + +El espacio de Actas fue rediseñado para mostrar con claridad: + +- 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. + +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. ## Barrera obligatoria @@ -35,13 +47,7 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And 5. `assembleRelease` para comprobar que la variante productiva compile; 6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado. -La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente. - -El artefacto de CI contiene: - -- APK debug; -- archivo `.sha256`; -- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal. +Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover el cambio. ## Firma release @@ -56,5 +62,5 @@ 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, lista de inspecciones, inicio, alta rápida de Subinstalación, GPS/foto, búsqueda de Inventario existente, Acta, Hallazgo y cierre; +- 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; - registrar el SHA Git y SHA-256 de la APK distribuida. From 7c7eff3f9342b18fab2a371bf2c0d6039710b64c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:57:36 -0300 Subject: [PATCH 14/14] docs: record F6.4 Android modern UI acceptance scope --- _ci_notes/F6_4_ANDROID_MODERN_UI.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 _ci_notes/F6_4_ANDROID_MODERN_UI.md diff --git a/_ci_notes/F6_4_ANDROID_MODERN_UI.md b/_ci_notes/F6_4_ANDROID_MODERN_UI.md new file mode 100644 index 0000000..7d3bfd9 --- /dev/null +++ b/_ci_notes/F6_4_ANDROID_MODERN_UI.md @@ -0,0 +1,11 @@ +# F6.4 · Android Campo Moderno + +Criterios de aceptación: + +- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente; +- la selección de Instalación abre la clasificación compatible de Subinstalación; +- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina; +- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo; +- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia; +- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos; +- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.