Compare commits

..
5 changed files with 334 additions and 24 deletions
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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) {
@@ -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)