feat(f6.8): harden offline field flow and act documents
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s

This commit is contained in:
DH V2
2026-09-15 15:56:50 -03:00
parent 47cd985931
commit 079728aa6d
62 changed files with 2240 additions and 338 deletions
@@ -6,7 +6,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.AssetSummary
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CaptureStatus
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
@@ -18,10 +20,14 @@ import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileVisitClosureHeader
import com.korexlabs.dhinspeccion.data.MobileResponsible
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.offline.OfflineMutationQueue
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
@@ -36,6 +42,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
private val offlineQueue = OfflineMutationQueue(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
@@ -45,6 +52,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set
var notice: String? by mutableStateOf(null)
private set
var pendingSyncCount by mutableStateOf(0)
private set
var inspectorSignatureConfigured: Boolean? by mutableStateOf(null)
private set
var selectedActCompanySignaturePending by mutableStateOf(false)
private set
var selectedActSealPending by mutableStateOf(false)
private set
var visits: List<VisitSummary> by mutableStateOf(emptyList())
private set
@@ -75,6 +90,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set
init {
OfflineMutationQueue.schedule(application)
viewModelScope.launch { refreshOfflineStateInternal() }
if (session != null) loadVisits()
}
@@ -143,24 +160,80 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() {
val id = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
offlineQueue.enqueueVisitStart(id)
val localStartedAt = Instant.now().toString()
visit = visit?.copy(status = "IN_PROGRESS", actualStartedAt = localStartedAt)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
} else if (result.retry) {
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada en el dispositivo. Podés trabajar sin conexión; se sincronizará automáticamente."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo iniciar la Inspección."
}
}
}
fun reloadActs() {
val visitId = visit?.id ?: return
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
launchBusy {
loadActsInternal(visitId, selectDraft = selectedAct == null)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
}
}
fun syncPendingNow() {
launchBusy {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
selectedAct?.id?.let { actId ->
runCatching { refreshSelectedActInternal(actId) }
}
if (result.pending == 0) {
notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización."
} else if (result.retry) {
notice = "Sin conexión. Los cambios siguen guardados en este dispositivo."
} else {
error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
fun refreshInspectorSignatureStatus() {
viewModelScope.launch {
refreshInspectorSignatureStatusInternal()
if (inspectorSignatureConfigured == true && pendingSyncCount > 0) {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val actId = selectedAct?.id
if (actId != null && !selectedActSealPending) {
runCatching { refreshSelectedActInternal(actId) }
}
when {
result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados."
result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión."
else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
}
fun selectAct(actId: String) {
launchBusy {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
clearFindingState()
}
}
@@ -176,11 +249,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
val created = actsRepository.create(currentVisit.id, currentVisit.code)
selectedAct = created
actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false)
notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
val actId = DhRepository.newOperationId()
val occurredAt = Instant.now().toString()
offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt)
val localCode = "ACTA-PEND-${actId.take(6).uppercase()}"
val local = MobileActDetail(
id = actId,
visitId = currentVisit.id,
code = localCode,
status = "DRAFT",
occurredAt = occurredAt,
title = "Acta de inspección ${currentVisit.code}",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
)
selectedAct = local
acts = acts + MobileActSummary(
id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt,
title = local.title, summary = local.summary,
)
actClosure = MobileActClosure(
act = MobileActClosureHeader(
id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0,
),
visit = MobileVisitClosureHeader(
id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt,
),
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadActsInternal(currentVisit.id, selectDraft = false)
refreshSelectedActInternal(actId)
notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
} else {
notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet."
}
}
}
@@ -209,16 +312,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
private suspend fun ensureAssetInActOffline(
actId: String,
assetId: String,
code: String,
name: String,
typeName: String?,
): OfflineMutationQueue.SyncResult {
if (selectedAct?.assets?.none { it.id == assetId } != false) {
offlineQueue.enqueueActAsset(actId, assetId)
selectedAct = selectedAct?.copy(
assets = selectedAct!!.assets + AssetSummary(assetId, code, name, typeName),
assetCount = selectedAct!!.assetCount + 1,
)
acts = acts.map { if (it.id == actId) it.copy(assetCount = selectedAct?.assetCount ?: it.assetCount) else it }
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) selectedAct = actsRepository.get(actId)
return result
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
offlineQueue.enqueueFieldAssetSelect(visitId, item.id)
selectedFieldAsset = FieldAssetDetail(
context = null,
asset = item.copy(selectedInInspection = true),
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = item.captureRequired,
hasGeometry = item.hasGeometry,
creationGpsCaptured = item.hasGeometry,
fieldPhotoCount = item.fieldPhotoCount,
readyForFinding = item.readyForFinding,
),
)
val sync = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (sync.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario agregado a la Inspección."
} else {
notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet."
}
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
loadActsInternal(visitId, selectDraft = false)
ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name)
loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
@@ -248,7 +390,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
val assetId = DhRepository.newOperationId()
val capturedAt = Instant.now().toString()
val request = CreateFieldInventoryRequest(
clientGeneratedId = assetId,
typeId = type.id,
parentId = parentId,
familyId = familyId,
@@ -259,11 +404,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(),
deviceCapturedAt = capturedAt,
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request)
val localAsset = FieldInventoryItem(
id = assetId,
code = "PEND-${assetId.take(6).uppercase()}",
name = request.name,
commonName = request.commonName,
informationStatus = "DRAFT",
dataOrigin = "FIELD_SURVEY",
type = AssetSummary(type.id, type.code, type.name, type.name),
selectedInInspection = true,
captureRequired = true,
hasGeometry = true,
fieldPhotoCount = 0,
readyForFinding = false,
)
selectedFieldAsset = FieldAssetDetail(
asset = localAsset,
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = true,
hasGeometry = true,
creationGpsCaptured = true,
fieldPhotoCount = 0,
readyForFinding = false,
),
)
inventory = inventory + localAsset
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, assetId)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
} else {
notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet."
}
}
}
@@ -290,8 +468,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
loadActsInternal(visitId, selectDraft = false)
ensureAssetInActOffline(
draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName,
)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
}
}
@@ -306,27 +485,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto(
visitId = visitId,
offlineQueue.enqueueFieldAssetPhoto(
assetId = asset.id,
file = file,
visitId = visitId,
source = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Inventario listo: ubicación y foto registradas."
val localCapture = selectedFieldAsset?.capture?.copy(
hasGeometry = true,
creationGpsCaptured = true,
fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1,
readyForFinding = true,
) ?: CaptureStatus(
captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true,
)
selectedFieldAsset = selectedFieldAsset?.copy(
asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true),
capture = localCapture,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario listo: ubicación y foto registradas."
} else {
"Fotografía registrada."
notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet."
}
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, asset.id, draft.id)
} else if (response.capture.readyForFinding) {
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
val current = selectedFieldAsset?.asset ?: asset
ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name)
loadFindingOptionsInternal(visitId, current.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
@@ -341,8 +534,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
val asset = selectedFieldAsset?.asset ?: return@launchBusy
ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name)
loadFindingOptionsInternal(visitId, assetId, draft.id)
}
}
@@ -374,23 +567,60 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
actId = actId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
),
val clientFindingId = DhRepository.newOperationId()
val request = CreateFieldFindingRequest(
actId = actId,
clientGeneratedId = clientFindingId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
)
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
loadActsInternal(visitId, selectDraft = false)
if (selectedAct?.assets?.none { it.id == assetId } != false) {
offlineQueue.enqueueActAsset(actId, assetId)
selectedFieldAsset?.asset?.let { asset ->
selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary(
id = asset.id,
code = asset.code,
name = asset.name,
typeName = asset.type?.name,
))
}
}
offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request)
val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId }
val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1
val localFinding = FieldFindingItem(
id = clientFindingId,
actId = actId,
assetId = assetId,
catalogItemId = catalogItemId,
findingNumber = localNumber,
code = "PEND-H${localNumber.toString().padStart(3, '0')}",
status = "OPEN",
title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" },
description = description.trim(),
severity = severity ?: catalog?.suggestedSeverity,
suggestedSeverity = catalog?.suggestedSeverity,
)
lastCreatedFinding = localFinding
fieldFindingOptions = fieldFindingOptions?.copy(
findings = fieldFindingOptions!!.findings + localFinding,
canAddAnother = true,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding
loadActsInternal(visitId, selectDraft = false)
notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica."
} else {
notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet."
}
}
}
@@ -404,17 +634,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description: String? = null,
) {
launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto(
offlineQueue.enqueueFindingPhoto(
findingId = findingId,
file = file,
source = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
title = title,
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
} else {
notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión."
}
}
}
@@ -438,19 +674,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = normalizedEmail.lowercase(),
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
val request = MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = normalizedEmail.lowercase(),
phone = phone?.trim()?.takeIf { it.isNotBlank() },
)
notice = "Representante de la empresa registrado para esta Acta."
offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = request.attendanceStatus,
fullName = request.fullName,
documentType = request.documentType,
documentNumber = request.documentNumber,
position = request.position,
email = request.email,
phone = request.phone,
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Representante de la empresa registrado para esta Acta."
} else {
notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
}
}
@@ -461,14 +712,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
val request = MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
)
notice = "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) actClosure = actsRepository.closure(actId)
notice = if (result.pending == 0) {
"Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
} else {
"Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
}
}
@@ -479,10 +740,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId)
offlineQueue.enqueueActLock(actId, urgency)
selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency)
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency) else it }
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
if (result.pending == 0) {
refreshSelectedActInternal(actId)
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
} else {
notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet."
}
}
}
@@ -513,14 +783,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
offlineQueue.enqueueCompanySignature(
actId = actId,
source = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
manifestation = manifestation,
statement = statement,
)
notice = if (manifestation == "DISSENT") {
"Firma del representante registrada en disconformidad."
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (!selectedActCompanySignaturePending) {
actClosure = actsRepository.closure(actId)
notice = if (manifestation == "DISSENT") {
"Firma del representante registrada en disconformidad."
} else {
"Firma del representante de la empresa registrada."
}
} else {
"Firma del representante de la empresa registrada."
notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet."
}
result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it }
}
}
@@ -535,8 +819,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim())
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Negativa a firmar asentada."
} else {
notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet."
}
}
}
@@ -544,20 +835,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
offlineQueue.enqueueSeal(actId)
if (inspectorSignatureConfigured == false) {
refreshOfflineStateInternal()
notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente."
return@launchBusy
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (!selectedActSealPending) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} else if (result.retry) {
val localClosedAt = Instant.now().toString()
selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt)
acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it }
clearFindingState()
notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet."
} else {
error = result.lastError ?: "El cierre quedó pendiente de sincronización."
}
}
}
fun closeInspection() {
val visitId = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
val closedAt = Instant.now().toString()
offlineQueue.enqueueVisitClose(visitId, closedAt)
visit = visit?.copy(status = "CLOSED", actualClosedAt = closedAt)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} else if (result.retry) {
notice = "Cierre de Inspección guardado en el dispositivo. Quedará sincronizado automáticamente cuando vuelva internet."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo cerrar la Inspección."
}
}
}
@@ -595,6 +916,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedAct = null
actClosure = null
}
refreshOfflineStateInternal()
}
private suspend fun refreshSelectedActInternal(actId: String) {
@@ -625,6 +947,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
}
private suspend fun refreshInspectorSignatureStatusInternal() {
runCatching { actsRepository.selfSignatureStatus() }
.onSuccess { inspectorSignatureConfigured = it.configured }
}
private suspend fun refreshOfflineStateInternal() {
pendingSyncCount = offlineQueue.pendingCount()
val actId = selectedAct?.id
if (actId == null) {
selectedActCompanySignaturePending = false
selectedActSealPending = false
} else {
selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId)
|| offlineQueue.hasPending("COMPANY_OUTCOME", actId)
selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId)
}
}
private fun clearActState() {
acts = emptyList()
selectedAct = null
@@ -5,6 +5,8 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
@@ -25,6 +27,7 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.security.KeyStore
import java.time.Instant
import java.util.UUID
@@ -254,6 +257,7 @@ data class FieldAssetDetail(
)
data class CreateFieldInventoryRequest(
val clientGeneratedId: String? = null,
val typeId: String,
val parentId: String? = null,
val familyId: String? = null,
@@ -366,6 +370,7 @@ interface DhApi {
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
@Part("operationId") operationId: okhttp3.RequestBody?,
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
@@ -448,8 +453,14 @@ class SecureSessionStore(context: Context) {
}
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val visitListAdapter = moshi.adapter(VisitListResponse::class.java)
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -468,25 +479,47 @@ class DhRepository(context: Context) {
store.clear()
}
suspend fun visits(): VisitListResponse = authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
suspend fun visits(): VisitListResponse {
val session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
return cached("visits:${session.userId}", visitListAdapter) {
authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) }
}
}
suspend fun visit(id: String): VisitDetail = authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) {
authorized { session -> api.visit("Bearer ${session.accessToken}", id) }
}
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
suspend fun startVisit(id: String): VisitDetail {
val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) }
cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value))
return value
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse {
val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() }
val baseKey = "inventory:$visitId:${parentId ?: "root"}"
return try {
val value = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId)
}
if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value))
value
} catch (error: IOException) {
val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error
if (normalizedSearch == null) cached else cached.copy(
data = cached.data.filter { item ->
item.code.contains(normalizedSearch, ignoreCase = true)
|| item.name.contains(normalizedSearch, ignoreCase = true)
},
)
}
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse =
cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) {
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
@@ -518,6 +551,7 @@ class DhRepository(context: Context) {
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
@@ -532,12 +566,27 @@ class DhRepository(context: Context) {
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
operationId = operationId?.toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
@@ -22,6 +24,7 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.time.Instant
data class FieldFindingAct(
@@ -108,6 +111,7 @@ data class FieldFindingOptionsResponse(
data class CreateFieldFindingRequest(
val actId: String,
val clientGeneratedId: String? = null,
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
@@ -161,6 +165,7 @@ private interface FieldFindingsApi {
@Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
@Part("operationId") operationId: RequestBody?,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
@@ -172,8 +177,12 @@ private interface FieldFindingsApi {
* F3.2 exige que la APK identifique explícitamente el Acta activa.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val optionsAdapter = moshi.adapter(FieldFindingOptionsResponse::class.java)
private val evidenceAdapter = moshi.adapter(FieldFindingEvidenceListResponse::class.java)
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -181,10 +190,28 @@ class FieldFindingsRepository(context: Context) {
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse {
val exactKey = "finding-options:$visitId:$assetId:$actId"
val templateKey = "finding-options-template:$visitId:$assetId"
return try {
val value = authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
val json = optionsAdapter.toJson(value)
cache.put(userCacheKey(exactKey), json)
cache.put(userCacheKey(templateKey), json)
value
} catch (error: IOException) {
cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson)
?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy(
act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"),
findings = emptyList(),
assetIncludedInAct = true,
canAddAnother = true,
)
?: throw error
}
}
suspend fun create(
visitId: String,
@@ -194,9 +221,10 @@ 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 evidence(findingId: String): FieldFindingEvidenceListResponse =
cached("finding-evidence:$findingId", evidenceAdapter) {
authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) }
}
suspend fun uploadObservationPhoto(
findingId: String,
@@ -207,6 +235,7 @@ class FieldFindingsRepository(context: Context) {
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
@@ -227,9 +256,24 @@ class FieldFindingsRepository(context: Context) {
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
operationId = operationId?.toRequestBody(text),
)
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
@@ -24,6 +26,7 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.time.Instant
data class MobileActSummary(
@@ -90,6 +93,7 @@ data class MobileActListResponse(
)
data class CreateMobileActRequest(
val clientGeneratedId: String? = null,
val occurredAt: String,
val title: String,
val summary: String,
@@ -194,6 +198,14 @@ data class MobileActClosure(
val consents: MobileClosureConsents = MobileClosureConsents(),
)
data class MobileProfileSignatureStatus(
val configured: Boolean = false,
val mimeType: String? = null,
val sizeBytes: Int? = null,
val imageSha256: String? = null,
val updatedAt: String? = null,
)
data class MobileCompanyOutcomeRequest(
val status: String,
val reason: String,
@@ -201,7 +213,7 @@ data class MobileCompanyOutcomeRequest(
data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE",
val uploadMode: String = "IMMEDIATE",
)
data class MobileCloseVisitRequest(
@@ -236,6 +248,11 @@ private interface MobileActsApi {
@Body request: UpdateMobileActRequest,
): MobileActDetail
@GET("users/self/signature")
suspend fun selfSignature(
@Header("Authorization") authorization: String,
): MobileProfileSignatureStatus
@GET("inspection-acts/{actId}/closure")
suspend fun closure(
@Header("Authorization") authorization: String,
@@ -312,8 +329,14 @@ private interface MobileActsApi {
}
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val actListAdapter = moshi.adapter(MobileActListResponse::class.java)
private val actAdapter = moshi.adapter(MobileActDetail::class.java)
private val closureAdapter = moshi.adapter(MobileActClosure::class.java)
private val signatureStatusAdapter = moshi.adapter(MobileProfileSignatureStatus::class.java)
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -321,28 +344,36 @@ class MobileActsRepository(context: Context) {
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun list(visitId: String): MobileActListResponse =
cached("acts:$visitId", actListAdapter) {
authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) }
}
suspend fun get(actId: String): MobileActDetail = authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) {
authorized { session -> api.act("Bearer ${session.accessToken}", actId) }
}
suspend fun create(
visitId: String,
visitCode: String,
): MobileActDetail = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = emptyList(),
),
)
clientGeneratedId: String? = null,
occurredAt: String = Instant.now().toString(),
): MobileActDetail {
val created = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
clientGeneratedId = clientGeneratedId,
occurredAt = occurredAt,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = emptyList(),
),
)
}
cache.put(userCacheKey("act:${created.id}"), actAdapter.toJson(created))
return created
}
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
@@ -354,16 +385,25 @@ class MobileActsRepository(context: Context) {
}
}
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) {
authorized { session -> api.closure("Bearer ${session.accessToken}", actId) }
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
suspend fun selfSignatureStatus(): MobileProfileSignatureStatus =
cached("self-signature-status", signatureStatusAdapter) {
authorized { session -> api.selfSignature("Bearer ${session.accessToken}") }
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure {
val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
}
suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
suspend fun lock(actId: String, urgency: String): MobileActClosure {
val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
}
suspend fun signInspector(
@@ -382,6 +422,7 @@ class MobileActsRepository(context: Context) {
accuracyM: Double?,
manifestation: String = "CONFORMITY",
statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = signature(
actId = actId,
png = png,
@@ -391,22 +432,38 @@ class MobileActsRepository(context: Context) {
company = true,
manifestation = manifestation,
statement = statement,
clientSignedAt = clientSignedAt,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure {
val value = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
}
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
suspend fun seal(
actId: String,
uploadMode: String = "IMMEDIATE",
clientClosedAt: String = Instant.now().toString(),
): MobileActClosure {
val value = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode))
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
}
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
suspend fun closeVisit(
visitId: String,
clientClosedAt: String = Instant.now().toString(),
): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt))
}
private suspend fun signature(
@@ -418,6 +475,7 @@ class MobileActsRepository(context: Context) {
company: Boolean,
manifestation: String? = null,
statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData(
@@ -426,7 +484,7 @@ class MobileActsRepository(context: Context) {
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val signedAt = clientSignedAt.toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
@@ -446,6 +504,20 @@ class MobileActsRepository(context: Context) {
}
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -0,0 +1,622 @@
package com.korexlabs.dhinspeccion.data.offline
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@Entity(tableName = "pending_mobile_operations")
data class PendingMobileOperation(
@PrimaryKey val id: String,
val ownerUserId: String,
val type: String,
val resourceId: String,
val payloadJson: String,
val filePath: String? = null,
val state: String = "PENDING",
val attempts: Int = 0,
val lastError: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface PendingMobileOperationDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(value: PendingMobileOperation)
@Query("SELECT * FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR') ORDER BY createdAt ASC")
suspend fun pending(ownerUserId: String): List<PendingMobileOperation>
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun pendingCount(ownerUserId: String): Int
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND resourceId=:resourceId AND type=:type AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun hasPending(ownerUserId: String, type: String, resourceId: String): Int
@Query("UPDATE pending_mobile_operations SET state=:state, attempts=:attempts, lastError=:lastError, updatedAt=:updatedAt WHERE id=:id")
suspend fun mark(id: String, state: String, attempts: Int, lastError: String?, updatedAt: Long = System.currentTimeMillis())
@Query("DELETE FROM pending_mobile_operations WHERE id=:id")
suspend fun delete(id: String)
}
@Entity(tableName = "mobile_offline_cache")
data class OfflineCacheEntry(
@PrimaryKey val cacheKey: String,
val json: String,
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface OfflineCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(value: OfflineCacheEntry)
@Query("SELECT * FROM mobile_offline_cache WHERE cacheKey=:key LIMIT 1")
suspend fun get(key: String): OfflineCacheEntry?
@Query("DELETE FROM mobile_offline_cache WHERE cacheKey=:key")
suspend fun delete(key: String)
}
@Database(entities = [PendingMobileOperation::class, OfflineCacheEntry::class], version = 1, exportSchema = false)
abstract class OfflineQueueDatabase : RoomDatabase() {
abstract fun operations(): PendingMobileOperationDao
abstract fun cache(): OfflineCacheDao
companion object {
@Volatile private var instance: OfflineQueueDatabase? = null
fun get(context: Context): OfflineQueueDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
OfflineQueueDatabase::class.java,
"dh_mobile_offline_queue.db",
).build().also { instance = it }
}
}
}
class OfflineJsonCache(context: Context) {
private val dao = OfflineQueueDatabase.get(context).cache()
suspend fun put(key: String, json: String) {
dao.put(OfflineCacheEntry(key, json))
}
suspend fun get(key: String): String? = dao.get(key)?.json
suspend fun delete(key: String) = dao.delete(key)
}
class OfflineMutationQueue(private val context: Context) {
private val dao = OfflineQueueDatabase.get(context).operations()
private val sessions = SecureSessionStore(context.applicationContext)
private val mediaRoot = File(context.filesDir, "offline-media").apply { mkdirs() }
suspend fun pendingCount(): Int = currentOwnerOrNull()?.let { dao.pendingCount(it) } ?: 0
suspend fun hasPending(type: String, resourceId: String): Boolean =
currentOwnerOrNull()?.let { dao.hasPending(it, type, resourceId) > 0 } ?: false
suspend fun enqueueFindingCreate(
findingId: String,
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): String = enqueueJson(
type = "FINDING_CREATE",
resourceId = findingId,
payload = JSONObject()
.put("visitId", visitId)
.put("assetId", assetId)
.put("actId", request.actId)
.put("clientGeneratedId", request.clientGeneratedId ?: findingId)
.put("catalogItemId", request.catalogItemId)
.put("customTitle", request.customTitle)
.put("customLegalBasis", request.customLegalBasis)
.put("description", request.description)
.put("severity", request.severity)
.toString(),
)
suspend fun enqueueFindingPhoto(
findingId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String?,
description: String?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FINDING_PHOTO", findingId, source, "jpg") { id ->
JSONObject()
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("title", title)
.put("description", description)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueVisitStart(visitId: String): String = enqueueJson(
type = "VISIT_START",
resourceId = visitId,
payload = JSONObject().toString(),
)
suspend fun enqueueVisitClose(
visitId: String,
clientClosedAt: String = Instant.now().toString(),
): String = enqueueJson(
type = "VISIT_CLOSE",
resourceId = visitId,
payload = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
)
suspend fun enqueueFieldAssetCreate(
assetId: String,
visitId: String,
request: CreateFieldInventoryRequest,
): String = enqueueJson(
type = "FIELD_ASSET_CREATE",
resourceId = assetId,
payload = JSONObject()
.put("visitId", visitId)
.put("typeId", request.typeId)
.put("parentId", request.parentId)
.put("familyId", request.familyId)
.put("code", request.code)
.put("name", request.name)
.put("commonName", request.commonName)
.put("description", request.description)
.put("discoveryNotes", request.discoveryNotes)
.put("attributes", JSONObject(request.attributes))
.put("deviceLatitude", request.deviceLatitude)
.put("deviceLongitude", request.deviceLongitude)
.put("deviceAccuracyM", request.deviceAccuracyM)
.put("deviceCapturedAt", request.deviceCapturedAt)
.put("deviceLabel", request.deviceLabel)
.toString(),
)
suspend fun enqueueFieldAssetPhoto(
assetId: String,
visitId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FIELD_ASSET_PHOTO", assetId, source, "jpg") { id ->
JSONObject()
.put("visitId", visitId)
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueFieldAssetSelect(visitId: String, assetId: String): String = enqueueJson(
type = "FIELD_ASSET_SELECT",
resourceId = assetId,
payload = JSONObject().put("visitId", visitId).toString(),
)
suspend fun enqueueActCreate(
actId: String,
visitId: String,
visitCode: String,
occurredAt: String,
): String = enqueueJson(
type = "ACT_CREATE",
resourceId = actId,
payload = JSONObject()
.put("visitId", visitId)
.put("visitCode", visitCode)
.put("occurredAt", occurredAt)
.toString(),
)
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
type = "ACT_ASSET_ENSURE",
resourceId = actId,
payload = JSONObject().put("assetId", assetId).toString(),
)
suspend fun enqueueResponsible(actId: String, request: MobileResponsibleRequest): String = enqueueJson(
type = "ACT_RESPONSIBLE",
resourceId = actId,
payload = JSONObject()
.put("attendanceStatus", request.attendanceStatus)
.put("fullName", request.fullName)
.put("documentType", request.documentType)
.put("documentNumber", request.documentNumber)
.put("position", request.position)
.put("email", request.email)
.put("phone", request.phone)
.put("absenceReason", request.absenceReason)
.toString(),
)
suspend fun enqueueActLock(actId: String, urgency: String): String = enqueueJson(
type = "ACT_LOCK",
resourceId = actId,
payload = JSONObject().put("urgency", urgency).toString(),
)
suspend fun enqueueCompanyOutcome(actId: String, status: String, reason: String): String = enqueueJson(
type = "COMPANY_OUTCOME",
resourceId = actId,
payload = JSONObject().put("status", status).put("reason", reason).toString(),
)
suspend fun enqueueCompanySignature(
actId: String,
source: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
clientSignedAt: String = Instant.now().toString(),
): String = persistWithFile("COMPANY_SIGNATURE", actId, source, "png") { _ ->
JSONObject()
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("manifestation", manifestation)
.put("statement", statement)
.put("clientSignedAt", clientSignedAt)
.toString()
}
suspend fun enqueueSeal(
actId: String,
clientClosedAt: String = Instant.now().toString(),
): String {
val id = UUID.randomUUID().toString()
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = "ACT_SEAL",
resourceId = actId,
payloadJson = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
createdAt = nextCreatedAt(),
),
)
schedule(context)
return id
}
private suspend fun enqueueJson(type: String, resourceId: String, payload: String): String {
val id = UUID.randomUUID().toString()
dao.insert(PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload,
createdAt = nextCreatedAt(),
))
schedule(context)
return id
}
private suspend fun persistWithFile(
type: String,
resourceId: String,
source: File,
extension: String,
payload: (String) -> String,
): String = withContext(Dispatchers.IO) {
val id = UUID.randomUUID().toString()
val target = File(mediaRoot, "$id.$extension")
source.copyTo(target, overwrite = false)
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload(id),
filePath = target.absolutePath,
createdAt = nextCreatedAt(),
),
)
source.delete()
schedule(context)
id
}
suspend fun syncAll(): SyncResult = syncMutex.withLock {
val ownerUserId = currentOwnerOrNull() ?: return@withLock SyncResult(0, 0, retry = false)
var synced = 0
var lastError: String? = null
for (operation in dao.pending(ownerUserId)) {
val attempt = operation.attempts + 1
dao.mark(operation.id, "SYNCING", attempt, null)
try {
syncOne(operation)
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} catch (error: IOException) {
dao.mark(operation.id, "PENDING", attempt, error.message)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = true, lastError = error.message)
} catch (error: HttpException) {
if (isAlreadyApplied(operation, error)) {
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} else {
lastError = httpError(error)
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
} catch (error: Throwable) {
lastError = error.message ?: error::class.java.simpleName
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
}
SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
private suspend fun syncOne(operation: PendingMobileOperation) {
val payload = JSONObject(operation.payloadJson)
when (operation.type) {
"VISIT_START" -> DhRepository(context).startVisit(operation.resourceId)
"VISIT_CLOSE" -> MobileActsRepository(context).closeVisit(
operation.resourceId,
payload.getString("clientClosedAt"),
)
"FIELD_ASSET_CREATE" -> DhRepository(context).createFieldAsset(
payload.getString("visitId"),
CreateFieldInventoryRequest(
clientGeneratedId = operation.resourceId,
typeId = payload.getString("typeId"),
parentId = payload.optNullableString("parentId"),
familyId = payload.optNullableString("familyId"),
code = payload.optNullableString("code"),
name = payload.getString("name"),
commonName = payload.optNullableString("commonName"),
description = payload.optNullableString("description"),
discoveryNotes = payload.optNullableString("discoveryNotes"),
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
deviceLatitude = payload.getDouble("deviceLatitude"),
deviceLongitude = payload.getDouble("deviceLongitude"),
deviceAccuracyM = payload.optDoubleOrNull("deviceAccuracyM"),
deviceCapturedAt = payload.getString("deviceCapturedAt"),
deviceLabel = payload.optString("deviceLabel", "DH Android"),
),
)
"FIELD_ASSET_PHOTO" -> DhRepository(context).uploadFieldPhoto(
visitId = payload.getString("visitId"),
assetId = operation.resourceId,
file = requiredFile(operation),
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
"FIELD_ASSET_SELECT" -> DhRepository(context).selectFieldAsset(
payload.getString("visitId"),
operation.resourceId,
)
"ACT_CREATE" -> MobileActsRepository(context).create(
visitId = payload.getString("visitId"),
visitCode = payload.getString("visitCode"),
clientGeneratedId = operation.resourceId,
occurredAt = payload.getString("occurredAt"),
)
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
operation.resourceId,
payload.getString("assetId"),
)
"FINDING_CREATE" -> FieldFindingsRepository(context).create(
payload.getString("visitId"),
payload.getString("assetId"),
CreateFieldFindingRequest(
actId = payload.getString("actId"),
clientGeneratedId = payload.getString("clientGeneratedId"),
catalogItemId = payload.optNullableString("catalogItemId"),
customTitle = payload.optNullableString("customTitle"),
customLegalBasis = payload.optNullableString("customLegalBasis"),
description = payload.getString("description"),
severity = if (!payload.has("severity") || payload.isNull("severity")) null else payload.getInt("severity"),
),
)
"ACT_RESPONSIBLE" -> MobileActsRepository(context).setResponsible(
operation.resourceId,
MobileResponsibleRequest(
attendanceStatus = payload.getString("attendanceStatus"),
fullName = payload.optNullableString("fullName"),
documentType = payload.optNullableString("documentType"),
documentNumber = payload.optNullableString("documentNumber"),
position = payload.optNullableString("position"),
email = payload.optNullableString("email"),
phone = payload.optNullableString("phone"),
absenceReason = payload.optNullableString("absenceReason"),
),
)
"ACT_LOCK" -> MobileActsRepository(context).lock(
operation.resourceId,
payload.getString("urgency"),
)
"COMPANY_OUTCOME" -> MobileActsRepository(context).companyOutcome(
operation.resourceId,
payload.getString("status"),
payload.getString("reason"),
)
"FINDING_PHOTO" -> {
val file = requiredFile(operation)
FieldFindingsRepository(context).uploadObservationPhoto(
findingId = operation.resourceId,
file = file,
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
title = payload.optNullableString("title"),
description = payload.optNullableString("description"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
}
"COMPANY_SIGNATURE" -> {
val file = requiredFile(operation)
MobileActsRepository(context).signCompany(
actId = operation.resourceId,
png = file,
latitude = payload.optDoubleOrNull("latitude"),
longitude = payload.optDoubleOrNull("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
manifestation = payload.optString("manifestation", "CONFORMITY"),
statement = payload.optNullableString("statement"),
clientSignedAt = payload.getString("clientSignedAt"),
)
}
"ACT_SEAL" -> MobileActsRepository(context).seal(
actId = operation.resourceId,
uploadMode = "DEFERRED",
clientClosedAt = payload.getString("clientClosedAt"),
)
else -> error("Operación offline desconocida: ${operation.type}")
}
}
private suspend fun isAlreadyApplied(operation: PendingMobileOperation, error: HttpException): Boolean {
if (error.code() != 409) return false
return when (operation.type) {
"VISIT_START" -> runCatching {
DhRepository(context).visit(operation.resourceId).status in setOf("IN_PROGRESS", "CLOSED")
}.getOrDefault(false)
"VISIT_CLOSE" -> runCatching {
DhRepository(context).visit(operation.resourceId).status == "CLOSED"
}.getOrDefault(false)
"ACT_LOCK" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status != "DRAFT"
}.getOrDefault(false)
"COMPANY_OUTCOME", "COMPANY_SIGNATURE" -> runCatching {
MobileActsRepository(context).closure(operation.resourceId).signatures.any {
it.signerType == "COMPANY_RESPONSIBLE" && (it.status == "SIGNED" || it.status == "REFUSED")
}
}.getOrDefault(false)
"ACT_SEAL" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status == "SEALED"
}.getOrDefault(false)
else -> false
}
}
private fun requiredFile(operation: PendingMobileOperation): File {
val path = operation.filePath ?: error("La operación ${operation.id} no tiene archivo local")
return File(path).also { require(it.isFile) { "Archivo local pendiente inexistente" } }
}
private fun currentOwnerOrNull(): String? = sessions.load()?.userId
private fun currentOwner(): String = currentOwnerOrNull()
?: throw IllegalStateException("Sesión no iniciada")
data class SyncResult(val synced: Int, val pending: Int, val retry: Boolean, val lastError: String? = null)
companion object {
private const val UNIQUE_WORK = "dh-mobile-offline-sync"
private val syncMutex = Mutex()
private val queueClock = AtomicLong(System.currentTimeMillis())
private fun nextCreatedAt(): Long = queueClock.updateAndGet { previous ->
maxOf(System.currentTimeMillis(), previous + 1)
}
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(UNIQUE_WORK, ExistingWorkPolicy.KEEP, request)
}
}
}
class OfflineSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val result = OfflineMutationQueue(applicationContext).syncAll()
return if (result.retry) Result.retry() else Result.success()
}
}
private fun httpError(error: HttpException): String {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull().orEmpty()
return runCatching { JSONObject(body).optString("message") }.getOrNull()
?.takeIf { it.isNotBlank() }
?: "No se pudo sincronizar (HTTP ${error.code()})"
}
private fun jsonObjectToMap(value: JSONObject): Map<String, Any?> = buildMap {
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
put(key, jsonValue(value.opt(key)))
}
}
private fun jsonValue(value: Any?): Any? = when (value) {
null, JSONObject.NULL -> null
is JSONObject -> jsonObjectToMap(value)
is org.json.JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) }
else -> value
}
private fun JSONObject.optDoubleOrNull(name: String): Double? =
if (!has(name) || isNull(name)) null else getDouble(name)
private fun JSONObject.optNullableString(name: String): String? =
if (!has(name) || isNull(name)) null else optString(name).takeIf { it.isNotBlank() }
@@ -1,12 +1,14 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.graphics.BitmapFactory
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.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -40,6 +42,8 @@ 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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -48,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
@@ -93,6 +98,9 @@ fun FieldFindingScreen(model: MainViewModel) {
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
var reviewPhotoFile by remember { mutableStateOf<File?>(null) }
var reviewPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var reviewPhotoFindingId by remember { mutableStateOf<String?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
@@ -100,14 +108,11 @@ fun FieldFindingScreen(model: MainViewModel) {
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",
)
reviewPhotoFile = file
reviewPhotoGeo = geo
reviewPhotoFindingId = findingId
} else {
file?.delete()
}
pendingPhotoFile = null
pendingPhotoGeo = null
@@ -182,6 +187,79 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
val reviewFile = reviewPhotoFile
val reviewGeo = reviewPhotoGeo
val reviewFindingId = reviewPhotoFindingId
if (reviewFile != null && reviewGeo != null && reviewFindingId != null) {
val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) {
runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull()
}
Dialog(onDismissRequest = {}) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
"Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = "Vista previa de la fotografía del Hallazgo",
modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp),
contentScale = ContentScale.Fit,
)
} else {
Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error)
}
Text(
"GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = {
model.uploadFindingPhoto(
findingId = reviewFindingId,
file = reviewFile,
latitude = reviewGeo.latitude,
longitude = reviewGeo.longitude,
accuracyM = reviewGeo.accuracyM,
title = "Evidencia fotográfica de campo",
)
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy && bitmap != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Usar esta foto") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
beginPhoto(reviewFindingId)
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Volver a tomar") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Eliminar foto") }
}
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -76,14 +76,12 @@ fun ModernMobileActsScreen(
val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
@@ -138,6 +136,29 @@ fun ModernMobileActsScreen(
ModernMessage(model)
if (model.pendingSyncCount > 0) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${model.pendingSyncCount} cambio${if (model.pendingSyncCount == 1) "" else "s"} guardado${if (model.pendingSyncCount == 1) "" else "s"} en este dispositivo",
fontWeight = FontWeight.Bold,
)
Text(
"Podés seguir trabajando sin internet. Los cambios se sincronizan automáticamente cuando vuelve la conexión.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
OutlinedButton(onClick = { model.syncPendingNow() }, enabled = !model.busy) {
Text("Sincronizar ahora")
}
}
}
}
if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(
@@ -255,37 +276,23 @@ fun ModernMobileActsScreen(
Spacer(Modifier.width(8.dp))
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar representante") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
Text(
"Identificá a la persona que acompaña el recorrido. Al finalizar esta Acta podrá firmar conforme, firmar en disconformidad o negarse a firmar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar representante") }
}
}
@@ -334,7 +341,8 @@ fun ModernMobileActsScreen(
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
val companyResolved = companyResolvedOnServer || model.selectedActCompanySignaturePending
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -347,24 +355,50 @@ fun ModernMobileActsScreen(
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(
"Tu firma de inspector está guardada en el Dashboard y se incorporará automáticamente cuando cierres esta Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
when (model.inspectorSignatureConfigured) {
true -> Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(
"Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
false -> Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) {
Text("Volver a verificar")
}
}
}
null -> Text(
"La firma del inspector se validará con el Dashboard al sincronizar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
if (companyOutcome != null && companyResolvedOnServer) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
@@ -374,26 +408,24 @@ fun ModernMobileActsScreen(
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
} else if (model.selectedActCompanySignaturePending) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
Text(
"El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
enabled = !model.busy && recordedAbsence.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Confirmar ausencia documentada") }
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
SuccessLine("Firma guardada en el dispositivo")
Text(
"Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.",
style = MaterialTheme.typography.bodySmall,
)
}
}
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Text(
"Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.",
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
@@ -429,14 +461,24 @@ fun ModernMobileActsScreen(
}
if (companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Aplicar mi firma y cerrar Acta")
if (model.selectedActSealPending) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
"Cierre guardado en este dispositivo · pendiente de sincronización",
Modifier.padding(12.dp),
fontWeight = FontWeight.SemiBold,
)
}
} else {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text(if (model.inspectorSignatureConfigured == false) "Guardar cierre pendiente" else "Firmar y cerrar Acta")
}
}
} else {
Text(