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
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:
@@ -2,6 +2,7 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 37
|
||||
versionName = "0.19.9"
|
||||
versionCode = 38
|
||||
versionName = "0.19.10"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
@@ -73,6 +74,10 @@ dependencies {
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.1")
|
||||
implementation("androidx.room:room-runtime:2.7.2")
|
||||
implementation("androidx.room:room-ktx:2.7.2")
|
||||
kapt("androidx.room:room-compiler:2.7.2")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+51
-7
@@ -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 {
|
||||
|
||||
+622
@@ -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()
|
||||
|
||||
+113
-71
@@ -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(
|
||||
|
||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(37, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.9-debug", BuildConfig.VERSION_NAME)
|
||||
assertEquals(38, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.10-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user