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 const val SignatureHeight = 60
private enum class AuthMode { Login, Register, VerifyNotice } private enum class AuthMode { Login }
private enum class AppTab(val title: String) { Dashboard("Inicio"), Documents("Documentos"), Sign("Firmar"), Certificates("Certificados"), Profile("Perfil") } private enum class AppTab(val title: String) { Documents("Documentos"), Sign("Firmar"), Certificates("Certificados") }
private data class PdfPreviewState( private data class PdfPreviewState(
val file: File, val file: File,
@@ -200,7 +200,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
val state = viewModel.uiState val state = viewModel.uiState
var authMode by rememberSaveable { mutableStateOf(AuthMode.Login) } 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) } var pendingSignDocumentId by rememberSaveable { mutableStateOf<String?>(null) }
val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) } val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) }
var appUnlocked by rememberSaveable { mutableStateOf(false) } var appUnlocked by rememberSaveable { mutableStateOf(false) }
@@ -213,12 +213,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
viewModel.bootstrap() viewModel.bootstrap()
} }
LaunchedEffect(state.emailForVerificationNotice) {
if (!state.emailForVerificationNotice.isNullOrBlank()) {
authMode = AuthMode.VerifyNotice
}
}
LaunchedEffect(state.loading, state.authenticated, biometricAvailable) { LaunchedEffect(state.loading, state.authenticated, biometricAvailable) {
if (!state.loading && !bootstrapGateHandled) { if (!state.loading && !bootstrapGateHandled) {
bootstrapGateHandled = true 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( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Brand, containerColor = Brand,
titleContentColor = Color.White titleContentColor = Color.White
@@ -266,11 +273,9 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
tonalElevation = 0.dp tonalElevation = 0.dp
) { ) {
val tabs = listOf( val tabs = listOf(
AppTab.Dashboard to Icons.Outlined.Dashboard,
AppTab.Documents to Icons.Outlined.Description, AppTab.Documents to Icons.Outlined.Description,
AppTab.Sign to Icons.Outlined.Draw, AppTab.Sign to Icons.Outlined.Draw,
AppTab.Certificates to Icons.Outlined.Badge, AppTab.Certificates to Icons.Outlined.Badge
AppTab.Profile to Icons.Outlined.AccountCircle
) )
tabs.forEach { (tab, icon) -> tabs.forEach { (tab, icon) ->
NavigationBarItem( NavigationBarItem(
@@ -308,7 +313,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
scope.launch { scope.launch {
runCatching { viewModel.biometricLogin() } runCatching { viewModel.biometricLogin() }
.onSuccess { .onSuccess {
selectedTab = AppTab.Dashboard selectedTab = AppTab.Documents
appUnlocked = true appUnlocked = true
snackbarHostState.showSnackbar("Sesión iniciada con huella.") snackbarHostState.showSnackbar("Sesión iniciada con huella.")
} }
@@ -343,15 +348,11 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
} }
) )
!state.authenticated -> AuthFlow( !state.authenticated -> AuthFlow(
mode = authMode,
onModeChange = { authMode = it },
verifyEmail = state.emailForVerificationNotice,
onConsumeVerifyNotice = { viewModel.clearVerificationNotice() },
onLogin = { email, password -> onLogin = { email, password ->
scope.launch { scope.launch {
runCatching { viewModel.login(email, password) } runCatching { viewModel.login(email, password) }
.onSuccess { .onSuccess {
selectedTab = AppTab.Dashboard selectedTab = AppTab.Documents
appUnlocked = true appUnlocked = true
pendingBiometricEmail = email.trim() pendingBiometricEmail = email.trim()
pendingBiometricPassword = password 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) modifier = Modifier.padding(padding)
) )
else -> AppFlow( 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.") } .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, selectedSignDocumentId = pendingSignDocumentId,
onSelectDocumentToSign = { documentId -> onSelectDocumentToSign = { documentId ->
pendingSignDocumentId = documentId.toString() pendingSignDocumentId = documentId.toString()
@@ -524,12 +498,7 @@ private fun LoadingState() {
@Composable @Composable
private fun AuthFlow( private fun AuthFlow(
mode: AuthMode,
onModeChange: (AuthMode) -> Unit,
verifyEmail: String?,
onConsumeVerifyNotice: () -> Unit,
onLogin: (String, String) -> Unit, onLogin: (String, String) -> Unit,
onRegister: (String, String, String, Uri, String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@@ -554,7 +523,7 @@ private fun AuthFlow(
} }
Text("LinkedSign", color = Color.White, fontSize = AppFontSizes.brand, fontWeight = FontWeight.Bold) Text("LinkedSign", color = Color.White, fontSize = AppFontSizes.brand, fontWeight = FontWeight.Bold)
Text( Text(
if (mode == AuthMode.Register) "Crear cuenta" else "Firma digital profesional", "Firmador móvil",
color = Color.White.copy(alpha = 0.75f), color = Color.White.copy(alpha = 0.75f),
fontSize = AppFontSizes.base fontSize = AppFontSizes.base
) )
@@ -569,23 +538,7 @@ private fun AuthFlow(
.padding(horizontal = 20.dp, vertical = 24.dp), .padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
when (mode) { LoginCard(onSubmit = onLogin)
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)
}
)
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Text( Text(
"Servidor: ${BuildConfig.API_BASE_URL}", "Servidor: ${BuildConfig.API_BASE_URL}",
@@ -682,8 +635,6 @@ private fun AppFlow(
onUploadCertificate: (Uri, String, String, String) -> Unit, onUploadCertificate: (Uri, String, String, String) -> Unit,
onDeleteDocument: (UnifiedDocumentItem) -> Unit, onDeleteDocument: (UnifiedDocumentItem) -> Unit,
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit, onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
onRequestPasswordChange: (String, String) -> Unit,
onLogout: () -> Unit,
onSelectDocumentToSign: (Int) -> Unit, onSelectDocumentToSign: (Int) -> Unit,
accessToken: () -> String? accessToken: () -> String?
) { ) {
@@ -693,7 +644,6 @@ private fun AppFlow(
.padding(padding) .padding(padding)
) { ) {
when (selectedTab) { when (selectedTab) {
AppTab.Dashboard -> DashboardScreen(state = state, onSync = onSync)
AppTab.Documents -> DocumentsScreen( AppTab.Documents -> DocumentsScreen(
state = state, state = state,
accessToken = accessToken, accessToken = accessToken,
@@ -711,13 +661,12 @@ private fun AppFlow(
onSign = onSign onSign = onSign
) )
AppTab.Certificates -> CertificatesScreen(state = state, onSync = onSync, onUploadCertificate = onUploadCertificate) AppTab.Certificates -> CertificatesScreen(state = state, onSync = onSync, onUploadCertificate = onUploadCertificate)
AppTab.Profile -> ProfileScreen(state = state, onRequestPasswordChange = onRequestPasswordChange, onLogout = onLogout, onSync = onSync)
} }
} }
} }
@Composable @Composable
private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Unit) { private fun LoginCard(onSubmit: (String, String) -> Unit) {
var email by rememberSaveable { mutableStateOf("") } var email by rememberSaveable { mutableStateOf("") }
var password 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)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Iniciar sesión", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark) 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 = email, onValueChange = { email = it }, label = "Correo electrónico", keyboardType = KeyboardType.Email)
AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true) AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true)
@@ -765,183 +714,20 @@ private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Un
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Entrar", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.xl) 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( Surface(
color = SurfaceAlt, color = SurfaceAlt,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, BrandLight) 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( 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á.", "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
)
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, color = TextMuted,
fontSize = AppFontSizes.sm, fontSize = AppFontSizes.sm,
modifier = Modifier.weight(1f) modifier = Modifier.padding(12.dp)
) )
} }
} }
} }
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)
}
}
}
} }
@Composable @Composable