diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt index 7744fb2..acc6c63 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt @@ -1,5 +1,12 @@ 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.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -8,7 +15,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -16,24 +25,47 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.Button import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.foundation.text.KeyboardOptions 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 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 FindingGeoSnapshot( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) @Composable fun FieldFindingScreen(model: MainViewModel) { val options = model.fieldFindingOptions ?: return val asset = model.selectedFieldAsset?.asset ?: return + val context = LocalContext.current + val scope = rememberCoroutineScope() var search by rememberSaveable(asset.id) { mutableStateOf("") } var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf(null) } var other by rememberSaveable(asset.id) { mutableStateOf(false) } @@ -43,6 +75,71 @@ fun FieldFindingScreen(model: MainViewModel) { var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") } + var requestedFindingId by remember { mutableStateOf(null) } + var pendingPhotoFile by remember { mutableStateOf(null) } + var pendingPhotoGeo by remember { mutableStateOf(null) } + var pendingPhotoFindingId by remember { mutableStateOf(null) } + + val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + val file = pendingPhotoFile + val geo = pendingPhotoGeo + val findingId = pendingPhotoFindingId + if (success && file != null && geo != null && findingId != null) { + runCatching { writeFindingExif(file, geo) } + model.uploadFindingPhoto( + findingId = findingId, + file = file, + latitude = geo.latitude, + longitude = geo.longitude, + accuracyM = geo.accuracyM, + title = "Evidencia fotográfica de campo", + ) + } + pendingPhotoFile = null + pendingPhotoGeo = null + pendingPhotoFindingId = null + } + + fun beginPhoto(findingId: String) { + scope.launch { + runCatching { currentFindingGeo(context) } + .onSuccess { geo -> + val (file, uri) = newFindingPhoto(context) + pendingPhotoFile = file + pendingPhotoGeo = geo + pendingPhotoFindingId = findingId + takePicture.launch(uri) + } + } + } + + val photoPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { result -> + val camera = result[Manifest.permission.CAMERA] == true || findingHasPermission(context, Manifest.permission.CAMERA) + val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || + result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || findingHasLocation(context) + val findingId = requestedFindingId + requestedFindingId = null + if (camera && location && findingId != null) beginPhoto(findingId) + } + + fun requestPhoto(findingId: String) { + requestedFindingId = findingId + if (findingHasPermission(context, Manifest.permission.CAMERA) && findingHasLocation(context)) { + requestedFindingId = null + beginPhoto(findingId) + } else { + photoPermissionLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + } + val selected = options.catalog.items.firstOrNull { it.id == selectedCatalogId } val filtered = options.catalog.items.filter { search.isBlank() || @@ -88,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) { Text(asset.name, fontWeight = FontWeight.Bold) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text( - "GPS + foto: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", + "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) @@ -107,12 +204,30 @@ fun FieldFindingScreen(model: MainViewModel) { } if (options.findings.isNotEmpty()) { - Text("Hallazgos ya registrados en este Inventario", fontWeight = FontWeight.Bold) + Text("Hallazgos registrados en este Inventario", fontWeight = FontWeight.Bold) options.findings.forEach { finding -> + val evidence = model.fieldFindingEvidence[finding.id].orEmpty() Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(10.dp)) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall) + Text( + "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", + style = MaterialTheme.typography.bodySmall, + ) + evidence.take(3).forEach { item -> + Text( + "• ${item.title ?: item.originalName}${item.capturedAt?.let { " · ${shortFindingDate(it)}" }.orEmpty()}", + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + onClick = { requestPhoto(finding.id) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Tomar foto con GPS") + } } } } @@ -243,12 +358,64 @@ fun FieldFindingScreen(model: MainViewModel) { Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Hallazgo registrado", fontWeight = FontWeight.Bold) Text("${finding.code} · ${finding.title}") - Text("Podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall) + Button( + onClick = { requestPhoto(finding.id) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Tomar foto con GPS") + } + Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall) } } } } } + +private fun findingHasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +private fun findingHasLocation(context: Context): Boolean = + findingHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || + findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + +private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation -> + if (!findingHasLocation(context)) { + continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) + return@suspendCancellableCoroutine + } + val source = CancellationTokenSource() + val client = LocationServices.getFusedLocationProviderClient(context) + 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(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + continuation.invokeOnCancellation { source.cancel() } +} + +private fun newFindingPhoto(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_HALLAZGO_${System.currentTimeMillis()}_", ".jpg", directory) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file) + return file to uri +} + +private fun writeFindingExif(file: File, geo: FindingGeoSnapshot) { + 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 shortFindingDate(value: String): String = value.replace('T', ' ').take(16)