diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt new file mode 100644 index 0000000..2d5a9ec --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt @@ -0,0 +1,202 @@ +package com.korexlabs.dhinspeccion.ui + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.korexlabs.dhinspeccion.MainViewModel + +private const val BIOMETRIC_PREFS = "dh_v2_biometric" +private const val BIOMETRIC_ENABLED = "enabled" + +private fun biometricAvailable(context: Context): Boolean = + BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + +private fun biometricEnabled(context: Context): Boolean = + context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE) + .getBoolean(BIOMETRIC_ENABLED, false) + +private fun setBiometricEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(BIOMETRIC_ENABLED, enabled).apply() +} + +@Composable +fun DhRoot(model: MainViewModel, activity: FragmentActivity) { + val context = LocalContext.current + val capable = remember { biometricAvailable(context) } + var unlocked by rememberSaveable { mutableStateOf(false) } + var passwordLoginInFlight by rememberSaveable { mutableStateOf(false) } + var enabled by remember { mutableStateOf(biometricEnabled(context)) } + + LaunchedEffect(model.session) { + if (model.session != null && passwordLoginInFlight) { + if (capable) { + setBiometricEnabled(context, true) + enabled = true + } + unlocked = true + passwordLoginInFlight = false + } + if (model.session == null) unlocked = false + } + + MaterialTheme { + Surface(Modifier.fillMaxSize()) { + when { + model.session == null -> EnhancedLoginScreen(model) { + passwordLoginInFlight = true + } + enabled && capable && !unlocked -> BiometricUnlockScreen( + activity = activity, + displayName = model.session?.displayName.orEmpty(), + onAuthenticated = { unlocked = true }, + onUsePassword = { + setBiometricEnabled(context, false) + enabled = false + model.logout() + }, + ) + else -> DhApp(model) + } + } + } +} + +@Composable +private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { + var identifier by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text("Aplicación de campo · Dirección de Hidrocarburos") + model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) } + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text("Usuario o email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Contraseña") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña", + ) + } + }, + ) + Button( + onClick = { + onPasswordLogin() + model.login(identifier, password) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !model.busy, + ) { Text(if (model.busy) "Ingresando…" else "Ingresar") } + Text( + "Después del primer ingreso correcto, si la tablet tiene una huella fuerte configurada, se habilita el acceso biométrico. La contraseña no se almacena.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun BiometricUnlockScreen( + activity: FragmentActivity, + displayName: String, + onAuthenticated: () -> Unit, + onUsePassword: () -> Unit, +) { + var error by rememberSaveable { mutableStateOf(null) } + + fun authenticate() { + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + error = null + onAuthenticated() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + error = errString.toString() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + error = "Huella no reconocida." + } + }, + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle("Ingresar a DH Inspección") + .setSubtitle(if (displayName.isBlank()) "Validá tu identidad" else "Hola, $displayName") + .setNegativeButtonText("Usar contraseña") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + prompt.authenticate(info) + } + + LaunchedEffect(Unit) { authenticate() } + + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text("Ingresá con tu huella") + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button(onClick = { authenticate() }, modifier = Modifier.fillMaxWidth()) { Text("Usar huella") } + OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") } + } + } +}