Adjust Android document actions

This commit is contained in:
2026-04-23 10:37:04 -05:00
parent 622048b163
commit ae5649f408
6 changed files with 156 additions and 31 deletions
@@ -1,5 +1,6 @@
package net.linkedcorp.linkedsign.ui
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@@ -8,6 +9,7 @@ import android.hardware.biometrics.BiometricPrompt
import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.widget.Toast
import androidx.activity.ComponentActivity
@@ -15,7 +17,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricManager
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -48,6 +49,7 @@ import androidx.compose.material.icons.outlined.Badge
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Draw
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Fingerprint
@@ -116,6 +118,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
@@ -890,6 +893,7 @@ private fun DocumentsScreen(
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
var showSignedDocuments by rememberSaveable { mutableStateOf(true) }
var documentPreview by remember { mutableStateOf<PdfPreviewState?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
try {
@@ -963,26 +967,56 @@ private fun DocumentsScreen(
title = item.title,
subtitle = item.subtitle,
onSelect = if (item.isSigned) null else ({ onSelectDocumentToSign(item.documentId) }),
onView = {
onPreview = {
scope.launch {
runCatching {
if (item.isSigned && item.signedSignatureId != null) {
openSecurePdf(
val token = accessToken()
val file = if (item.isSigned && item.signedSignatureId != null) {
downloadSecuredFileToCache(
appContext,
"${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download",
accessToken(),
token,
item.signedFilename ?: "firmado.pdf"
)
} else {
openSecurePdf(
downloadSecuredFileToCache(
appContext,
"${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download",
accessToken(),
token,
item.originalFilename
)
}
renderPdfPreview(file, 0)
}.onSuccess {
documentPreview = it
}.onFailure {
Toast.makeText(appContext, it.message ?: "No se pudo abrir el PDF.", Toast.LENGTH_SHORT).show()
Toast.makeText(appContext, it.message ?: "No se pudo abrir el PDF dentro de la app.", Toast.LENGTH_SHORT).show()
}
}
},
onDownload = {
scope.launch {
runCatching {
val token = accessToken()
if (item.isSigned && item.signedSignatureId != null) {
downloadFile(
appContext,
"${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download",
token,
item.signedFilename ?: "firmado.pdf"
)
} else {
downloadFile(
appContext,
"${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download",
token,
item.originalFilename
)
}
}.onSuccess {
Toast.makeText(appContext, "Descarga iniciada.", Toast.LENGTH_SHORT).show()
}.onFailure {
Toast.makeText(appContext, it.message ?: "No se pudo descargar el PDF.", Toast.LENGTH_SHORT).show()
}
}
},
@@ -1022,6 +1056,22 @@ private fun DocumentsScreen(
}
)
}
documentPreview?.let { preview ->
PdfPreviewDialog(
preview = preview,
onDismiss = { documentPreview = null },
onPageChange = { page ->
scope.launch {
runCatching { renderPdfPreview(preview.file, page) }
.onSuccess { documentPreview = it }
.onFailure {
Toast.makeText(appContext, it.message ?: "No se pudo cargar la página.", Toast.LENGTH_SHORT).show()
}
}
}
)
}
}
@Composable
@@ -1465,7 +1515,14 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
}
@Composable
private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDelete: () -> Unit) {
private fun DocumentRow(
title: String,
subtitle: String,
onSelect: (() -> Unit)? = null,
onPreview: () -> Unit,
onDownload: () -> Unit,
onDelete: () -> Unit
) {
val isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true)
val compactSubtitle = subtitle
.replace("signed", "", ignoreCase = true)
@@ -1525,14 +1582,16 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)?
)
}
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf,
contentDescription = null,
tint = if (isSigned) Success else Warning,
modifier = Modifier.size(18.dp)
)
IconButton(onClick = onView, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.dp))
IconButton(onClick = onPreview, modifier = Modifier.size(28.dp)) {
Icon(
if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf,
contentDescription = "Ver PDF dentro de la app",
tint = if (isSigned) Success else Warning,
modifier = Modifier.size(18.dp)
)
}
IconButton(onClick = onDownload, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Download, contentDescription = "Descargar documento", tint = Brand, modifier = Modifier.size(17.dp))
}
IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.dp))
@@ -1544,6 +1603,64 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)?
Spacer(Modifier.height(2.dp))
}
@Composable
private fun PdfPreviewDialog(
preview: PdfPreviewState,
onDismiss: () -> Unit,
onPageChange: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = Color.White,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Vista local del PDF", color = BrandDark, fontSize = AppFontSizes.lg, fontWeight = FontWeight.SemiBold)
TextButton(onClick = onDismiss) { Text("Cerrar") }
}
Text(
"Página ${preview.pageIndex + 1} / ${preview.pageCount}",
color = TextMuted,
fontSize = AppFontSizes.sm
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF3F6FC), RoundedCornerShape(16.dp))
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Image(
bitmap = preview.bitmap,
contentDescription = "Vista local PDF",
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedActionButton("‹", Icons.Outlined.Description, modifier = Modifier.weight(1f)) {
if (preview.pageIndex > 0) onPageChange(preview.pageIndex - 1)
}
OutlinedActionButton("›", Icons.Outlined.Description, modifier = Modifier.weight(1f)) {
if (preview.pageIndex < preview.pageCount - 1) onPageChange(preview.pageIndex + 1)
}
}
}
}
}
}
@Composable
private fun CertificateRow(certificate: CertificateItem) {
val isActive = (certificate.isActive ?: 0) == 1
@@ -1824,16 +1941,19 @@ private fun queryFileName(context: Context, uri: Uri): String? {
}
}
private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) {
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName)
private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("Descargando archivo de LinkedSign")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
val uri = FileProvider.getUriForFile(context, "net.linkedcorp.linkedsign.fileprovider", targetFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (!accessToken.isNullOrBlank()) {
request.addRequestHeader("Authorization", "Bearer $accessToken")
}
context.startActivity(intent)
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
manager.enqueue(request)
}
private suspend fun downloadSecuredFileToCache(