From 04822d9cad535abcd71ae7068ce560bc7107f769 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 23:31:37 -0300 Subject: [PATCH] feat(android): centrar inspeccion en recorrido acta y hallazgos --- .../korexlabs/dhinspeccion/ui/F8VisitRoot.kt | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F8VisitRoot.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F8VisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F8VisitRoot.kt new file mode 100644 index 0000000..188e9de --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F8VisitRoot.kt @@ -0,0 +1,793 @@ +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.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.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.Assignment +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Factory +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.FilterChip +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.text.font.FontWeight +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.PlannedAsset +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.LocalDate +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private enum class F8VisitScreen { OVERVIEW, ACTS, FINDING_TARGET, PICK_ASSET, CREATE_ASSET } +private enum class F8Target { YACIMIENTO, INSTALACION, SUBINSTALACION } + +private data class F8Geo(val latitude: Double, val longitude: Double, val accuracyM: Double?) + +@Composable +fun F8VisitRoot(model: MainViewModel) { + val visit = model.visit ?: return + var screenName by rememberSaveable(visit.id) { mutableStateOf(F8VisitScreen.OVERVIEW.name) } + var targetName by rememberSaveable(visit.id) { mutableStateOf(F8Target.INSTALACION.name) } + var parentId by rememberSaveable(visit.id) { mutableStateOf(null) } + var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") } + + val screen = runCatching { F8VisitScreen.valueOf(screenName) }.getOrDefault(F8VisitScreen.OVERVIEW) + val target = runCatching { F8Target.valueOf(targetName) }.getOrDefault(F8Target.INSTALACION) + + fun goOverview() { + screenName = F8VisitScreen.OVERVIEW.name + parentId = null + parentLabel = "" + model.clearSelectedFieldAsset() + } + + when (screen) { + F8VisitScreen.OVERVIEW -> F8VisitOverview( + model = model, + onActs = { screenName = F8VisitScreen.ACTS.name }, + onCreateFinding = { screenName = F8VisitScreen.FINDING_TARGET.name }, + ) + F8VisitScreen.ACTS -> ModernMobileActsScreen( + model = model, + onBack = { screenName = F8VisitScreen.OVERVIEW.name }, + onGoInventory = { screenName = F8VisitScreen.FINDING_TARGET.name }, + ) + F8VisitScreen.FINDING_TARGET -> F8FindingTargetScreen( + model = model, + onBack = { screenName = F8VisitScreen.OVERVIEW.name }, + onYacimiento = { + val scope = visit.scopeAsset + if (scope != null) { + model.selectExisting( + FieldInventoryItem( + id = scope.id, + code = scope.code, + name = scope.name, + type = scope, + readyForFinding = true, + ), + ) + } + }, + onInstallation = { + targetName = F8Target.INSTALACION.name + parentId = null + parentLabel = visit.scopeAsset?.name.orEmpty() + screenName = F8VisitScreen.PICK_ASSET.name + }, + onSubinstallation = { + targetName = F8Target.SUBINSTALACION.name + parentId = null + parentLabel = "" + screenName = F8VisitScreen.PICK_ASSET.name + }, + ) + F8VisitScreen.PICK_ASSET -> F8AssetPicker( + model = model, + target = target, + parentId = parentId, + parentLabel = parentLabel, + onBack = { + if (target == F8Target.SUBINSTALACION && parentId != null) { + parentId = null + parentLabel = "" + } else { + screenName = F8VisitScreen.FINDING_TARGET.name + } + }, + onParent = { item -> + parentId = item.id + parentLabel = item.name + }, + onCreate = { + screenName = F8VisitScreen.CREATE_ASSET.name + }, + ) + F8VisitScreen.CREATE_ASSET -> F8QuickCreateAsset( + model = model, + target = target, + parentId = parentId, + parentLabel = parentLabel.ifBlank { visit.scopeAsset?.name.orEmpty() }, + onBack = { screenName = F8VisitScreen.PICK_ASSET.name }, + onFinishedWithoutFinding = { goOverview() }, + ) + } +} + +@Composable +private fun F8VisitOverview( + model: MainViewModel, + onActs: () -> Unit, + onCreateFinding: () -> Unit, +) { + val visit = model.visit ?: return + val draft = model.acts.firstOrNull { it.status == "DRAFT" } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + F8Header("Inspección en campo", visit.code) { model.closeVisitView() } + + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(visit.scopeAsset?.name ?: "Yacimiento", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + visit.operationalArea?.name?.takeIf { it.isNotBlank() }?.let { + Text("Área · $it", style = MaterialTheme.typography.bodyMedium) + } + Text(f8VisitStatus(visit.status), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + } + } + + F8Messages(model) + if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + + F8RouteCard(visit) + + if (visit.status == "PLANNED") { + Button( + onClick = { model.startVisit() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (model.busy) "Iniciando…" else "Iniciar inspección") + } + } + + if (visit.status == "IN_PROGRESS") { + if (draft == null) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Empezar a documentar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Creá un Acta cuando empieces a registrar lo observado. El número definitivo se asignará al cerrarla.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { model.createAct("NON_URGENT") }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(7.dp)) + Text("Nueva Acta") + } + OutlinedButton( + onClick = { model.createAct("URGENT") }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Nueva Acta urgente") } + } + } + } else { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + Icon(Icons.Filled.Assignment, null, tint = MaterialTheme.colorScheme.primary) + Column { + Text("Acta en elaboración", fontWeight = FontWeight.Bold) + Text("${draft.findingCount} Hallazgo${if (draft.findingCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall) + } + } + Button(onClick = onCreateFinding, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(7.dp)) + Text("Crear hallazgo") + } + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Acta actual") } + } + } + } + } else if (visit.status == "CLOSED") { + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") } + } + + if (model.acts.isNotEmpty()) { + Text("Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + model.acts.take(4).forEach { act -> + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) { + Row(Modifier.padding(13.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(if (act.status == "DRAFT") "Acta en elaboración" else act.code, fontWeight = FontWeight.SemiBold) + Text("${act.findingCount} hallazgos · ${f8ActStatus(act.status)}", style = MaterialTheme.typography.bodySmall) + } + Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.secondary) + } + } + } + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver todas las Actas") } + } + + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun F8RouteCard(visit: VisitDetail) { + val planned = visit.planningAssets.filter { it.included } + val checklist = visit.checklist.items.filter { it.assetIncluded && it.asset != null } + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Recorrido sugerido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Usalo como guía. Podés registrar cualquier Hallazgo nuevo aunque no esté planificado.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (planned.isEmpty() && checklist.isEmpty()) { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) { + Text("No hay controles pendientes cargados para este recorrido.", Modifier.padding(12.dp)) + } + } else { + planned.take(10).forEach { asset -> F8RouteLine(asset, "Planificado", false) } + checklist.take(12).forEach { item -> + val due = item.referenceOn?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() } + val overdue = due?.isBefore(LocalDate.now()) == true || item.findingStatus?.uppercase() == "OVERDUE" + val label = if (overdue) "Vencido" else "Por vencer" + val asset = item.asset + if (asset != null) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Icon( + if (overdue) Icons.Filled.WarningAmber else Icons.Filled.LocationOn, + contentDescription = null, + tint = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f)) { + Text(asset.name, fontWeight = FontWeight.SemiBold) + Text( + listOfNotNull(label, item.findingCode, item.findingTitle).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun F8RouteLine(asset: PlannedAsset, label: String, error: Boolean) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Icon(Icons.Filled.LocationOn, null, tint = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(asset.name, fontWeight = FontWeight.SemiBold) + Text( + listOfNotNull(label, asset.typeName?.takeIf { it.isNotBlank() }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun F8FindingTargetScreen( + model: MainViewModel, + onBack: () -> Unit, + onYacimiento: () -> Unit, + onInstallation: () -> Unit, + onSubinstallation: () -> Unit, +) { + val visit = model.visit ?: return + val hasDraft = model.acts.any { it.status == "DRAFT" } + + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + F8Header("Crear hallazgo", visit.scopeAsset?.name ?: visit.code, onBack) + F8Messages(model) + if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + + if (!hasDraft) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Primero necesitás un Acta", fontWeight = FontWeight.Bold) + Text("Volvé y tocá Nueva Acta. Así cada Hallazgo queda asociado correctamente.") + Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") } + } + } + return@Column + } + + Text("¿Dónde está el hallazgo?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Elegí el lugar. Si todavía no existe en el Inventario, lo vas a poder crear en el siguiente paso.", color = MaterialTheme.colorScheme.onSurfaceVariant) + + F8TargetCard("En el Yacimiento", visit.scopeAsset?.name ?: "Yacimiento", Icons.Filled.LocationOn, onYacimiento) + F8TargetCard("En una Instalación", "Elegir o crear Instalación", Icons.Filled.Factory, onInstallation) + F8TargetCard("En una Subinstalación", "Elegir Instalación y luego el elemento", Icons.Filled.Assignment, onSubinstallation) + } +} + +@Composable +private fun F8TargetCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) { + ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(18.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) { + Icon(icon, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary) + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun F8AssetPicker( + model: MainViewModel, + target: F8Target, + parentId: String?, + parentLabel: String, + onBack: () -> Unit, + onParent: (FieldInventoryItem) -> Unit, + onCreate: () -> Unit, +) { + val visit = model.visit ?: return + var search by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") } + val pickingParent = target == F8Target.SUBINSTALACION && parentId == null + val title = when { + pickingParent -> "Elegir Instalación" + target == F8Target.SUBINSTALACION -> "Elegir Subinstalación" + else -> "Elegir Instalación" + } + + LaunchedEffect(visit.id, target.name, parentId, search) { + delay(220) + val effectiveParent = if (pickingParent || target == F8Target.INSTALACION) visit.scopeAsset?.id else parentId + model.searchInventory(search.trim(), effectiveParent) + } + + val rows = model.inventory.filter { item -> + val code = f8Norm(item.type?.code ?: item.type?.name) + if (pickingParent || target == F8Target.INSTALACION) { + code.contains("instalacion") && !code.contains("subinstalacion") + } else { + code.contains("subinstalacion") + } + } + + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + F8Header(title, if (parentLabel.isBlank()) visit.scopeAsset?.name ?: visit.code else parentLabel, onBack) + F8Messages(model) + if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Buscar por nombre o código") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (!pickingParent) { + OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Add, null) + Spacer(Modifier.width(6.dp)) + Text(if (target == F8Target.SUBINSTALACION) "Crear Subinstalación" else "Crear Instalación") + } + } else if (rows.isEmpty() && !model.busy) { + OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) { + Text("No existe: crear Instalación") + } + } + + if (rows.isEmpty() && !model.busy) { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) { + Text("No encontramos coincidencias. Podés crear el elemento sin salir del Hallazgo.", Modifier.padding(14.dp)) + } + } + + LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(rows, key = { it.id }) { item -> + ElevatedCard( + onClick = { if (pickingParent) onParent(item) else model.selectExisting(item) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Filled.Factory, null, 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) } + } + } + } + } + } + } +} + +@Composable +private fun F8QuickCreateAsset( + model: MainViewModel, + target: F8Target, + parentId: String?, + parentLabel: String, + onBack: () -> Unit, + onFinishedWithoutFinding: () -> Unit, +) { + val visit = model.visit ?: return + val context = LocalContext.current + val scope = rememberCoroutineScope() + var name by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") } + var selectedTypeId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(null) } + var selectedFamilyId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(null) } + val attributes = remember { mutableStateMapOf() } + var localError by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(null) } + var pendingAutoPhoto by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(false) } + var photoFile by remember { mutableStateOf(null) } + var photoGeo by remember { mutableStateOf(null) } + + LaunchedEffect(visit.id, target.name, parentId) { + model.loadFieldTypes(if (target == F8Target.SUBINSTALACION) parentId else null) + } + LaunchedEffect(model.fieldTypes) { + if (model.fieldTypes.none { it.id == selectedTypeId }) { + selectedTypeId = model.fieldTypes.firstOrNull()?.id + selectedFamilyId = null + attributes.clear() + } + } + + val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } + val selectedAsset = model.selectedFieldAsset + + val takePhoto = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + val file = photoFile + val geo = photoGeo + if (success && file != null && geo != null) { + runCatching { f8WriteExif(file, geo) } + model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM) + } else if (!success) { + localError = "La foto es necesaria para continuar con el Hallazgo." + } + photoFile = null + photoGeo = null + } + + fun beginPhoto() { + scope.launch { + runCatching { f8CurrentGeo(context) } + .onSuccess { geo -> + val (file, uri) = f8NewPhoto(context) + photoFile = file + photoGeo = geo + takePhoto.launch(uri) + } + .onFailure { localError = it.message ?: "No se pudo obtener la ubicación." } + } + } + + fun createNow() { + val type = selectedType ?: return + scope.launch { + runCatching { f8CurrentGeo(context) } + .onSuccess { geo -> + pendingAutoPhoto = true + model.createFieldAsset( + type = type, + parentId = if (target == F8Target.SUBINSTALACION) parentId else null, + familyId = selectedFamilyId, + name = name, + commonName = null, + description = null, + attributes = f8Attributes(type, attributes), + latitude = geo.latitude, + longitude = geo.longitude, + accuracyM = geo.accuracyM, + ) + } + .onFailure { localError = it.message ?: "No se pudo capturar el GPS." } + } + } + + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val camera = result[Manifest.permission.CAMERA] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f8HasLocation(context) + if (camera && location) { + if (selectedAsset?.capture?.captureRequired == true) beginPhoto() else createNow() + } else { + localError = "Para el alta en campo se necesitan cámara y ubicación." + } + } + + fun requestCreate() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) createNow() + else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + + fun requestPhoto() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) beginPhoto() + else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + + LaunchedEffect(selectedAsset?.asset?.id, pendingAutoPhoto) { + if (pendingAutoPhoto && selectedAsset?.capture?.captureRequired == true && selectedAsset.capture.creationGpsCaptured && selectedAsset.capture.fieldPhotoCount == 0) { + pendingAutoPhoto = false + requestPhoto() + } + } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) { + F8Header( + if (target == F8Target.SUBINSTALACION) "Nueva Subinstalación" else "Nueva Instalación", + parentLabel, + onBack, + ) + F8Messages(model) + localError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + + if (selectedAsset != null && selectedAsset.capture.captureRequired) { + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(selectedAsset.asset.name, fontWeight = FontWeight.Bold) + Text("GPS ${if (selectedAsset.capture.creationGpsCaptured) "✓" else "pendiente"} · Foto ${if (selectedAsset.capture.fieldPhotoCount > 0) "✓" else "pendiente"}") + if (!selectedAsset.capture.readyForFinding) { + Button(onClick = { requestPhoto() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) { + Icon(Icons.Filled.CameraAlt, null) + Spacer(Modifier.width(7.dp)) + Text("Tomar foto y continuar") + } + } else { + Button(onClick = { model.openFindingForSelected() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) { + Text("Continuar con el Hallazgo") + } + } + OutlinedButton(onClick = onFinishedWithoutFinding, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") } + } + } + } else { + Text("Datos básicos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + OutlinedTextField(name, { name = it }, label = { Text("Nombre *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + + if (model.fieldTypes.size > 1) { + Text("Tipo", fontWeight = FontWeight.SemiBold) + model.fieldTypes.forEach { type -> + FilterChip( + selected = selectedTypeId == type.id, + onClick = { selectedTypeId = type.id; selectedFamilyId = null; attributes.clear() }, + label = { Text(type.name) }, + ) + } + } else { + selectedType?.let { Text("Tipo · ${it.name}", color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + + if (selectedType?.familyRequired == true) { + Text("Función / clasificación", fontWeight = FontWeight.SemiBold) + selectedType.families.forEach { family -> + FilterChip( + selected = selectedFamilyId == family.id, + onClick = { selectedFamilyId = family.id }, + label = { Text(family.name) }, + ) + } + } + + selectedType?.attributes?.forEach { definition -> + OutlinedTextField( + value = attributes[definition.code].orEmpty(), + onValueChange = { attributes[definition.code] = it }, + label = { Text(definition.name + if (definition.isRequired) " *" else "") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(9.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary) + Text("Al guardar se toma GPS y foto. Después volvés directamente al Hallazgo.", style = MaterialTheme.typography.bodySmall) + } + } + + val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributes[it.code].orEmpty().isNotBlank() } ?: false + val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null + Button( + onClick = { requestCreate() }, + enabled = name.isNotBlank() && selectedType != null && requiredReady && familyReady && !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.CameraAlt, null) + Spacer(Modifier.width(7.dp)) + Text("Guardar y tomar foto") + } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun F8Header(title: String, subtitle: String, onBack: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Volver") } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun F8Messages(model: MainViewModel) { + model.error?.let { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) { + Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer) + } + } + model.notice?.let { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) { + Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onPrimaryContainer) + } + } +} + +private fun f8VisitStatus(status: String): String = when (status) { + "PLANNED" -> "Planificada" + "IN_PROGRESS" -> "En curso" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun f8ActStatus(status: String): String = when (status) { + "DRAFT" -> "En elaboración" + "LOCKED" -> "Pendiente de firma" + "SEALED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun f8Norm(value: String?): String = (value ?: "") + .normalize(java.text.Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + .trim() + .lowercase() + +private fun f8Attributes(type: FieldType, values: Map): Map = + type.attributes.associate { definition -> definition.code to f8AttributeValue(definition, values[definition.code].orEmpty()) } + +private fun f8AttributeValue(definition: FieldAttributeDefinition, raw: String): Any? { + val value = raw.trim() + if (value.isBlank()) return null + return when (definition.dataType.uppercase()) { + "NUMBER", "DECIMAL", "FLOAT" -> value.replace(',', '.').toDoubleOrNull() ?: value + "INTEGER", "INT" -> value.toLongOrNull() ?: value + "BOOLEAN", "BOOL" -> value.equals("true", true) || value.equals("si", true) || value.equals("sí", true) || value == "1" + else -> value + } +} + +private fun f8HasLocation(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 f8CurrentGeo(context: Context): F8Geo = suspendCancellableCoroutine { continuation -> + if (!f8HasLocation(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(F8Geo(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 f8NewPhoto(context: Context): Pair { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir + val file = File(dir, "dh_field_${System.currentTimeMillis()}.jpg") + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + return file to uri +} + +private fun f8WriteExif(file: File, geo: F8Geo) { + val exif = ExifInterface(file) + exif.setGpsInfo(android.location.Location("DH").apply { + latitude = geo.latitude + longitude = geo.longitude + accuracy = geo.accuracyM?.toFloat() ?: 0f + time = System.currentTimeMillis() + }) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, Instant.now().toString()) + exif.saveAttributes() +}