diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt new file mode 100644 index 0000000..1b6f188 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt @@ -0,0 +1,972 @@ +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.input.KeyboardType +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.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 data class DynamicGeoSnapshot( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +private enum class DynamicInventoryMode { + BROWSE, + PICK_PARENT, + CREATE_INSTALLATION, + CREATE_SUBINSTALLATION, +} + +@Composable +fun DynamicVisitRoot(model: MainViewModel) { + var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) } + var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) } + + when { + actsMode -> MobileActsScreen( + model = model, + onBack = { actsMode = false }, + onGoInventory = { + actsMode = false + inventoryMode = true + }, + ) + inventoryMode -> DynamicFieldInventoryScreen( + model = model, + onBack = { + inventoryMode = false + actsMode = true + }, + ) + else -> DynamicVisitOverview( + model = model, + onInventory = { inventoryMode = true }, + onActs = { actsMode = true }, + ) + } +} + +@Composable +private fun DynamicVisitOverview( + model: MainViewModel, + onInventory: () -> Unit, + onActs: () -> Unit, +) { + val visit = model.visit ?: return + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") } + Text(dynamicStatusLabel(visit.status), fontWeight = FontWeight.Bold) + } + Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}") + visit.scopeAsset?.let { + Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + visit.plannedStartAt?.let { + Text("Planificada: ${dynamicShortDate(it)}", style = MaterialTheme.typography.bodySmall) + } + visit.instructions?.takeIf { it.isNotBlank() }?.let { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("Instrucciones", fontWeight = FontWeight.Bold) + Text(it) + } + } + } + DynamicMessageStrip(model) + + if (visit.status == "PLANNED") { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Todo listo para comenzar", fontWeight = FontWeight.Bold) + Text("Al iniciar se habilitan Actas, Hallazgos y altas rápidas de campo.") + 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") { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Modo campo rápido", fontWeight = FontWeight.Bold) + Text( + "Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.", + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { + Text("+ Nueva subinstalación / inventario") + } + } + } + + Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } + Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}") + } + OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { + Text("Buscar Inventario / Hallazgos") + } + } else if (visit.status == "CLOSED") { + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + Text("Ver Actas · ${model.acts.size}") + } + } + + DynamicChecklistCard(visit) + + Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (visit.planningAssets.none { it.included }) { + Text("Sin elementos planificados.") + } + visit.planningAssets.filter { it.included }.forEach { asset -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp)) { + Text(asset.name, fontWeight = FontWeight.SemiBold) + Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +private fun DynamicChecklistCard(visit: VisitDetail) { + val checklist = visit.checklist + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold) + Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}") + Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}") + if (checklist.stale) { + Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val visit = model.visit ?: return + + var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) } + val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.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 = DynamicInventoryMode.BROWSE.name + parentId = null + parentLabel = "" + parentSearch = "" + resetForm() + model.loadFieldTypes(null) + } + + fun startInstallation() { + model.clearSelectedFieldAsset() + modeName = DynamicInventoryMode.CREATE_INSTALLATION.name + parentId = null + parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" + resetForm() + model.loadFieldTypes(null) + } + + fun startSubinstallation() { + model.clearSelectedFieldAsset() + modeName = DynamicInventoryMode.PICK_PARENT.name + parentId = null + parentLabel = "" + parentSearch = "" + resetForm() + model.searchInventory("", visit.scopeAsset?.id) + } + + fun chooseParent(item: FieldInventoryItem) { + parentId = item.id + parentLabel = "${item.name} (${item.code})" + modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name + resetForm() + model.loadFieldTypes(item.id) + } + + LaunchedEffect(visit.id) { + model.searchInventory("", visit.scopeAsset?.id) + } + + LaunchedEffect(model.fieldTypes, modeName) { + if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) { + val preferred = model.fieldTypes.firstOrNull { type -> + val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name }) + when (mode) { + DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion") + DynamicInventoryMode.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 = dynamicNormalize(familySearch) + selectedType?.families.orEmpty().filter { family -> + needle.isBlank() || dynamicNormalize("${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 { writeDynamicExif(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 { currentDynamicGeo(context) } + .onSuccess { geo -> + val (file, uri) = newDynamicPhoto(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 = when (mode) { + DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId + else -> null + } + scope.launch { + runCatching { currentDynamicGeo(context) } + .onSuccess { geo -> + pendingAutoPhoto = true + model.createFieldAsset( + type = type, + parentId = effectiveParent, + familyId = selectedFamilyId, + name = name, + commonName = commonName, + attributes = buildDynamicAttributes(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 || dynamicHasPermission(context, Manifest.permission.CAMERA) + val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || + result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context) + when (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 (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) { + createWithLocation() + } else { + permissionsLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + } + + fun requestPhoto() { + permissionAction = "PHOTO" + if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(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 && dynamicItemTypeCode(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().padding(top = 28.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) { + Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás") + } + Column(horizontalAlignment = Alignment.End) { + Text( + when (mode) { + DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación" + DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación" + DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación" + else -> "Inventario de campo" + }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall) + } + } + + Column(Modifier.padding(horizontal = 16.dp)) { + DynamicMessageStrip(model) + localError?.let { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + onClick = { localError = null }, + ) { Text(it, Modifier.padding(12.dp)) } + } + } + + if (selectedCapture != null) { + DynamicCaptureCard( + item = selectedCapture.asset, + gps = selectedCapture.capture.creationGpsCaptured, + photos = selectedCapture.capture.fieldPhotoCount, + ready = selectedCapture.capture.readyForFinding, + onPhoto = { requestPhoto() }, + onClose = { model.clearSelectedFieldAsset() }, + onCreateAnother = { + if (dynamicItemTypeCode(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) { + Card( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("¿Ya existía?", fontWeight = FontWeight.Bold) + Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = mergeSearch, + onValueChange = { mergeSearch = it }, + label = { Text("Buscar posible duplicado") }, + 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 -> + AssistChip( + onClick = { mergeCandidateId = candidate.id }, + label = { + Text(if (mergeCandidateId == candidate.id) "✓ ${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}") + }, + ) + } + if (mergeCandidateId != null) { + 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) { + DynamicInventoryMode.BROWSE -> { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Alta rápida en campo", fontWeight = FontWeight.Bold) + Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall) + Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") { + Text("+ Nueva Subinstalación") + } + OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") { + Text("+ Nueva Instalación") + } + } + } + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Buscar instalación, subinstalación o código") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { model.searchInventory(search) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Buscar") } + } + + Text("Inventario disponible", Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold) + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + model.inventory.filter { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") }, + key = { it.id }, + ) { item -> + DynamicInventoryCard( + item = item, + onInspect = { model.selectExisting(item) }, + onAddChild = { chooseParent(item) }, + ) + } + item { Spacer(Modifier.height(30.dp)) } + } + } + + DynamicInventoryMode.PICK_PARENT -> { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + DynamicStepHeader(1, "Elegí la Instalación padre") + Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = parentSearch, + onValueChange = { parentSearch = it }, + label = { Text("Buscar Instalación por nombre o código") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Buscar Instalación") } + } + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" }, + key = { it.id }, + ) { item -> + Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(item.name, fontWeight = FontWeight.Bold) + Text(item.code, style = MaterialTheme.typography.bodySmall) + Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + } + } + item { Spacer(Modifier.height(24.dp)) } + } + } + + DynamicInventoryMode.CREATE_INSTALLATION, + DynamicInventoryMode.CREATE_SUBINSTALLATION -> { + val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación") + Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold) + if (isSubinstallation) { + OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) { + Text("Cambiar Instalación padre") + } + } + + if (model.fieldTypes.isEmpty()) { + Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error) + } + + if (model.fieldTypes.size > 1) { + Text("Nivel de Inventario", fontWeight = FontWeight.Bold) + model.fieldTypes.forEach { type -> + AssistChip( + onClick = { + selectedTypeId = type.id + selectedFamilyId = null + attributeValues.clear() + }, + label = { Text(if (selectedTypeId == type.id) "✓ ${type.name}" else type.name) }, + ) + } + } + + if (selectedType?.familyRequired == true) { + Text("Clasificación técnica *", fontWeight = FontWeight.Bold) + OutlinedTextField( + value = familySearch, + onValueChange = { familySearch = it }, + label = { Text("Buscar clasificación") }, + supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + filteredFamilies.take(12).forEach { family -> + AssistChip( + onClick = { selectedFamilyId = family.id }, + label = { + val prefix = when { + selectedFamilyId == family.id -> "✓ " + family.isOther -> "+ " + else -> "" + } + Text(prefix + family.name) + }, + ) + } + if (filteredFamilies.size > 12) { + Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall) + } + selectedFamily?.let { family -> + if (family.isOther) { + Text("Quedará marcado como no catalogado 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 ve el inspector en la placa o 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() + DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso") + Text( + "Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.", + style = MaterialTheme.typography.bodySmall, + ) + + 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(), + ) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") } + Spacer(Modifier.height(30.dp)) + } + } + } + } +} + +@Composable +private fun DynamicStepHeader(step: Int, title: String) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold) + } + Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun DynamicCaptureCard( + item: FieldInventoryItem, + gps: Boolean, + photos: Int, + ready: Boolean, + onPhoto: () -> Unit, + onClose: () -> Unit, + onCreateAnother: () -> Unit, +) { + Card( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + colors = CardDefaults.cardColors( + containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(Modifier.weight(1f)) { + Text(item.name, fontWeight = FontWeight.Bold) + Text(item.code, style = MaterialTheme.typography.bodySmall) + } + OutlinedButton(onClick = onClose) { Text("Cerrar") } + } + Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") + if (!ready) { + Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error) + Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") } + } else { + Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) { + Text("+ Crear otra Subinstalación") + } + } + } + } +} + +@Composable +private fun DynamicInventoryCard( + item: FieldInventoryItem, + onInspect: () -> Unit, + onAddChild: () -> Unit, +) { + val typeCode = dynamicItemTypeCode(item) + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(item.name, fontWeight = FontWeight.SemiBold) + Text(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall) + } + Text(item.code, style = MaterialTheme.typography.bodySmall) + item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + Text( + if (item.readyForFinding) "Disponible" else "GPS/foto pendiente", + color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { + Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Usar para Hallazgo") + } + if (typeCode == "instalacion") { + OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) { + Text("+ Agregar Subinstalación") + } + } + } + } +} + +@Composable +private fun DynamicMessageStrip(model: MainViewModel) { + val error = model.error + val notice = model.notice + if (error != null || notice != null) { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + colors = CardDefaults.cardColors( + containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer, + ), + onClick = { model.clearMessages() }, + ) { + Text(error ?: notice.orEmpty(), Modifier.padding(12.dp)) + } + } +} + +private fun dynamicItemTypeCode(item: FieldInventoryItem): String { + val type = item.type ?: return "" + return dynamicNormalize(type.typeName ?: type.name) +} + +private fun dynamicNormalize(value: String): String = value.trim().lowercase() + .replace('ó', 'o') + .replace('í', 'i') + .replace('á', 'a') + .replace('é', 'e') + .replace('ú', 'u') + .replace("_", "") + .replace("-", "") + .replace(" ", "") + +private fun dynamicStatusLabel(status: String): String = when (status) { + "PLANNED" -> "Planificada" + "IN_PROGRESS" -> "En curso" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun buildDynamicAttributes(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 coerceDynamicAttribute(definition, raw) + }.toMap() + +private fun coerceDynamicAttribute(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 dynamicHasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +private fun dynamicHasLocation(context: Context): Boolean = + dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || + dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + +private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation -> + if (!dynamicHasLocation(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(DynamicGeoSnapshot(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 newDynamicPhoto(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 writeDynamicExif(file: File, geo: DynamicGeoSnapshot) { + 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 dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)