Refine Android app into focused mobile signer flow
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user