460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
Pressable,
|
|
Alert,
|
|
} from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import GradientBackground from '@/shared/components/layout/GradientBackground';
|
|
import LinearGradient from 'react-native-linear-gradient';
|
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
import { login, clearError } from '@/modules/auth/store/authSlice';
|
|
import type { RootState, AppDispatch } from '@/store/store';
|
|
import { useTheme } from '@/shared/styles/useTheme';
|
|
import { validateLoginForm } from '@/shared/utils/validation';
|
|
import { showError, showSuccess, showWarning, showInfo } from '@/shared/utils/Toast';
|
|
|
|
const LoginScreen: React.FC = () => {
|
|
const navigation = useNavigation();
|
|
const dispatch = useDispatch<AppDispatch>();
|
|
const { colors, fonts } = useTheme();
|
|
const { loading, error, isAuthenticated } = useSelector((s: RootState) => s.auth);
|
|
|
|
const [email, setEmail] = React.useState('');
|
|
const [password, setPassword] = React.useState('');
|
|
const [showPassword, setShowPassword] = React.useState(false);
|
|
const [rememberMe, setRememberMe] = React.useState(false);
|
|
const [focused, setFocused] = React.useState<null | 'email' | 'password'>(null);
|
|
const [validationErrors, setValidationErrors] = React.useState<{ email?: string; password?: string }>({});
|
|
|
|
const emailRef = React.useRef<TextInput>(null);
|
|
const passwordRef = React.useRef<TextInput>(null);
|
|
|
|
// Clear validation errors when user starts typing
|
|
const handleEmailChange = (text: string) => {
|
|
setEmail(text);
|
|
if (validationErrors.email) {
|
|
setValidationErrors(prev => ({ ...prev, email: undefined }));
|
|
}
|
|
// Clear API error when user starts typing
|
|
if (error) {
|
|
dispatch(clearError());
|
|
}
|
|
};
|
|
|
|
const handlePasswordChange = (text: string) => {
|
|
setPassword(text);
|
|
if (validationErrors.password) {
|
|
setValidationErrors(prev => ({ ...prev, password: undefined }));
|
|
}
|
|
// Clear API error when user starts typing
|
|
if (error) {
|
|
dispatch(clearError());
|
|
}
|
|
};
|
|
|
|
const handleLogin = async () => {
|
|
// Clear previous validation errors
|
|
setValidationErrors({});
|
|
|
|
// Validate form inputs
|
|
const validation = validateLoginForm(email, password);
|
|
|
|
if (!validation.isValid) {
|
|
setValidationErrors(validation.errors);
|
|
|
|
// Show toast messages for validation errors
|
|
if (validation.errors.email) {
|
|
showError(validation.errors.email);
|
|
}
|
|
if (validation.errors.password) {
|
|
showError(validation.errors.password);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Dispatch login action
|
|
const result = await dispatch(login({ email, password }));
|
|
|
|
// Check if login was successful
|
|
if (login.fulfilled.match(result)) {
|
|
// Login successful - show success toast
|
|
showSuccess('Login successful! Welcome back!');
|
|
// Navigation will be handled by the app navigator based on isAuthenticated state
|
|
} else if (login.rejected.match(result)) {
|
|
// Login failed - show error toast
|
|
showError(result.payload as string || 'Login failed. Please try again.');
|
|
}
|
|
} catch (err) {
|
|
// Error handling is done in the slice
|
|
console.error('Login error:', err);
|
|
showError('An unexpected error occurred. Please try again.');
|
|
}
|
|
};
|
|
|
|
// Clear error when component mounts or when user starts typing
|
|
React.useEffect(() => {
|
|
if (error) {
|
|
dispatch(clearError());
|
|
}
|
|
}, [dispatch]);
|
|
|
|
// Auto-focus email input when component mounts
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
emailRef.current?.focus();
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
return (
|
|
<GradientBackground preset="warm" style={styles.gradient}>
|
|
<View style={styles.container}>
|
|
{/* Card */}
|
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
|
{/* Logo placeholder */}
|
|
<View style={[styles.logoCircle, { backgroundColor: '#F1F5F9' }]}>
|
|
<Icon name="shield" size={28} color={colors.primary} />
|
|
</View>
|
|
|
|
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Login</Text>
|
|
<Text style={[styles.subtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>Enter your email and password to log in</Text>
|
|
|
|
{/* Email input */}
|
|
<View>
|
|
<Pressable
|
|
style={[
|
|
styles.inputWrapper,
|
|
{
|
|
borderColor: validationErrors.email
|
|
? colors.error
|
|
: focused === 'email'
|
|
? colors.primary
|
|
: colors.border,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
]}
|
|
onPress={() => emailRef.current?.focus()}
|
|
>
|
|
<Icon
|
|
name="email-outline"
|
|
size={20}
|
|
color={validationErrors.email
|
|
? colors.error
|
|
: focused === 'email'
|
|
? colors.primary
|
|
: colors.textLight
|
|
}
|
|
/>
|
|
<TextInput
|
|
ref={emailRef}
|
|
placeholder="Email"
|
|
placeholderTextColor={colors.textLight}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoComplete="email"
|
|
keyboardType="email-address"
|
|
returnKeyType="next"
|
|
value={email}
|
|
onFocus={() => setFocused('email')}
|
|
onBlur={() => setFocused(null)}
|
|
onChangeText={handleEmailChange}
|
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
|
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
|
|
/>
|
|
</Pressable>
|
|
{validationErrors.email && (
|
|
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
|
|
{validationErrors.email}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Password input */}
|
|
<View>
|
|
<Pressable
|
|
style={[
|
|
styles.inputWrapper,
|
|
{
|
|
borderColor: validationErrors.password
|
|
? colors.error
|
|
: focused === 'password'
|
|
? colors.primary
|
|
: colors.border,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
]}
|
|
onPress={() => passwordRef.current?.focus()}
|
|
>
|
|
<Icon
|
|
name="lock-outline"
|
|
size={20}
|
|
color={validationErrors.password
|
|
? colors.error
|
|
: focused === 'password'
|
|
? colors.primary
|
|
: colors.textLight
|
|
}
|
|
/>
|
|
<TextInput
|
|
ref={passwordRef}
|
|
placeholder="Password"
|
|
placeholderTextColor={colors.textLight}
|
|
secureTextEntry={!showPassword}
|
|
textContentType="password"
|
|
autoComplete="off"
|
|
autoCorrect={false}
|
|
autoCapitalize="none"
|
|
value={password}
|
|
onFocus={() => setFocused('password')}
|
|
onBlur={() => setFocused(null)}
|
|
onChangeText={handlePasswordChange}
|
|
style={[styles.input, { color: colors.text, fontFamily: fonts.regular }]}
|
|
/>
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
onPress={() => setShowPassword(v => !v)}
|
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
|
|
>
|
|
<Icon name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={22} color={colors.textLight} />
|
|
</TouchableOpacity>
|
|
</Pressable>
|
|
{validationErrors.password && (
|
|
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
|
|
{validationErrors.password}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Row: Remember me + Forgot password */}
|
|
<View style={styles.rowBetween}>
|
|
<Pressable
|
|
style={styles.row}
|
|
onPress={() => {
|
|
setRememberMe(v => !v);
|
|
showInfo(rememberMe ? 'Will not remember login' : 'Will remember login');
|
|
}}
|
|
>
|
|
<Icon name={rememberMe ? 'checkbox-marked' : 'checkbox-blank-outline'} size={20} color={colors.primary} />
|
|
<Text style={[styles.rememberText, { color: colors.text, fontFamily: fonts.regular }]}>Remember me</Text>
|
|
</Pressable>
|
|
|
|
<TouchableOpacity onPress={() => showInfo('Forgot password feature coming soon!')}>
|
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Forgot Password ?</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Login button */}
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
onPress={handleLogin}
|
|
disabled={loading || !email.trim() || !password.trim()}
|
|
style={{ marginTop: 12 }}
|
|
>
|
|
<LinearGradient
|
|
colors={loading || !email.trim() || !password.trim()
|
|
? [colors.border, colors.border]
|
|
: ["#3AA0FF", "#2D6BFF"]
|
|
}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={styles.loginButton}
|
|
>
|
|
<Text style={[styles.loginButtonText, { fontFamily: fonts.bold }]}>
|
|
{loading ? 'Logging in...' : 'Log In'}
|
|
</Text>
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
|
|
{/* Or divider */}
|
|
<View style={styles.orContainer}>
|
|
<View style={[styles.orLine, { backgroundColor: colors.border }]} />
|
|
<Text style={[styles.orText, { color: colors.textLight, fontFamily: fonts.regular }]}>Or login with</Text>
|
|
<View style={[styles.orLine, { backgroundColor: colors.border }]} />
|
|
</View>
|
|
|
|
{/* Social buttons */}
|
|
<View style={styles.socialRow}>
|
|
<TouchableOpacity
|
|
style={[styles.socialButton, { borderColor: colors.border }]}
|
|
onPress={() => showInfo('Google login coming soon!')}
|
|
>
|
|
<Icon name="google" size={20} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.socialButton, { borderColor: colors.border }]}
|
|
onPress={() => showInfo('Facebook login coming soon!')}
|
|
>
|
|
<Icon name="facebook" size={20} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.socialButton, { borderColor: colors.border }]}
|
|
onPress={() => showInfo('Apple login coming soon!')}
|
|
>
|
|
<Icon name="apple" size={20} color={colors.text} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Sign up */}
|
|
<View style={styles.signupRow}>
|
|
<Text style={[styles.signupText, { color: colors.textLight, fontFamily: fonts.regular }]}>Don't have an account? </Text>
|
|
<TouchableOpacity onPress={() => navigation.navigate('Signup' as never)}>
|
|
<Text style={[styles.link, { color: colors.primary, fontFamily: fonts.medium }]}>Sign up</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* API Error */}
|
|
{!!error && (
|
|
<View style={styles.errorContainer}>
|
|
<Icon name="alert-circle-outline" size={16} color={colors.error} />
|
|
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.regular }]}>
|
|
{error}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</GradientBackground>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
gradient: {
|
|
flex: 1,
|
|
},
|
|
container: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 16,
|
|
},
|
|
card: {
|
|
width: '100%',
|
|
maxWidth: 380,
|
|
borderRadius: 16,
|
|
padding: 16,
|
|
borderWidth: 1,
|
|
},
|
|
logoCircle: {
|
|
alignSelf: 'center',
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 8,
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
textAlign: 'center',
|
|
},
|
|
subtitle: {
|
|
textAlign: 'center',
|
|
marginTop: 4,
|
|
marginBottom: 12,
|
|
},
|
|
inputWrapper: {
|
|
marginTop: 12,
|
|
borderWidth: 1,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 12,
|
|
height: 52,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
input: {
|
|
paddingVertical: 0,
|
|
marginLeft: 8,
|
|
flex: 1,
|
|
},
|
|
iconButton: {
|
|
position: 'absolute',
|
|
right: 8,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 36,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
rowBetween: {
|
|
marginTop: 12,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
rememberText: {
|
|
marginLeft: 6,
|
|
},
|
|
link: {},
|
|
loginButton: {
|
|
height: 48,
|
|
borderRadius: 8,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
loginButtonText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 16,
|
|
},
|
|
orContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginVertical: 16,
|
|
},
|
|
orLine: {
|
|
flex: 1,
|
|
height: 1,
|
|
},
|
|
orText: {
|
|
marginHorizontal: 8,
|
|
fontSize: 12,
|
|
},
|
|
socialRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
socialButton: {
|
|
width: 52,
|
|
height: 44,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginHorizontal: 6,
|
|
flex: 1,
|
|
},
|
|
signupRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
marginTop: 16,
|
|
},
|
|
signupText: {
|
|
fontSize: 12,
|
|
},
|
|
errorContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: 12,
|
|
paddingHorizontal: 8,
|
|
},
|
|
errorText: {
|
|
marginLeft: 6,
|
|
fontSize: 14,
|
|
textAlign: 'left',
|
|
flex: 1,
|
|
},
|
|
});
|
|
|
|
export default LoginScreen; |