2a7ab64bbf
- Unify login/register into single screen with tab switcher, logo, and themed inputs - Redesign profile with header card, segmented theme toggle, icon-based menu, and bottom-sheet modals - Add auth guard to alerts screen showing login prompt instead of 401 error - Switch ImageBackground to explicit Image+View layering for reliable rendering - Add dark mode background image switching in root layout - Add dark mode override for recent-tag in PWA search view
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Alert,
|
|
Image,
|
|
} from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useThemeContext } from '../../components/ThemeProvider';
|
|
import { register } from '../../services/auth';
|
|
import { spacing, borderRadius, shadows } from '../../constants/theme';
|
|
import {
|
|
isBiometricsAvailable,
|
|
authenticateWithBiometrics,
|
|
saveBiometricCredentials,
|
|
getBiometricUsername,
|
|
} from '../../services/biometrics';
|
|
|
|
type Tab = 'login' | 'register';
|
|
|
|
export default function LoginScreen() {
|
|
const router = useRouter();
|
|
const { login } = useAuthStore();
|
|
const { colors } = useThemeContext();
|
|
const [tab, setTab] = useState<Tab>('login');
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
|
|
const [biometricUsername, setBiometricUsername] = useState<string | null>(null);
|
|
|
|
const isRegister = tab === 'register';
|
|
|
|
useEffect(() => {
|
|
checkBiometrics();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setUsername('');
|
|
setPassword('');
|
|
setConfirmPassword('');
|
|
}, [tab]);
|
|
|
|
const checkBiometrics = async () => {
|
|
try {
|
|
const available = await isBiometricsAvailable();
|
|
setBiometricsAvailable(available);
|
|
if (available) {
|
|
const savedUsername = await getBiometricUsername();
|
|
setBiometricUsername(savedUsername);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking biometrics:', error);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!username.trim() || !password) {
|
|
Alert.alert('Error', 'Por favor completa todos los campos');
|
|
return;
|
|
}
|
|
|
|
if (isRegister) {
|
|
if (password.length < 8) {
|
|
Alert.alert('Error', 'La contraseña debe tener al menos 8 caracteres');
|
|
return;
|
|
}
|
|
if (password !== confirmPassword) {
|
|
Alert.alert('Error', 'Las contraseñas no coinciden');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
if (isRegister) {
|
|
await register(username.trim(), password);
|
|
Alert.alert('Éxito', 'Cuenta creada correctamente', [
|
|
{ text: 'OK', onPress: () => setTab('login') },
|
|
]);
|
|
} else {
|
|
await login(username.trim(), password);
|
|
if (biometricsAvailable) {
|
|
await saveBiometricCredentials(username.trim());
|
|
}
|
|
router.replace('/(tabs)');
|
|
}
|
|
} catch (error) {
|
|
Alert.alert(
|
|
'Error',
|
|
isRegister ? 'No se pudo crear la cuenta' : 'Credenciales incorrectas'
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBiometricLogin = async () => {
|
|
if (!biometricUsername) {
|
|
Alert.alert('Error', 'No hay credenciales biométricas guardadas');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const authenticated = await authenticateWithBiometrics();
|
|
if (authenticated) {
|
|
await login(biometricUsername, '');
|
|
router.replace('/(tabs)');
|
|
} else {
|
|
Alert.alert('Error', 'Autenticación biométrica fallida');
|
|
}
|
|
} catch (error) {
|
|
Alert.alert('Error', 'Error en la autenticación biométrica');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={[styles.container, { backgroundColor: colors.background }]}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
>
|
|
<View style={styles.content}>
|
|
{/* Logo */}
|
|
<View style={styles.logoSection}>
|
|
<Image
|
|
source={require('../../assets/farmaclic_logo.png')}
|
|
style={styles.logo}
|
|
resizeMode="contain"
|
|
/>
|
|
<Text style={[styles.brandName, { color: colors.text }]}>FarmaClic</Text>
|
|
</View>
|
|
|
|
{/* Tabs */}
|
|
<View style={[styles.tabBar, { backgroundColor: colors.surfaceLow }]}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.tab,
|
|
!isRegister && [styles.tabActive, { backgroundColor: colors.primary }],
|
|
]}
|
|
onPress={() => setTab('login')}
|
|
disabled={isLoading}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.tabText,
|
|
{ color: isRegister ? colors.textSecondary : colors.onPrimaryContainer },
|
|
!isRegister && styles.tabTextActive,
|
|
]}
|
|
>
|
|
Iniciar Sesión
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.tab,
|
|
isRegister && [styles.tabActive, { backgroundColor: colors.primary }],
|
|
]}
|
|
onPress={() => setTab('register')}
|
|
disabled={isLoading}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.tabText,
|
|
{ color: !isRegister ? colors.textSecondary : colors.onPrimaryContainer },
|
|
isRegister && styles.tabTextActive,
|
|
]}
|
|
>
|
|
Crear Cuenta
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Card */}
|
|
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
|
|
{/* Header */}
|
|
<Text style={[styles.title, { color: colors.text }]}>
|
|
{isRegister ? 'Crea tu cuenta' : 'Bienvenido de nuevo'}
|
|
</Text>
|
|
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
|
|
{isRegister
|
|
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
|
|
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
|
|
</Text>
|
|
|
|
{/* Username */}
|
|
<View style={[styles.inputWrapper, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
|
<Ionicons name="person-outline" size={20} color={colors.textSecondary} style={styles.inputIcon} />
|
|
<TextInput
|
|
style={[styles.input, { color: colors.text }]}
|
|
value={username}
|
|
onChangeText={setUsername}
|
|
placeholder="Usuario"
|
|
placeholderTextColor={colors.textSecondary}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoComplete="username"
|
|
/>
|
|
</View>
|
|
|
|
{/* Password */}
|
|
<View style={[styles.inputWrapper, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
|
<Ionicons name="lock-closed-outline" size={20} color={colors.textSecondary} style={styles.inputIcon} />
|
|
<TextInput
|
|
style={[styles.input, { color: colors.text }]}
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
placeholder="Contraseña"
|
|
placeholderTextColor={colors.textSecondary}
|
|
secureTextEntry
|
|
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
|
/>
|
|
</View>
|
|
|
|
{/* Confirm password (register only) */}
|
|
{isRegister && (
|
|
<View style={[styles.inputWrapper, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
|
<Ionicons name="lock-closed-outline" size={20} color={colors.textSecondary} style={styles.inputIcon} />
|
|
<TextInput
|
|
style={[styles.input, { color: colors.text }]}
|
|
value={confirmPassword}
|
|
onChangeText={setConfirmPassword}
|
|
placeholder="Confirmar contraseña"
|
|
placeholderTextColor={colors.textSecondary}
|
|
secureTextEntry
|
|
autoComplete="new-password"
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Hints (register only) */}
|
|
{isRegister && (
|
|
<View style={styles.hints}>
|
|
<Text style={[styles.hint, { color: colors.textSecondary }]}>
|
|
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> 3-32 caracteres para el usuario
|
|
</Text>
|
|
<Text style={[styles.hint, { color: colors.textSecondary }]}>
|
|
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> Mínimo 8 caracteres para la contraseña
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Submit button */}
|
|
<TouchableOpacity
|
|
style={[styles.button, { backgroundColor: colors.primary }, isLoading && styles.buttonDisabled]}
|
|
onPress={handleSubmit}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<Ionicons name="hourglass" size={20} color={colors.onPrimaryContainer} />
|
|
) : (
|
|
<Text style={[styles.buttonText, { color: colors.onPrimaryContainer }]}>
|
|
{isRegister ? 'Crear Cuenta' : 'Iniciar Sesión'}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{/* Biometrics (login only) */}
|
|
{!isRegister && biometricsAvailable && biometricUsername && (
|
|
<TouchableOpacity
|
|
style={[styles.biometricButton, { borderColor: colors.primary }]}
|
|
onPress={handleBiometricLogin}
|
|
disabled={isLoading}
|
|
>
|
|
<Ionicons name="finger-print" size={22} color={colors.primary} />
|
|
<Text style={[styles.biometricText, { color: colors.primary }]}>Iniciar con biometría</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Footer link */}
|
|
<TouchableOpacity
|
|
style={styles.linkButton}
|
|
onPress={() => setTab(isRegister ? 'login' : 'register')}
|
|
>
|
|
<Text style={[styles.linkText, { color: colors.primary }]}>
|
|
{isRegister ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
paddingHorizontal: spacing.lg,
|
|
gap: spacing.lg,
|
|
},
|
|
logoSection: {
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
logo: {
|
|
width: 80,
|
|
height: 80,
|
|
},
|
|
brandName: {
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
letterSpacing: -0.3,
|
|
},
|
|
tabBar: {
|
|
flexDirection: 'row',
|
|
borderRadius: borderRadius.lg,
|
|
padding: 4,
|
|
},
|
|
tab: {
|
|
flex: 1,
|
|
paddingVertical: spacing.sm + 2,
|
|
borderRadius: borderRadius.md,
|
|
alignItems: 'center',
|
|
},
|
|
tabActive: {
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 4,
|
|
elevation: 2,
|
|
},
|
|
tabText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
tabTextActive: {
|
|
fontWeight: '700',
|
|
},
|
|
card: {
|
|
borderRadius: borderRadius.xl,
|
|
padding: spacing.lg,
|
|
gap: spacing.sm,
|
|
},
|
|
title: {
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
marginBottom: spacing.xs,
|
|
},
|
|
subtitle: {
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
inputWrapper: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: borderRadius.md,
|
|
borderWidth: 1,
|
|
paddingHorizontal: spacing.md,
|
|
},
|
|
inputIcon: {
|
|
marginRight: spacing.sm,
|
|
},
|
|
input: {
|
|
flex: 1,
|
|
paddingVertical: spacing.md,
|
|
fontSize: 16,
|
|
},
|
|
hints: {
|
|
gap: 2,
|
|
marginTop: spacing.xs,
|
|
},
|
|
hint: {
|
|
fontSize: 12,
|
|
lineHeight: 18,
|
|
},
|
|
button: {
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.md,
|
|
alignItems: 'center',
|
|
marginTop: spacing.sm,
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
buttonText: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
},
|
|
biometricButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.md,
|
|
marginTop: spacing.xs,
|
|
borderWidth: 1,
|
|
gap: spacing.sm,
|
|
},
|
|
biometricText: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
linkButton: {
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.sm,
|
|
},
|
|
linkText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
});
|