From 95012cf373c931c22781b61ea0a89c207a290844 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 08:52:56 -0300 Subject: [PATCH] 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() } +}