diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt new file mode 100644 index 0000000..4148db2 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt @@ -0,0 +1,579 @@ +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.LazyRow +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.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +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.text.input.PasswordVisualTransformation +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.CancellationTokenSource +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +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 com.korexlabs.dhinspeccion.data.VisitSummary +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 GeoSnapshot( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +@Composable +fun DhApp(model: MainViewModel) { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + when { + model.session == null -> LoginScreen(model) + model.visit == null -> VisitsScreen(model) + else -> VisitRouter(model) + } + } + } +} + +@Composable +private fun MessageStrip(model: MainViewModel) { + val error = model.error + val notice = model.notice + if (error != null || notice != null) { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + colors = CardDefaults.cardColors( + containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.secondaryContainer, + ), + onClick = { model.clearMessages() }, + ) { + Text( + text = error ?: notice.orEmpty(), + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun LoginScreen(model: MainViewModel) { + var identifier by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text("Aplicación de campo · Dirección de Hidrocarburos") + MessageStrip(model) + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text("Usuario o email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Contraseña") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + Button( + onClick = { model.login(identifier, password) }, + modifier = Modifier.fillMaxWidth(), + enabled = !model.busy, + ) { + if (model.busy) CircularProgressIndicator(modifier = Modifier.width(20.dp).height(20.dp)) + else Text("Ingresar") + } + Text( + "El acceso móvil está reservado a usuarios con rol Inspector.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun VisitsScreen(model: MainViewModel) { + val session = model.session ?: return + Column(Modifier.fillMaxSize().padding(top = 28.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(session.displayName, style = MaterialTheme.typography.bodySmall) + } + Row { + OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar") } + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = { model.logout() }) { Text("Salir") } + } + } + Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) } + if (model.busy && model.visits.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } else if (model.visits.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No tenés inspecciones asignadas.") + } + } else { + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(model.visits, key = { it.id }) { visit -> + VisitCard(visit) { model.openVisit(visit.id) } + } + item { Spacer(Modifier.height(24.dp)) } + } + } + } +} + +@Composable +private fun VisitCard(visit: VisitSummary, onOpen: () -> Unit) { + Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(visit.code, fontWeight = FontWeight.Bold) + Text(visit.status) + } + Text(visit.operatorCompany?.name ?: "Operadora sin definir") + Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) + visit.plannedStartAt?.let { Text("Prevista: ${shortDate(it)}", style = MaterialTheme.typography.bodySmall) } + Text("Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun VisitRouter(model: MainViewModel) { + var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) } + if (inventoryMode) { + FieldInventoryScreen(model) { inventoryMode = false } + } else { + VisitScreen(model) { inventoryMode = true } + } +} + +@Composable +private fun VisitScreen(model: MainViewModel, onInventory: () -> 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(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.instructions?.takeIf { it.isNotBlank() }?.let { + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text("Instrucciones", fontWeight = FontWeight.Bold); Text(it) } } + } + MessageStrip(model) + + if (visit.status == "PLANNED") { + Button(onClick = { model.startVisit() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) { + Text("Iniciar inspección") + } + } + if (visit.status == "PLANNED" || visit.status == "IN_PROGRESS") { + OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Text("Inventario de campo") } + } + + ChecklistCard(visit) + + Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (visit.planningAssets.isEmpty()) 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 ChecklistCard(visit: VisitDetail) { + val checklist = visit.checklist + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Checklist de antecedentes", 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) + checklist.items.take(10).forEach { item -> + HorizontalDivider() + Text(item.findingTitle ?: item.findingCode ?: "Hallazgo", fontWeight = FontWeight.SemiBold) + Text("${item.asset?.name.orEmpty()} · Gravedad ${item.severity ?: "s/d"}", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val visit = model.visit ?: return + var search by rememberSaveable { mutableStateOf("") } + var showCreate by rememberSaveable { mutableStateOf(false) } + var parentId by rememberSaveable { mutableStateOf(null) } + var parentLabel by rememberSaveable { mutableStateOf("Área de la inspección") } + var name by rememberSaveable { mutableStateOf("") } + var commonName by rememberSaveable { mutableStateOf("") } + var selectedTypeId by rememberSaveable { mutableStateOf(null) } + val attributeValues = remember { mutableStateMapOf() } + + LaunchedEffect(visit.id) { + model.searchInventory("") + model.loadFieldTypes(null) + } + LaunchedEffect(model.fieldTypes) { + if (model.fieldTypes.none { it.id == selectedTypeId }) { + selectedTypeId = model.fieldTypes.firstOrNull()?.id + attributeValues.clear() + } + } + + val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } + + val createWithLocation: () -> Unit = { + val type = selectedType + if (type != null) { + scope.launch { + runCatching { currentGeo(context) } + .onSuccess { geo -> + val values = buildAttributes(type, attributeValues) + model.createFieldAsset(type, parentId, name, commonName, values, geo.latitude, geo.longitude, geo.accuracyM) + } + } + } + } + val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (allowed) createWithLocation() + } + + 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 { writeExif(file, geo) } + model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM) + } + pendingPhotoFile = null + pendingPhotoGeo = null + } + val beginPhoto: () -> Unit = { + scope.launch { + runCatching { currentGeo(context) }.onSuccess { geo -> + val (file, uri) = newPhoto(context) + pendingPhotoFile = file + pendingPhotoGeo = geo + takePicture.launch(uri) + } + } + } + val photoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val camera = result[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) + val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || hasLocation(context) + if (camera && location) beginPhoto() + } + + Column(Modifier.fillMaxSize().padding(top = 28.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onBack) { Text("Volver") } + Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) } + + val selectedCapture = model.selectedFieldAsset + if (selectedCapture != null) { + CaptureCard( + detailName = selectedCapture.asset.name, + captureRequired = selectedCapture.capture.captureRequired, + gps = selectedCapture.capture.creationGpsCaptured, + photos = selectedCapture.capture.fieldPhotoCount, + ready = selectedCapture.capture.readyForFinding, + onPhoto = { + val permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + if (hasPermission(context, Manifest.permission.CAMERA) && hasLocation(context)) beginPhoto() + else photoPermissionLauncher.launch(permissions) + }, + onClose = { model.clearSelectedFieldAsset() }, + ) + } + + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Buscar por nombre, código o atributo") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") } + } + if (visit.status == "IN_PROGRESS") { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Alta en campo", fontWeight = FontWeight.Bold) + OutlinedButton(onClick = { + showCreate = !showCreate + if (showCreate) model.loadFieldTypes(parentId) + }) { Text(if (showCreate) "Ocultar" else "Crear nuevo") } + } + } + + if (showCreate && visit.status == "IN_PROGRESS") { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()).weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall) + if (parentId != null) { + OutlinedButton(onClick = { + parentId = null + parentLabel = "Área de la inspección" + model.loadFieldTypes(null) + }) { Text("Volver al Área") } + } + if (model.fieldTypes.isEmpty()) Text("No hay tipos habilitados debajo de este padre.") + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(model.fieldTypes, key = { it.id }) { type -> + AssistChip( + onClick = { selectedTypeId = type.id; attributeValues.clear() }, + label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) }, + ) + } + } + OutlinedTextField(name, { name = it }, label = { Text("Nombre o código identificable *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(commonName, { commonName = it }, label = { Text("Nombre común") }, modifier = Modifier.fillMaxWidth()) + 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(), + ) + } + val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributeValues[it.code].orEmpty().isNotBlank() } ?: false + Button( + onClick = { + if (hasLocation(context)) createWithLocation() + else locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + }, + enabled = selectedType != null && name.isNotBlank() && requiredReady && !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Capturar GPS y crear") } + HorizontalDivider() + } + } + + Text("Resultados", modifier = 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, key = { it.id }) { item -> + InventoryCard( + item = item, + canModify = visit.status == "IN_PROGRESS", + onSelect = { model.selectExisting(item) }, + onUseParent = { + parentId = item.id + parentLabel = "${item.name} (${item.code})" + showCreate = true + selectedTypeId = null + model.loadFieldTypes(item.id) + }, + ) + } + item { Spacer(Modifier.height(30.dp)) } + } + } +} + +@Composable +private fun CaptureCard( + detailName: String, + captureRequired: Boolean, + gps: Boolean, + photos: Int, + ready: Boolean, + onPhoto: () -> Unit, + onClose: () -> Unit, +) { + Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(detailName, fontWeight = FontWeight.Bold) + OutlinedButton(onClick = onClose) { Text("Cerrar") } + } + Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") + if (captureRequired && !ready) { + Text("El Hallazgo permanece bloqueado hasta completar GPS + foto.", color = MaterialTheme.colorScheme.error) + Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") } + } else if (ready) { + Text("Captura completa · habilitado para Hallazgos", color = MaterialTheme.colorScheme.primary) + } + } + } +} + +@Composable +private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect: () -> Unit, onUseParent: () -> Unit) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(item.name, fontWeight = FontWeight.SemiBold) + Text("${item.code} · ${item.type?.name.orEmpty()}", style = MaterialTheme.typography.bodySmall) + item.commonName?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + Text( + if (item.readyForFinding) "Listo" else "Captura incompleta: GPS/foto pendiente", + color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + if (canModify) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onSelect) { Text(if (item.selectedInInspection) "Abrir" else "Seleccionar") } + OutlinedButton(onClick = onUseParent) { Text("Crear hijo") } + } + } + } + } +} + +private fun buildAttributes(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 coerceAttribute(definition, raw) + }.toMap() + +private fun coerceAttribute(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 hasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +private fun hasLocation(context: Context): Boolean = + hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + +private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation -> + if (!hasLocation(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(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + continuation.invokeOnCancellation { source.cancel() } +} + +private fun newPhoto(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_${System.currentTimeMillis()}_", ".jpg", directory) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file) + return file to uri +} + +private fun writeExif(file: File, geo: GeoSnapshot) { + 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 shortDate(value: String): String = value.replace('T', ' ').take(16)