|
|
@@ -0,0 +1,793 @@
|
|
|
|
|
|
|
|
package com.korexlabs.dhinspeccion.ui
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import android.Manifest
|
|
|
|
|
|
|
|
import android.content.Context
|
|
|
|
|
|
|
|
import android.content.pm.PackageManager
|
|
|
|
|
|
|
|
import android.net.Uri
|
|
|
|
|
|
|
|
import android.os.Environment
|
|
|
|
|
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
|
|
|
|
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
|
|
|
|
|
|
import androidx.compose.foundation.BorderStroke
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.Row
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.Spacer
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.height
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.padding
|
|
|
|
|
|
|
|
import androidx.compose.foundation.layout.width
|
|
|
|
|
|
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
|
|
|
|
|
|
import androidx.compose.foundation.lazy.items
|
|
|
|
|
|
|
|
import androidx.compose.foundation.rememberScrollState
|
|
|
|
|
|
|
|
import androidx.compose.foundation.verticalScroll
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.Icons
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.Add
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.ArrowBack
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.Assignment
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.CameraAlt
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.CheckCircle
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.Factory
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.LocationOn
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.Search
|
|
|
|
|
|
|
|
import androidx.compose.material.icons.filled.WarningAmber
|
|
|
|
|
|
|
|
import androidx.compose.material3.Button
|
|
|
|
|
|
|
|
import androidx.compose.material3.ElevatedCard
|
|
|
|
|
|
|
|
import androidx.compose.material3.FilterChip
|
|
|
|
|
|
|
|
import androidx.compose.material3.HorizontalDivider
|
|
|
|
|
|
|
|
import androidx.compose.material3.Icon
|
|
|
|
|
|
|
|
import androidx.compose.material3.IconButton
|
|
|
|
|
|
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
|
|
|
|
|
|
import androidx.compose.material3.MaterialTheme
|
|
|
|
|
|
|
|
import androidx.compose.material3.OutlinedButton
|
|
|
|
|
|
|
|
import androidx.compose.material3.OutlinedTextField
|
|
|
|
|
|
|
|
import androidx.compose.material3.Surface
|
|
|
|
|
|
|
|
import androidx.compose.material3.Text
|
|
|
|
|
|
|
|
import androidx.compose.runtime.Composable
|
|
|
|
|
|
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
|
|
|
|
|
|
import androidx.compose.runtime.getValue
|
|
|
|
|
|
|
|
import androidx.compose.runtime.mutableStateMapOf
|
|
|
|
|
|
|
|
import androidx.compose.runtime.mutableStateOf
|
|
|
|
|
|
|
|
import androidx.compose.runtime.remember
|
|
|
|
|
|
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
|
|
|
|
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
|
|
|
|
|
|
import androidx.compose.runtime.setValue
|
|
|
|
|
|
|
|
import androidx.compose.ui.Alignment
|
|
|
|
|
|
|
|
import androidx.compose.ui.Modifier
|
|
|
|
|
|
|
|
import androidx.compose.ui.platform.LocalContext
|
|
|
|
|
|
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
|
|
|
|
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
|
|
|
|
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
|
|
|
|
import androidx.core.content.ContextCompat
|
|
|
|
|
|
|
|
import androidx.core.content.FileProvider
|
|
|
|
|
|
|
|
import androidx.exifinterface.media.ExifInterface
|
|
|
|
|
|
|
|
import com.google.android.gms.location.LocationServices
|
|
|
|
|
|
|
|
import com.google.android.gms.location.Priority
|
|
|
|
|
|
|
|
import com.google.android.gms.tasks.CancellationTokenSource
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.MainViewModel
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.data.FieldType
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.data.PlannedAsset
|
|
|
|
|
|
|
|
import com.korexlabs.dhinspeccion.data.VisitDetail
|
|
|
|
|
|
|
|
import kotlinx.coroutines.delay
|
|
|
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
|
|
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
|
|
|
|
|
|
import java.io.File
|
|
|
|
|
|
|
|
import java.time.Instant
|
|
|
|
|
|
|
|
import java.time.LocalDate
|
|
|
|
|
|
|
|
import kotlin.coroutines.resume
|
|
|
|
|
|
|
|
import kotlin.coroutines.resumeWithException
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private enum class F8VisitScreen { OVERVIEW, ACTS, FINDING_TARGET, PICK_ASSET, CREATE_ASSET }
|
|
|
|
|
|
|
|
private enum class F8Target { YACIMIENTO, INSTALACION, SUBINSTALACION }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private data class F8Geo(val latitude: Double, val longitude: Double, val accuracyM: Double?)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
fun F8VisitRoot(model: MainViewModel) {
|
|
|
|
|
|
|
|
val visit = model.visit ?: return
|
|
|
|
|
|
|
|
var screenName by rememberSaveable(visit.id) { mutableStateOf(F8VisitScreen.OVERVIEW.name) }
|
|
|
|
|
|
|
|
var targetName by rememberSaveable(visit.id) { mutableStateOf(F8Target.INSTALACION.name) }
|
|
|
|
|
|
|
|
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
|
|
|
|
|
|
|
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val screen = runCatching { F8VisitScreen.valueOf(screenName) }.getOrDefault(F8VisitScreen.OVERVIEW)
|
|
|
|
|
|
|
|
val target = runCatching { F8Target.valueOf(targetName) }.getOrDefault(F8Target.INSTALACION)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun goOverview() {
|
|
|
|
|
|
|
|
screenName = F8VisitScreen.OVERVIEW.name
|
|
|
|
|
|
|
|
parentId = null
|
|
|
|
|
|
|
|
parentLabel = ""
|
|
|
|
|
|
|
|
model.clearSelectedFieldAsset()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
when (screen) {
|
|
|
|
|
|
|
|
F8VisitScreen.OVERVIEW -> F8VisitOverview(
|
|
|
|
|
|
|
|
model = model,
|
|
|
|
|
|
|
|
onActs = { screenName = F8VisitScreen.ACTS.name },
|
|
|
|
|
|
|
|
onCreateFinding = { screenName = F8VisitScreen.FINDING_TARGET.name },
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
F8VisitScreen.ACTS -> ModernMobileActsScreen(
|
|
|
|
|
|
|
|
model = model,
|
|
|
|
|
|
|
|
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
|
|
|
|
|
|
|
|
onGoInventory = { screenName = F8VisitScreen.FINDING_TARGET.name },
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
F8VisitScreen.FINDING_TARGET -> F8FindingTargetScreen(
|
|
|
|
|
|
|
|
model = model,
|
|
|
|
|
|
|
|
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
|
|
|
|
|
|
|
|
onYacimiento = {
|
|
|
|
|
|
|
|
val scope = visit.scopeAsset
|
|
|
|
|
|
|
|
if (scope != null) {
|
|
|
|
|
|
|
|
model.selectExisting(
|
|
|
|
|
|
|
|
FieldInventoryItem(
|
|
|
|
|
|
|
|
id = scope.id,
|
|
|
|
|
|
|
|
code = scope.code,
|
|
|
|
|
|
|
|
name = scope.name,
|
|
|
|
|
|
|
|
type = scope,
|
|
|
|
|
|
|
|
readyForFinding = true,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
onInstallation = {
|
|
|
|
|
|
|
|
targetName = F8Target.INSTALACION.name
|
|
|
|
|
|
|
|
parentId = null
|
|
|
|
|
|
|
|
parentLabel = visit.scopeAsset?.name.orEmpty()
|
|
|
|
|
|
|
|
screenName = F8VisitScreen.PICK_ASSET.name
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
onSubinstallation = {
|
|
|
|
|
|
|
|
targetName = F8Target.SUBINSTALACION.name
|
|
|
|
|
|
|
|
parentId = null
|
|
|
|
|
|
|
|
parentLabel = ""
|
|
|
|
|
|
|
|
screenName = F8VisitScreen.PICK_ASSET.name
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
F8VisitScreen.PICK_ASSET -> F8AssetPicker(
|
|
|
|
|
|
|
|
model = model,
|
|
|
|
|
|
|
|
target = target,
|
|
|
|
|
|
|
|
parentId = parentId,
|
|
|
|
|
|
|
|
parentLabel = parentLabel,
|
|
|
|
|
|
|
|
onBack = {
|
|
|
|
|
|
|
|
if (target == F8Target.SUBINSTALACION && parentId != null) {
|
|
|
|
|
|
|
|
parentId = null
|
|
|
|
|
|
|
|
parentLabel = ""
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
screenName = F8VisitScreen.FINDING_TARGET.name
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
onParent = { item ->
|
|
|
|
|
|
|
|
parentId = item.id
|
|
|
|
|
|
|
|
parentLabel = item.name
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
onCreate = {
|
|
|
|
|
|
|
|
screenName = F8VisitScreen.CREATE_ASSET.name
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
F8VisitScreen.CREATE_ASSET -> F8QuickCreateAsset(
|
|
|
|
|
|
|
|
model = model,
|
|
|
|
|
|
|
|
target = target,
|
|
|
|
|
|
|
|
parentId = parentId,
|
|
|
|
|
|
|
|
parentLabel = parentLabel.ifBlank { visit.scopeAsset?.name.orEmpty() },
|
|
|
|
|
|
|
|
onBack = { screenName = F8VisitScreen.PICK_ASSET.name },
|
|
|
|
|
|
|
|
onFinishedWithoutFinding = { goOverview() },
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8VisitOverview(
|
|
|
|
|
|
|
|
model: MainViewModel,
|
|
|
|
|
|
|
|
onActs: () -> Unit,
|
|
|
|
|
|
|
|
onCreateFinding: () -> Unit,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
val visit = model.visit ?: return
|
|
|
|
|
|
|
|
val draft = model.acts.firstOrNull { it.status == "DRAFT" }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Column(
|
|
|
|
|
|
|
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 18.dp, vertical = 16.dp),
|
|
|
|
|
|
|
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
F8Header("Inspección en campo", visit.code) { model.closeVisitView() }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
|
|
|
|
|
|
Text(visit.scopeAsset?.name ?: "Yacimiento", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
|
|
|
|
|
|
|
|
Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
visit.operationalArea?.name?.takeIf { it.isNotBlank() }?.let {
|
|
|
|
|
|
|
|
Text("Área · $it", style = MaterialTheme.typography.bodyMedium)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Text(f8VisitStatus(visit.status), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F8Messages(model)
|
|
|
|
|
|
|
|
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F8RouteCard(visit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (visit.status == "PLANNED") {
|
|
|
|
|
|
|
|
Button(
|
|
|
|
|
|
|
|
onClick = { model.startVisit() },
|
|
|
|
|
|
|
|
enabled = !model.busy,
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
Text(if (model.busy) "Iniciando…" else "Iniciar inspección")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (visit.status == "IN_PROGRESS") {
|
|
|
|
|
|
|
|
if (draft == null) {
|
|
|
|
|
|
|
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
Text("Empezar a documentar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text(
|
|
|
|
|
|
|
|
"Creá un Acta cuando empieces a registrar lo observado. El número definitivo se asignará al cerrarla.",
|
|
|
|
|
|
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
Button(
|
|
|
|
|
|
|
|
onClick = { model.createAct("NON_URGENT") },
|
|
|
|
|
|
|
|
enabled = !model.busy,
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.Add, null)
|
|
|
|
|
|
|
|
Spacer(Modifier.width(7.dp))
|
|
|
|
|
|
|
|
Text("Nueva Acta")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
|
|
|
onClick = { model.createAct("URGENT") },
|
|
|
|
|
|
|
|
enabled = !model.busy,
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
) { Text("Nueva Acta urgente") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.Assignment, null, tint = MaterialTheme.colorScheme.primary)
|
|
|
|
|
|
|
|
Column {
|
|
|
|
|
|
|
|
Text("Acta en elaboración", fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text("${draft.findingCount} Hallazgo${if (draft.findingCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Button(onClick = onCreateFinding, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.Add, null)
|
|
|
|
|
|
|
|
Spacer(Modifier.width(7.dp))
|
|
|
|
|
|
|
|
Text("Crear hallazgo")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Acta actual") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else if (visit.status == "CLOSED") {
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (model.acts.isNotEmpty()) {
|
|
|
|
|
|
|
|
Text("Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
model.acts.take(4).forEach { act ->
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) {
|
|
|
|
|
|
|
|
Row(Modifier.padding(13.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(if (act.status == "DRAFT") "Acta en elaboración" else act.code, fontWeight = FontWeight.SemiBold)
|
|
|
|
|
|
|
|
Text("${act.findingCount} hallazgos · ${f8ActStatus(act.status)}", style = MaterialTheme.typography.bodySmall)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.secondary)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver todas las Actas") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8RouteCard(visit: VisitDetail) {
|
|
|
|
|
|
|
|
val planned = visit.planningAssets.filter { it.included }
|
|
|
|
|
|
|
|
val checklist = visit.checklist.items.filter { it.assetIncluded && it.asset != null }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
Text("Recorrido sugerido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text(
|
|
|
|
|
|
|
|
"Usalo como guía. Podés registrar cualquier Hallazgo nuevo aunque no esté planificado.",
|
|
|
|
|
|
|
|
style = MaterialTheme.typography.bodySmall,
|
|
|
|
|
|
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (planned.isEmpty() && checklist.isEmpty()) {
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
|
|
|
|
|
|
|
|
Text("No hay controles pendientes cargados para este recorrido.", Modifier.padding(12.dp))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
planned.take(10).forEach { asset -> F8RouteLine(asset, "Planificado", false) }
|
|
|
|
|
|
|
|
checklist.take(12).forEach { item ->
|
|
|
|
|
|
|
|
val due = item.referenceOn?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() }
|
|
|
|
|
|
|
|
val overdue = due?.isBefore(LocalDate.now()) == true || item.findingStatus?.uppercase() == "OVERDUE"
|
|
|
|
|
|
|
|
val label = if (overdue) "Vencido" else "Por vencer"
|
|
|
|
|
|
|
|
val asset = item.asset
|
|
|
|
|
|
|
|
if (asset != null) {
|
|
|
|
|
|
|
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
|
|
|
|
|
|
|
Icon(
|
|
|
|
|
|
|
|
if (overdue) Icons.Filled.WarningAmber else Icons.Filled.LocationOn,
|
|
|
|
|
|
|
|
contentDescription = null,
|
|
|
|
|
|
|
|
tint = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(asset.name, fontWeight = FontWeight.SemiBold)
|
|
|
|
|
|
|
|
Text(
|
|
|
|
|
|
|
|
listOfNotNull(label, item.findingCode, item.findingTitle).joinToString(" · "),
|
|
|
|
|
|
|
|
style = MaterialTheme.typography.bodySmall,
|
|
|
|
|
|
|
|
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8RouteLine(asset: PlannedAsset, label: String, error: Boolean) {
|
|
|
|
|
|
|
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.LocationOn, null, tint = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary)
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(asset.name, fontWeight = FontWeight.SemiBold)
|
|
|
|
|
|
|
|
Text(
|
|
|
|
|
|
|
|
listOfNotNull(label, asset.typeName?.takeIf { it.isNotBlank() }).joinToString(" · "),
|
|
|
|
|
|
|
|
style = MaterialTheme.typography.bodySmall,
|
|
|
|
|
|
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8FindingTargetScreen(
|
|
|
|
|
|
|
|
model: MainViewModel,
|
|
|
|
|
|
|
|
onBack: () -> Unit,
|
|
|
|
|
|
|
|
onYacimiento: () -> Unit,
|
|
|
|
|
|
|
|
onInstallation: () -> Unit,
|
|
|
|
|
|
|
|
onSubinstallation: () -> Unit,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
val visit = model.visit ?: return
|
|
|
|
|
|
|
|
val hasDraft = model.acts.any { it.status == "DRAFT" }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
|
|
|
|
|
|
|
F8Header("Crear hallazgo", visit.scopeAsset?.name ?: visit.code, onBack)
|
|
|
|
|
|
|
|
F8Messages(model)
|
|
|
|
|
|
|
|
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasDraft) {
|
|
|
|
|
|
|
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
Text("Primero necesitás un Acta", fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text("Volvé y tocá Nueva Acta. Así cada Hallazgo queda asociado correctamente.")
|
|
|
|
|
|
|
|
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return@Column
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Text("¿Dónde está el hallazgo?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text("Elegí el lugar. Si todavía no existe en el Inventario, lo vas a poder crear en el siguiente paso.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F8TargetCard("En el Yacimiento", visit.scopeAsset?.name ?: "Yacimiento", Icons.Filled.LocationOn, onYacimiento)
|
|
|
|
|
|
|
|
F8TargetCard("En una Instalación", "Elegir o crear Instalación", Icons.Filled.Factory, onInstallation)
|
|
|
|
|
|
|
|
F8TargetCard("En una Subinstalación", "Elegir Instalación y luego el elemento", Icons.Filled.Assignment, onSubinstallation)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8TargetCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
|
|
|
|
|
|
|
|
ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Row(Modifier.padding(18.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
|
|
|
|
|
|
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
|
|
|
|
|
|
|
|
Icon(icon, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8AssetPicker(
|
|
|
|
|
|
|
|
model: MainViewModel,
|
|
|
|
|
|
|
|
target: F8Target,
|
|
|
|
|
|
|
|
parentId: String?,
|
|
|
|
|
|
|
|
parentLabel: String,
|
|
|
|
|
|
|
|
onBack: () -> Unit,
|
|
|
|
|
|
|
|
onParent: (FieldInventoryItem) -> Unit,
|
|
|
|
|
|
|
|
onCreate: () -> Unit,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
val visit = model.visit ?: return
|
|
|
|
|
|
|
|
var search by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
|
|
|
|
|
|
|
|
val pickingParent = target == F8Target.SUBINSTALACION && parentId == null
|
|
|
|
|
|
|
|
val title = when {
|
|
|
|
|
|
|
|
pickingParent -> "Elegir Instalación"
|
|
|
|
|
|
|
|
target == F8Target.SUBINSTALACION -> "Elegir Subinstalación"
|
|
|
|
|
|
|
|
else -> "Elegir Instalación"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LaunchedEffect(visit.id, target.name, parentId, search) {
|
|
|
|
|
|
|
|
delay(220)
|
|
|
|
|
|
|
|
val effectiveParent = if (pickingParent || target == F8Target.INSTALACION) visit.scopeAsset?.id else parentId
|
|
|
|
|
|
|
|
model.searchInventory(search.trim(), effectiveParent)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val rows = model.inventory.filter { item ->
|
|
|
|
|
|
|
|
val code = f8Norm(item.type?.code ?: item.type?.name)
|
|
|
|
|
|
|
|
if (pickingParent || target == F8Target.INSTALACION) {
|
|
|
|
|
|
|
|
code.contains("instalacion") && !code.contains("subinstalacion")
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
code.contains("subinstalacion")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
F8Header(title, if (parentLabel.isBlank()) visit.scopeAsset?.name ?: visit.code else parentLabel, onBack)
|
|
|
|
|
|
|
|
F8Messages(model)
|
|
|
|
|
|
|
|
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
|
|
|
value = search,
|
|
|
|
|
|
|
|
onValueChange = { search = it },
|
|
|
|
|
|
|
|
label = { Text("Buscar por nombre o código") },
|
|
|
|
|
|
|
|
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
singleLine = true,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!pickingParent) {
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.Add, null)
|
|
|
|
|
|
|
|
Spacer(Modifier.width(6.dp))
|
|
|
|
|
|
|
|
Text(if (target == F8Target.SUBINSTALACION) "Crear Subinstalación" else "Crear Instalación")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else if (rows.isEmpty() && !model.busy) {
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Text("No existe: crear Instalación")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (rows.isEmpty() && !model.busy) {
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
|
|
|
|
|
|
|
|
Text("No encontramos coincidencias. Podés crear el elemento sin salir del Hallazgo.", Modifier.padding(14.dp))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
|
|
|
|
|
|
items(rows, key = { it.id }) { item ->
|
|
|
|
|
|
|
|
ElevatedCard(
|
|
|
|
|
|
|
|
onClick = { if (pickingParent) onParent(item) else model.selectExisting(item) },
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.Factory, null, tint = MaterialTheme.colorScheme.primary)
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(item.name, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
|
|
|
|
|
|
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
|
|
|
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8QuickCreateAsset(
|
|
|
|
|
|
|
|
model: MainViewModel,
|
|
|
|
|
|
|
|
target: F8Target,
|
|
|
|
|
|
|
|
parentId: String?,
|
|
|
|
|
|
|
|
parentLabel: String,
|
|
|
|
|
|
|
|
onBack: () -> Unit,
|
|
|
|
|
|
|
|
onFinishedWithoutFinding: () -> Unit,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
val visit = model.visit ?: return
|
|
|
|
|
|
|
|
val context = LocalContext.current
|
|
|
|
|
|
|
|
val scope = rememberCoroutineScope()
|
|
|
|
|
|
|
|
var name by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
|
|
|
|
|
|
|
|
var selectedTypeId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
|
|
|
|
|
|
|
|
var selectedFamilyId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
|
|
|
|
|
|
|
|
val attributes = remember { mutableStateMapOf<String, String>() }
|
|
|
|
|
|
|
|
var localError by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
|
|
|
|
|
|
|
|
var pendingAutoPhoto by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(false) }
|
|
|
|
|
|
|
|
var photoFile by remember { mutableStateOf<File?>(null) }
|
|
|
|
|
|
|
|
var photoGeo by remember { mutableStateOf<F8Geo?>(null) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LaunchedEffect(visit.id, target.name, parentId) {
|
|
|
|
|
|
|
|
model.loadFieldTypes(if (target == F8Target.SUBINSTALACION) parentId else null)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
LaunchedEffect(model.fieldTypes) {
|
|
|
|
|
|
|
|
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
|
|
|
|
|
|
|
selectedTypeId = model.fieldTypes.firstOrNull()?.id
|
|
|
|
|
|
|
|
selectedFamilyId = null
|
|
|
|
|
|
|
|
attributes.clear()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
|
|
|
|
|
|
|
val selectedAsset = model.selectedFieldAsset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val takePhoto = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
|
|
|
|
|
|
|
val file = photoFile
|
|
|
|
|
|
|
|
val geo = photoGeo
|
|
|
|
|
|
|
|
if (success && file != null && geo != null) {
|
|
|
|
|
|
|
|
runCatching { f8WriteExif(file, geo) }
|
|
|
|
|
|
|
|
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
|
|
|
|
|
|
|
|
} else if (!success) {
|
|
|
|
|
|
|
|
localError = "La foto es necesaria para continuar con el Hallazgo."
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
photoFile = null
|
|
|
|
|
|
|
|
photoGeo = null
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun beginPhoto() {
|
|
|
|
|
|
|
|
scope.launch {
|
|
|
|
|
|
|
|
runCatching { f8CurrentGeo(context) }
|
|
|
|
|
|
|
|
.onSuccess { geo ->
|
|
|
|
|
|
|
|
val (file, uri) = f8NewPhoto(context)
|
|
|
|
|
|
|
|
photoFile = file
|
|
|
|
|
|
|
|
photoGeo = geo
|
|
|
|
|
|
|
|
takePhoto.launch(uri)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación." }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun createNow() {
|
|
|
|
|
|
|
|
val type = selectedType ?: return
|
|
|
|
|
|
|
|
scope.launch {
|
|
|
|
|
|
|
|
runCatching { f8CurrentGeo(context) }
|
|
|
|
|
|
|
|
.onSuccess { geo ->
|
|
|
|
|
|
|
|
pendingAutoPhoto = true
|
|
|
|
|
|
|
|
model.createFieldAsset(
|
|
|
|
|
|
|
|
type = type,
|
|
|
|
|
|
|
|
parentId = if (target == F8Target.SUBINSTALACION) parentId else null,
|
|
|
|
|
|
|
|
familyId = selectedFamilyId,
|
|
|
|
|
|
|
|
name = name,
|
|
|
|
|
|
|
|
commonName = null,
|
|
|
|
|
|
|
|
description = null,
|
|
|
|
|
|
|
|
attributes = f8Attributes(type, attributes),
|
|
|
|
|
|
|
|
latitude = geo.latitude,
|
|
|
|
|
|
|
|
longitude = geo.longitude,
|
|
|
|
|
|
|
|
accuracyM = geo.accuracyM,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.onFailure { localError = it.message ?: "No se pudo capturar el GPS." }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
|
|
|
|
|
|
|
val camera = result[Manifest.permission.CAMERA] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
|
|
|
|
|
|
|
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f8HasLocation(context)
|
|
|
|
|
|
|
|
if (camera && location) {
|
|
|
|
|
|
|
|
if (selectedAsset?.capture?.captureRequired == true) beginPhoto() else createNow()
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
localError = "Para el alta en campo se necesitan cámara y ubicación."
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun requestCreate() {
|
|
|
|
|
|
|
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) createNow()
|
|
|
|
|
|
|
|
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun requestPhoto() {
|
|
|
|
|
|
|
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) beginPhoto()
|
|
|
|
|
|
|
|
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LaunchedEffect(selectedAsset?.asset?.id, pendingAutoPhoto) {
|
|
|
|
|
|
|
|
if (pendingAutoPhoto && selectedAsset?.capture?.captureRequired == true && selectedAsset.capture.creationGpsCaptured && selectedAsset.capture.fieldPhotoCount == 0) {
|
|
|
|
|
|
|
|
pendingAutoPhoto = false
|
|
|
|
|
|
|
|
requestPhoto()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) {
|
|
|
|
|
|
|
|
F8Header(
|
|
|
|
|
|
|
|
if (target == F8Target.SUBINSTALACION) "Nueva Subinstalación" else "Nueva Instalación",
|
|
|
|
|
|
|
|
parentLabel,
|
|
|
|
|
|
|
|
onBack,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
F8Messages(model)
|
|
|
|
|
|
|
|
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
|
|
|
|
|
|
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedAsset != null && selectedAsset.capture.captureRequired) {
|
|
|
|
|
|
|
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
|
|
|
|
|
|
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
|
|
|
|
|
|
Text(selectedAsset.asset.name, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text("GPS ${if (selectedAsset.capture.creationGpsCaptured) "✓" else "pendiente"} · Foto ${if (selectedAsset.capture.fieldPhotoCount > 0) "✓" else "pendiente"}")
|
|
|
|
|
|
|
|
if (!selectedAsset.capture.readyForFinding) {
|
|
|
|
|
|
|
|
Button(onClick = { requestPhoto() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.CameraAlt, null)
|
|
|
|
|
|
|
|
Spacer(Modifier.width(7.dp))
|
|
|
|
|
|
|
|
Text("Tomar foto y continuar")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
Button(onClick = { model.openFindingForSelected() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
|
|
|
|
|
|
|
|
Text("Continuar con el Hallazgo")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
OutlinedButton(onClick = onFinishedWithoutFinding, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
Text("Datos básicos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
OutlinedTextField(name, { name = it }, label = { Text("Nombre *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (model.fieldTypes.size > 1) {
|
|
|
|
|
|
|
|
Text("Tipo", fontWeight = FontWeight.SemiBold)
|
|
|
|
|
|
|
|
model.fieldTypes.forEach { type ->
|
|
|
|
|
|
|
|
FilterChip(
|
|
|
|
|
|
|
|
selected = selectedTypeId == type.id,
|
|
|
|
|
|
|
|
onClick = { selectedTypeId = type.id; selectedFamilyId = null; attributes.clear() },
|
|
|
|
|
|
|
|
label = { Text(type.name) },
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
selectedType?.let { Text("Tipo · ${it.name}", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedType?.familyRequired == true) {
|
|
|
|
|
|
|
|
Text("Función / clasificación", fontWeight = FontWeight.SemiBold)
|
|
|
|
|
|
|
|
selectedType.families.forEach { family ->
|
|
|
|
|
|
|
|
FilterChip(
|
|
|
|
|
|
|
|
selected = selectedFamilyId == family.id,
|
|
|
|
|
|
|
|
onClick = { selectedFamilyId = family.id },
|
|
|
|
|
|
|
|
label = { Text(family.name) },
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
selectedType?.attributes?.forEach { definition ->
|
|
|
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
|
|
|
value = attributes[definition.code].orEmpty(),
|
|
|
|
|
|
|
|
onValueChange = { attributes[definition.code] = it },
|
|
|
|
|
|
|
|
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
singleLine = true,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
|
|
|
|
|
|
|
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(9.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
|
|
|
|
|
|
|
|
Text("Al guardar se toma GPS y foto. Después volvés directamente al Hallazgo.", style = MaterialTheme.typography.bodySmall)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributes[it.code].orEmpty().isNotBlank() } ?: false
|
|
|
|
|
|
|
|
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
|
|
|
|
|
|
|
Button(
|
|
|
|
|
|
|
|
onClick = { requestCreate() },
|
|
|
|
|
|
|
|
enabled = name.isNotBlank() && selectedType != null && requiredReady && familyReady && !model.busy,
|
|
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
Icon(Icons.Filled.CameraAlt, null)
|
|
|
|
|
|
|
|
Spacer(Modifier.width(7.dp))
|
|
|
|
|
|
|
|
Text("Guardar y tomar foto")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8Header(title: String, subtitle: String, onBack: () -> Unit) {
|
|
|
|
|
|
|
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
|
|
|
|
|
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Volver") }
|
|
|
|
|
|
|
|
Column(Modifier.weight(1f)) {
|
|
|
|
|
|
|
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
|
|
|
|
|
|
|
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
|
|
private fun F8Messages(model: MainViewModel) {
|
|
|
|
|
|
|
|
model.error?.let {
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
|
|
|
|
|
|
|
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
model.notice?.let {
|
|
|
|
|
|
|
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
|
|
|
|
|
|
|
|
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onPrimaryContainer)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8VisitStatus(status: String): String = when (status) {
|
|
|
|
|
|
|
|
"PLANNED" -> "Planificada"
|
|
|
|
|
|
|
|
"IN_PROGRESS" -> "En curso"
|
|
|
|
|
|
|
|
"CLOSED" -> "Cerrada"
|
|
|
|
|
|
|
|
"CANCELLED" -> "Cancelada"
|
|
|
|
|
|
|
|
else -> status
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8ActStatus(status: String): String = when (status) {
|
|
|
|
|
|
|
|
"DRAFT" -> "En elaboración"
|
|
|
|
|
|
|
|
"LOCKED" -> "Pendiente de firma"
|
|
|
|
|
|
|
|
"SEALED" -> "Cerrada"
|
|
|
|
|
|
|
|
"CANCELLED" -> "Cancelada"
|
|
|
|
|
|
|
|
else -> status
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8Norm(value: String?): String = (value ?: "")
|
|
|
|
|
|
|
|
.normalize(java.text.Normalizer.Form.NFD)
|
|
|
|
|
|
|
|
.replace("\\p{Mn}+".toRegex(), "")
|
|
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
|
|
.lowercase()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
|
|
|
|
|
|
|
type.attributes.associate { definition -> definition.code to f8AttributeValue(definition, values[definition.code].orEmpty()) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8AttributeValue(definition: FieldAttributeDefinition, raw: String): Any? {
|
|
|
|
|
|
|
|
val value = raw.trim()
|
|
|
|
|
|
|
|
if (value.isBlank()) return null
|
|
|
|
|
|
|
|
return when (definition.dataType.uppercase()) {
|
|
|
|
|
|
|
|
"NUMBER", "DECIMAL", "FLOAT" -> value.replace(',', '.').toDoubleOrNull() ?: value
|
|
|
|
|
|
|
|
"INTEGER", "INT" -> value.toLongOrNull() ?: value
|
|
|
|
|
|
|
|
"BOOLEAN", "BOOL" -> value.equals("true", true) || value.equals("si", true) || value.equals("sí", true) || value == "1"
|
|
|
|
|
|
|
|
else -> value
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8HasLocation(context: Context): Boolean =
|
|
|
|
|
|
|
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
|
|
|
|
|
|
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private suspend fun f8CurrentGeo(context: Context): F8Geo = suspendCancellableCoroutine { continuation ->
|
|
|
|
|
|
|
|
if (!f8HasLocation(context)) {
|
|
|
|
|
|
|
|
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
|
|
|
|
|
|
|
|
return@suspendCancellableCoroutine
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
val source = CancellationTokenSource()
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
LocationServices.getFusedLocationProviderClient(context)
|
|
|
|
|
|
|
|
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
|
|
|
|
|
|
|
.addOnSuccessListener { location ->
|
|
|
|
|
|
|
|
if (!continuation.isActive) return@addOnSuccessListener
|
|
|
|
|
|
|
|
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
|
|
|
|
|
|
|
else continuation.resume(F8Geo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
|
|
|
|
|
|
|
} catch (error: SecurityException) {
|
|
|
|
|
|
|
|
if (continuation.isActive) continuation.resumeWithException(error)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
continuation.invokeOnCancellation { source.cancel() }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8NewPhoto(context: Context): Pair<File, Uri> {
|
|
|
|
|
|
|
|
val dir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
|
|
|
|
|
|
|
|
val file = File(dir, "dh_field_${System.currentTimeMillis()}.jpg")
|
|
|
|
|
|
|
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
|
|
|
|
|
|
|
return file to uri
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun f8WriteExif(file: File, geo: F8Geo) {
|
|
|
|
|
|
|
|
val exif = ExifInterface(file)
|
|
|
|
|
|
|
|
exif.setGpsInfo(android.location.Location("DH").apply {
|
|
|
|
|
|
|
|
latitude = geo.latitude
|
|
|
|
|
|
|
|
longitude = geo.longitude
|
|
|
|
|
|
|
|
accuracy = geo.accuracyM?.toFloat() ?: 0f
|
|
|
|
|
|
|
|
time = System.currentTimeMillis()
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, Instant.now().toString())
|
|
|
|
|
|
|
|
exif.saveAttributes()
|
|
|
|
|
|
|
|
}
|