Refine Android app into focused mobile signer flow

This commit is contained in:
2026-04-07 16:43:44 -05:00
parent 605ea94041
commit 007b5b4b9e
@@ -161,8 +161,8 @@ private val CardGlow = Brush.linearGradient(
private const val SignatureHeight = 60
private enum class AuthMode { Login, Register, VerifyNotice }
private enum class AppTab(val title: String) { Dashboard("Inicio"), Documents("Documentos"), Sign("Firmar"), Certificates("Certificados"), Profile("Perfil") }
private enum class AuthMode { Login }
private enum class AppTab(val title: String) { Documents("Documentos"), Sign("Firmar"), Certificates("Certificados") }
private data class PdfPreviewState(
val file: File,
@@ -200,7 +200,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
val state = viewModel.uiState
var authMode by rememberSaveable { mutableStateOf(AuthMode.Login) }
var selectedTab by rememberSaveable { mutableStateOf(AppTab.Dashboard) }
var selectedTab by rememberSaveable { mutableStateOf(AppTab.Documents) }
var pendingSignDocumentId by rememberSaveable { mutableStateOf<String?>(null) }
val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) }
var appUnlocked by rememberSaveable { mutableStateOf(false) }
@@ -213,12 +213,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
viewModel.bootstrap()
}
LaunchedEffect(state.emailForVerificationNotice) {
if (!state.emailForVerificationNotice.isNullOrBlank()) {
authMode = AuthMode.VerifyNotice
}
}
LaunchedEffect(state.loading, state.authenticated, biometricAvailable) {
if (!state.loading && !bootstrapGateHandled) {
bootstrapGateHandled = true
@@ -252,6 +246,19 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}
}
},
actions = {
IconButton(onClick = {
scope.launch {
viewModel.logout()
authMode = AuthMode.Login
pendingSignDocumentId = null
appUnlocked = !(biometricAvailable && state.biometricEnabled && state.biometricHasCredentials)
snackbarHostState.showSnackbar("Sesión cerrada.")
}
}) {
Icon(Icons.Outlined.Logout, contentDescription = "Cerrar sesión", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Brand,
titleContentColor = Color.White
@@ -266,11 +273,9 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
tonalElevation = 0.dp
) {
val tabs = listOf(
AppTab.Dashboard to Icons.Outlined.Dashboard,
AppTab.Documents to Icons.Outlined.Description,
AppTab.Sign to Icons.Outlined.Draw,
AppTab.Certificates to Icons.Outlined.Badge,
AppTab.Profile to Icons.Outlined.AccountCircle
AppTab.Certificates to Icons.Outlined.Badge
)
tabs.forEach { (tab, icon) ->
NavigationBarItem(
@@ -308,7 +313,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
scope.launch {
runCatching { viewModel.biometricLogin() }
.onSuccess {
selectedTab = AppTab.Dashboard
selectedTab = AppTab.Documents
appUnlocked = true
snackbarHostState.showSnackbar("Sesión iniciada con huella.")
}
@@ -343,15 +348,11 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}
)
!state.authenticated -> AuthFlow(
mode = authMode,
onModeChange = { authMode = it },
verifyEmail = state.emailForVerificationNotice,
onConsumeVerifyNotice = { viewModel.clearVerificationNotice() },
onLogin = { email, password ->
scope.launch {
runCatching { viewModel.login(email, password) }
.onSuccess {
selectedTab = AppTab.Dashboard
selectedTab = AppTab.Documents
appUnlocked = true
pendingBiometricEmail = email.trim()
pendingBiometricPassword = password
@@ -368,17 +369,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}
}
},
onRegister = { email, password, certPassword, certificateUri, certificateName ->
scope.launch {
runCatching { viewModel.register(email, password, certPassword, certificateUri, certificateName) }
.onSuccess {
authMode = AuthMode.VerifyNotice
}
.onFailure { error ->
snackbarHostState.showSnackbar((error as? AppException)?.message ?: "No se pudo registrar.")
}
}
},
modifier = Modifier.padding(padding)
)
else -> AppFlow(
@@ -434,22 +424,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
.onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo firmar el documento.") }
}
},
onRequestPasswordChange = { currentPassword, newPassword ->
scope.launch {
runCatching { viewModel.requestPasswordChange(currentPassword, newPassword) }
.onSuccess { snackbarHostState.showSnackbar("Te enviamos un correo para confirmar el cambio de contraseña.") }
.onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo solicitar el cambio.") }
}
},
onLogout = {
scope.launch {
viewModel.logout()
authMode = AuthMode.Login
pendingSignDocumentId = null
appUnlocked = !(biometricAvailable && state.biometricEnabled && state.biometricHasCredentials)
snackbarHostState.showSnackbar("Sesión cerrada.")
}
},
selectedSignDocumentId = pendingSignDocumentId,
onSelectDocumentToSign = { documentId ->
pendingSignDocumentId = documentId.toString()
@@ -524,12 +498,7 @@ private fun LoadingState() {
@Composable
private fun AuthFlow(
mode: AuthMode,
onModeChange: (AuthMode) -> Unit,
verifyEmail: String?,
onConsumeVerifyNotice: () -> Unit,
onLogin: (String, String) -> Unit,
onRegister: (String, String, String, Uri, String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -554,7 +523,7 @@ private fun AuthFlow(
}
Text("LinkedSign", color = Color.White, fontSize = AppFontSizes.brand, fontWeight = FontWeight.Bold)
Text(
if (mode == AuthMode.Register) "Crear cuenta" else "Firma digital profesional",
"Firmador móvil",
color = Color.White.copy(alpha = 0.75f),
fontSize = AppFontSizes.base
)
@@ -569,23 +538,7 @@ private fun AuthFlow(
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
when (mode) {
AuthMode.Login -> LoginCard(
onSubmit = onLogin,
onGoRegister = { onModeChange(AuthMode.Register) }
)
AuthMode.Register -> RegisterCard(
onSubmit = onRegister,
onGoLogin = { onModeChange(AuthMode.Login) }
)
AuthMode.VerifyNotice -> VerifyNoticeCard(
email = verifyEmail,
onBackToLogin = {
onConsumeVerifyNotice()
onModeChange(AuthMode.Login)
}
)
}
LoginCard(onSubmit = onLogin)
if (BuildConfig.DEBUG) {
Text(
"Servidor: ${BuildConfig.API_BASE_URL}",
@@ -682,8 +635,6 @@ private fun AppFlow(
onUploadCertificate: (Uri, String, String, String) -> Unit,
onDeleteDocument: (UnifiedDocumentItem) -> Unit,
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
onRequestPasswordChange: (String, String) -> Unit,
onLogout: () -> Unit,
onSelectDocumentToSign: (Int) -> Unit,
accessToken: () -> String?
) {
@@ -693,7 +644,6 @@ private fun AppFlow(
.padding(padding)
) {
when (selectedTab) {
AppTab.Dashboard -> DashboardScreen(state = state, onSync = onSync)
AppTab.Documents -> DocumentsScreen(
state = state,
accessToken = accessToken,
@@ -711,13 +661,12 @@ private fun AppFlow(
onSign = onSign
)
AppTab.Certificates -> CertificatesScreen(state = state, onSync = onSync, onUploadCertificate = onUploadCertificate)
AppTab.Profile -> ProfileScreen(state = state, onRequestPasswordChange = onRequestPasswordChange, onLogout = onLogout, onSync = onSync)
}
}
}
@Composable
private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Unit) {
private fun LoginCard(onSubmit: (String, String) -> Unit) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
@@ -750,7 +699,7 @@ private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Un
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Iniciar sesión", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark)
Text("Accede a tu cuenta LinkedSign", color = TextMuted, style = MaterialTheme.typography.bodySmall)
Text("Accede con tu cuenta existente. El registro se realiza desde la web.", color = TextMuted, style = MaterialTheme.typography.bodySmall)
}
AppTextField(value = email, onValueChange = { email = it }, label = "Correo electrónico", keyboardType = KeyboardType.Email)
AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true)
@@ -765,180 +714,17 @@ private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Un
Spacer(Modifier.width(8.dp))
Text("Entrar", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.xl)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text("¿No tienes cuenta?", color = Color(0xFF6B7280), fontSize = AppFontSizes.base)
TextButton(onClick = onGoRegister, contentPadding = PaddingValues(horizontal = 6.dp)) {
Text("Regístrate", color = Brand, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base)
}
}
}
}
}
@Composable
private fun RegisterCard(onSubmit: (String, String, String, Uri, String) -> Unit, onGoLogin: () -> Unit) {
val context = LocalContext.current
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var confirmPassword by rememberSaveable { mutableStateOf("") }
var certPassword by rememberSaveable { mutableStateOf("") }
var consentAccepted by rememberSaveable { mutableStateOf(false) }
var certificateUri by rememberSaveable { mutableStateOf<Uri?>(null) }
var certificateName by rememberSaveable { mutableStateOf("") }
val passwordProblems = passwordIssues(password)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
try {
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (_: Exception) {
}
certificateUri = uri
certificateName = queryFileName(context, uri) ?: "certificado.p12"
}
ElevatedCard(
shape = RoundedCornerShape(24.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.elevatedCardColors(containerColor = Color.White)
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Surface(
color = SurfaceAlt,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, BrandLight)
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, StrokeSoft)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier.size(32.dp).background(Color.White, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Outlined.Verified, contentDescription = null, tint = Brand, modifier = Modifier.size(18.dp))
}
Column {
Text("Alta protegida", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base)
Text("Correo válido, clave fuerte y certificado para el consentimiento legal", color = TextMuted, fontSize = AppFontSizes.sm)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Crear cuenta", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark)
Text("Registra tu cuenta en LinkedSign", color = TextMuted, style = MaterialTheme.typography.bodySmall)
}
AppTextField(value = email, onValueChange = { email = it }, label = "Correo electrónico", keyboardType = KeyboardType.Email)
AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true)
PasswordChecklist(password = password)
AppTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = "Confirmar contraseña", isPassword = true)
AppTextField(value = certPassword, onValueChange = { certPassword = it }, label = "Contraseña del certificado", isPassword = true)
Surface(color = SurfaceAlt, shape = RoundedCornerShape(14.dp), border = BorderStroke(1.dp, StrokeSoft)) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Registro legal inicial", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base)
Text(
"Debes cargar tu P12/PFX. La contraseña del certificado solo se usará para firmar los documentos legales del primer registro y no se guardará.",
color = TextMuted,
fontSize = AppFontSizes.sm
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedActionButton(
label = if (certificateName.isBlank()) "Seleccionar P12/PFX" else certificateName,
icon = Icons.Outlined.Badge,
modifier = Modifier.weight(1f)
) {
launcher.launch(arrayOf("*/*"))
}
}
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
androidx.compose.material3.Checkbox(
checked = consentAccepted,
onCheckedChange = { consentAccepted = it }
)
Text(
"Acepto los términos de uso, la política de privacidad y el consentimiento expreso para tratamiento de datos y uso de firma electrónica.",
color = TextMuted,
fontSize = AppFontSizes.sm,
modifier = Modifier.weight(1f)
)
}
}
}
if (passwordProblems.isNotEmpty() && password.isNotBlank()) {
Surface(color = WarningBg, shape = RoundedCornerShape(10.dp)) {
Text("La contraseña aún no cumple todos los requisitos.", color = Warning, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md)
}
}
if (password != confirmPassword && confirmPassword.isNotBlank()) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(10.dp)) {
Text("Las contraseñas no coinciden.", color = Danger, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md)
}
}
Spacer(Modifier.height(2.dp))
Button(
onClick = {
when {
passwordProblems.isNotEmpty() -> Unit
password != confirmPassword -> Unit
certPassword.isBlank() -> Unit
certificateUri == null -> Unit
!consentAccepted -> Unit
else -> onSubmit(email, password, certPassword, certificateUri!!, certificateName.ifBlank { "certificado.p12" })
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Brand)
) {
Text("Crear cuenta", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.xl)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text("¿Ya tienes cuenta?", color = Color(0xFF6B7280), fontSize = AppFontSizes.base)
TextButton(onClick = onGoLogin, contentPadding = PaddingValues(horizontal = 6.dp)) {
Text("Inicia sesión", color = Brand, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base)
}
}
}
}
}
@Composable
private fun VerifyNoticeCard(email: String?, onBackToLogin: () -> Unit) {
ElevatedCard(
shape = RoundedCornerShape(24.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.elevatedCardColors(containerColor = Color.White)
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(64.dp)
.background(Brush.radialGradient(listOf(SuccessBg, Color.White)), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Success, modifier = Modifier.size(32.dp))
}
Text("Confirma tu cuenta", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark)
Text(
"Enviamos un enlace de verificación a ${email ?: "tu correo"}. Debes confirmarlo antes de iniciar sesión.",
color = TextMuted,
style = MaterialTheme.typography.bodyMedium
)
Button(
onClick = onBackToLogin,
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Brand)
) {
Text("Volver al inicio", fontWeight = FontWeight.SemiBold)
Text(
"La app móvil se usa para firmar y revisar documentos. Para crear cuenta o gestionar accesos, usa la versión web.",
color = TextMuted,
fontSize = AppFontSizes.sm,
modifier = Modifier.padding(12.dp)
)
}
}
}