Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdaccb66d0 | ||
|
|
f5f6afe797 | ||
|
|
c524d2ade6 | ||
|
|
a2ed3cf091 | ||
|
|
c4c6dd6c24 |
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- '.github/workflows/android.yml'
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F2.3-0.10.2-debug
|
||||
name: DH-Inspeccion-F2.4-0.10.3-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 16
|
||||
versionName = "0.10.2"
|
||||
versionCode = 17
|
||||
versionName = "0.10.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
|
||||
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
|
||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
||||
import com.korexlabs.dhinspeccion.data.FieldAssetDetail
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingEvidence
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
||||
@@ -51,6 +52,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private set
|
||||
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
|
||||
private set
|
||||
var fieldFindingEvidence: Map<String, List<FieldFindingEvidence>> by mutableStateOf(emptyMap())
|
||||
private set
|
||||
|
||||
init {
|
||||
if (session != null) loadVisits()
|
||||
@@ -82,8 +85,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
clearFindingState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +100,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
clearFindingState()
|
||||
}
|
||||
|
||||
fun closeVisitView() {
|
||||
@@ -107,8 +108,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
clearFindingState()
|
||||
loadVisits()
|
||||
}
|
||||
|
||||
@@ -252,26 +252,76 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
),
|
||||
)
|
||||
lastCreatedFinding = response.finding
|
||||
notice = "Hallazgo ${response.finding.code} registrado."
|
||||
fieldFindingOptions = findingsRepository.options(visitId, assetId)
|
||||
notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica."
|
||||
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFindingPhoto(
|
||||
findingId: String,
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
) {
|
||||
launchBusy {
|
||||
findingsRepository.uploadObservationPhoto(
|
||||
findingId = findingId,
|
||||
file = file,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
title = title,
|
||||
description = description,
|
||||
)
|
||||
loadEvidenceInternal(findingId)
|
||||
notice = "Evidencia fotográfica registrada con GPS."
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadFindingEvidence(findingId: String) {
|
||||
launchBusy { loadEvidenceInternal(findingId) }
|
||||
}
|
||||
|
||||
fun clearFindingFlow() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
fieldFindingEvidence = emptyMap()
|
||||
error = null
|
||||
}
|
||||
|
||||
fun clearSelectedFieldAsset() {
|
||||
selectedFieldAsset = null
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
clearFindingState()
|
||||
}
|
||||
|
||||
private suspend fun loadFindingOptionsInternal(visitId: String, assetId: String) {
|
||||
fieldFindingOptions = findingsRepository.options(visitId, assetId)
|
||||
private suspend fun loadFindingOptionsInternal(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
keepLastCreated: Boolean = false,
|
||||
) {
|
||||
val options = findingsRepository.options(visitId, assetId)
|
||||
fieldFindingOptions = options
|
||||
if (!keepLastCreated) lastCreatedFinding = null
|
||||
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
|
||||
for (finding in options.findings) {
|
||||
loaded[finding.id] = findingsRepository.evidence(finding.id).data
|
||||
}
|
||||
fieldFindingEvidence = loaded
|
||||
}
|
||||
|
||||
private suspend fun loadEvidenceInternal(findingId: String) {
|
||||
fieldFindingEvidence = fieldFindingEvidence + (
|
||||
findingId to findingsRepository.evidence(findingId).data
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearFindingState() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
fieldFindingEvidence = emptyMap()
|
||||
}
|
||||
|
||||
private fun launchBusy(block: suspend () -> Unit) {
|
||||
|
||||
+94
-2
@@ -6,15 +6,24 @@ import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
data class FieldFindingAct(
|
||||
val id: String,
|
||||
@@ -64,6 +73,30 @@ data class FieldFindingItem(
|
||||
val correctionDueOn: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingEvidence(
|
||||
val id: String,
|
||||
val findingId: String,
|
||||
val kind: String,
|
||||
val purpose: String,
|
||||
val originalName: String,
|
||||
val mimeType: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val capturedAt: String? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val accuracyM: Double? = null,
|
||||
val deviceLabel: String? = null,
|
||||
val source: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class FieldFindingEvidenceListResponse(
|
||||
val data: List<FieldFindingEvidence> = emptyList(),
|
||||
)
|
||||
|
||||
data class FieldFindingOptionsResponse(
|
||||
val act: FieldFindingAct,
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
@@ -104,13 +137,36 @@ private interface FieldFindingsApi {
|
||||
@Body request: CreateFieldFindingRequest,
|
||||
): FieldFindingCreateResponse
|
||||
|
||||
@GET("inspection-findings/{findingId}/evidence")
|
||||
suspend fun evidence(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("findingId") findingId: String,
|
||||
): FieldFindingEvidenceListResponse
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-findings/{findingId}/evidence")
|
||||
suspend fun uploadEvidence(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("findingId") findingId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("kind") kind: RequestBody,
|
||||
@Part("purpose") purpose: RequestBody,
|
||||
@Part("title") title: RequestBody?,
|
||||
@Part("description") description: RequestBody?,
|
||||
@Part("capturedAt") capturedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody,
|
||||
@Part("longitude") longitude: RequestBody,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
): FieldFindingEvidence
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente separado para F2.3. Comparte el almacén cifrado de sesión de la APK,
|
||||
* pero mantiene el contrato de Hallazgos desacoplado del cliente F2.2.
|
||||
* Cliente de campo para Hallazgos y sus evidencias append-only.
|
||||
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
|
||||
*/
|
||||
class FieldFindingsRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
@@ -136,6 +192,42 @@ class FieldFindingsRepository(context: Context) {
|
||||
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
|
||||
}
|
||||
|
||||
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
|
||||
api.evidence("Bearer ${session.accessToken}", findingId)
|
||||
}
|
||||
|
||||
suspend fun uploadObservationPhoto(
|
||||
findingId: String,
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
capturedAt: String = Instant.now().toString(),
|
||||
): FieldFindingEvidence = authorized { session ->
|
||||
val text = "text/plain".toMediaType()
|
||||
val part = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
file.name,
|
||||
file.asRequestBody("image/jpeg".toMediaType()),
|
||||
)
|
||||
api.uploadEvidence(
|
||||
authorization = "Bearer ${session.accessToken}",
|
||||
findingId = findingId,
|
||||
file = part,
|
||||
kind = "PHOTO".toRequestBody(text),
|
||||
purpose = "OBSERVATION".toRequestBody(text),
|
||||
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
capturedAt = capturedAt.toRequestBody(text),
|
||||
latitude = latitude.toString().toRequestBody(text),
|
||||
longitude = longitude.toString().toRequestBody(text),
|
||||
accuracyM = accuracyM?.toString()?.toRequestBody(text),
|
||||
deviceLabel = "DH Android".toRequestBody(text),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||
try {
|
||||
|
||||
@@ -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<String?>(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<String?>(null) }
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
|
||||
var pendingPhotoFindingId by remember { mutableStateOf<String?>(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<File, Uri> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user