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
+6 -1
View File
@@ -2,10 +2,15 @@
## Versión actual ## Versión actual
- Versión base: `1.1.2` - Versión base: `1.1.3`
## Historial ## Historial
### 1.1.3 - 2026-04-23
- Se cambia en Android el botón de ojo por descarga real del PDF.
- El botón con ícono PDF ahora abre el documento localmente dentro de la app.
### 1.1.2 - 2026-04-23 ### 1.1.2 - 2026-04-23
- Se separa en Android la vista de documentos firmados y originales. - Se separa en Android la vista de documentos firmados y originales.
+1 -1
View File
@@ -1 +1 @@
1.1.2 1.1.3
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign" applicationId "net.linkedcorp.linkedsign"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 10102 versionCode 10103
versionName "1.1.2" versionName "1.1.3"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
} }
@@ -1,5 +1,6 @@
package net.linkedcorp.linkedsign.ui package net.linkedcorp.linkedsign.ui
import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -8,6 +9,7 @@ import android.hardware.biometrics.BiometricPrompt
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.Environment
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -15,7 +17,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.CheckCircle
import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Description 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.Draw
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Fingerprint 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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -890,6 +893,7 @@ private fun DocumentsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) } var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
var showSignedDocuments by rememberSaveable { mutableStateOf(true) } var showSignedDocuments by rememberSaveable { mutableStateOf(true) }
var documentPreview by remember { mutableStateOf<PdfPreviewState?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult uri ?: return@rememberLauncherForActivityResult
try { try {
@@ -963,26 +967,56 @@ private fun DocumentsScreen(
title = item.title, title = item.title,
subtitle = item.subtitle, subtitle = item.subtitle,
onSelect = if (item.isSigned) null else ({ onSelectDocumentToSign(item.documentId) }), onSelect = if (item.isSigned) null else ({ onSelectDocumentToSign(item.documentId) }),
onView = { onPreview = {
scope.launch { scope.launch {
runCatching { runCatching {
if (item.isSigned && item.signedSignatureId != null) { val token = accessToken()
openSecurePdf( val file = if (item.isSigned && item.signedSignatureId != null) {
downloadSecuredFileToCache(
appContext, appContext,
"${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download", "${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download",
accessToken(), token,
item.signedFilename ?: "firmado.pdf" item.signedFilename ?: "firmado.pdf"
) )
} else { } else {
openSecurePdf( downloadSecuredFileToCache(
appContext, appContext,
"${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download", "${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download",
accessToken(), token,
item.originalFilename item.originalFilename
) )
} }
renderPdfPreview(file, 0)
}.onSuccess {
documentPreview = it
}.onFailure { }.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 @Composable
@@ -1465,7 +1515,14 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
} }
@Composable @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 isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true)
val compactSubtitle = subtitle val compactSubtitle = subtitle
.replace("signed", "", ignoreCase = true) .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) { Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
Icon( IconButton(onClick = onPreview, modifier = Modifier.size(28.dp)) {
if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf, Icon(
contentDescription = null, if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf,
tint = if (isSigned) Success else Warning, contentDescription = "Ver PDF dentro de la app",
modifier = Modifier.size(18.dp) 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 = 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)) { IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.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)) 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 @Composable
private fun CertificateRow(certificate: CertificateItem) { private fun CertificateRow(certificate: CertificateItem) {
val isActive = (certificate.isActive ?: 0) == 1 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) { private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) {
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName) 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) if (!accessToken.isNullOrBlank()) {
val intent = Intent(Intent.ACTION_VIEW).apply { request.addRequestHeader("Authorization", "Bearer $accessToken")
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent)
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
manager.enqueue(request)
} }
private suspend fun downloadSecuredFileToCache( private suspend fun downloadSecuredFileToCache(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkedsign-backend", "name": "linkedsign-backend",
"version": "1.1.2", "version": "1.1.3",
"private": true, "private": true,
"description": "Initial backend for LinkedSign", "description": "Initial backend for LinkedSign",
"main": "src/server.js", "main": "src/server.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",