feat: add expo mobile application source code
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Platform,
|
||||
TouchableOpacity, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { storage } from '../../utils/storage';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useToast } from '../../context/ToastContext';
|
||||
import { useForm } from '../../hooks/useForm';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { useAppConfig } from '../../context/ConfigContext';
|
||||
import { useTranslation } from '../../context/LanguageContext';
|
||||
import { Image } from 'expo-image';
|
||||
import { AppScreen } from '../../components/AppScreen';
|
||||
import { AIInput, AIButton } from '../../components/UI';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const { signIn, isLoading } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { config } = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { values, handleChange } = useForm({ email: '', password: '' });
|
||||
const [bioCredentials, setBioCredentials] = useState<{ email: string; pass: string } | null>(null);
|
||||
|
||||
useEffect(() => { checkBiometrics(); }, []);
|
||||
|
||||
const checkBiometrics = async () => {
|
||||
if (Platform.OS === 'web') return;
|
||||
try {
|
||||
const bioEnabled = await storage.get('pref_biometrics');
|
||||
if (bioEnabled === 'true') {
|
||||
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
if (hasHardware && isEnrolled) {
|
||||
const email = await storage.get('saved_email');
|
||||
const pass = await storage.get('saved_pass');
|
||||
if (email && pass) setBioCredentials({ email, pass });
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn(e); }
|
||||
};
|
||||
|
||||
const handleBiometricLogin = async () => {
|
||||
if (!bioCredentials) return;
|
||||
try {
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: `${t('bioConfirm')} - ${config?.branding?.app_name || 'biiproject'}`,
|
||||
fallbackLabel: t('password'),
|
||||
});
|
||||
if (result.success) {
|
||||
await signIn(bioCredentials.email, bioCredentials.pass);
|
||||
showToast(t('bioSuccess'), 'success');
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
} catch { showToast(t('bioFailed'), 'error'); }
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!values.email.includes('@')) { showToast(t('invalidEmail'), 'error'); return; }
|
||||
try {
|
||||
await signIn(values.email, values.password);
|
||||
const bioEnabled = await storage.get('pref_biometrics');
|
||||
if (bioEnabled === 'true') {
|
||||
await storage.save('saved_email', values.email);
|
||||
await storage.save('saved_pass', values.password);
|
||||
}
|
||||
showToast(`${t('welcomeBack')} ${config?.branding?.app_name || 'biiproject'}`, 'success');
|
||||
router.replace('/(tabs)');
|
||||
} catch (error: any) {
|
||||
showToast(error.message || t('loginFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen scrollable={true}>
|
||||
<View style={styles.scroll}>
|
||||
{/* ── Header / Brand ── */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.logoBox, { backgroundColor: colors.surface }]}>
|
||||
{config?.branding?.logo_url ? (
|
||||
<Image source={{ uri: config.branding.logo_url }} style={{ width: 52, height: 52 }} contentFit="contain" />
|
||||
) : (
|
||||
<Text style={[styles.logoLetter, { color: colors.primary }]}>B</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: colors.text }]}>
|
||||
{config?.security_auth?.login_title || 'biiproject'}
|
||||
</Text>
|
||||
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
|
||||
{config?.security_auth?.login_subtitle || t('registerSubtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ── Login card ── */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('signIn')}</Text>
|
||||
|
||||
{/* Email */}
|
||||
<AIInput
|
||||
label={t('email')}
|
||||
icon="mail"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
value={values.email}
|
||||
onChangeText={(v: string) => handleChange('email', v)}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<AIInput
|
||||
label={t('password')}
|
||||
icon="lock"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
value={values.password}
|
||||
onChangeText={(v: string) => handleChange('password', v)}
|
||||
isPassword
|
||||
/>
|
||||
|
||||
{/* Forgot */}
|
||||
<TouchableOpacity onPress={() => router.push('/(auth)/forgot-password')} style={styles.forgotBtn}>
|
||||
<Text style={[styles.forgotText, { color: colors.primary }]}>{t('forgotPass')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionRow}>
|
||||
<AIButton
|
||||
title={t('signInNow')}
|
||||
onPress={handleLogin}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1, marginRight: bioCredentials ? 12 : 0 }}
|
||||
/>
|
||||
|
||||
{bioCredentials && (
|
||||
<TouchableOpacity
|
||||
onPress={handleBiometricLogin}
|
||||
style={[styles.bioBtn, { backgroundColor: isDark ? colors.surfaceLight : colors.background, borderColor: colors.border }]}
|
||||
>
|
||||
<MaterialCommunityIcons name="fingerprint" size={30} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Social logins */}
|
||||
{(config?.security_auth?.oauth_google_enabled || config?.security_auth?.oauth_apple_enabled) && (
|
||||
<View style={styles.socialSection}>
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textSecondary }]}>{t('orContinueWith')}</Text>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
<View style={styles.socialButtons}>
|
||||
{config?.security_auth?.oauth_google_enabled && (
|
||||
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<MaterialCommunityIcons name="google" size={22} color={isDark ? '#FFF' : '#EA4335'} />
|
||||
<Text style={[styles.socialText, { color: colors.text }]}>{t('google')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{config?.security_auth?.oauth_apple_enabled && (
|
||||
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<MaterialCommunityIcons name="apple" size={22} color={colors.text} />
|
||||
<Text style={[styles.socialText, { color: colors.text }]}>{t('apple')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Register link */}
|
||||
{config?.features?.enable_registration && (
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('noAccount')}</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
|
||||
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signUp')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ height: 40 }} />
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { paddingHorizontal: 24, paddingTop: 40 },
|
||||
|
||||
// Header
|
||||
header: { alignItems: 'center', marginBottom: 28 },
|
||||
logoBox: {
|
||||
width: 80, height: 80, borderRadius: 26,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
elevation: 12, shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 18,
|
||||
borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
logoLetter: { fontSize: 48, fontFamily: 'Outfit_800ExtraBold', lineHeight: 54 },
|
||||
brandName: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 14, letterSpacing: -0.5 },
|
||||
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||
|
||||
// Card
|
||||
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
|
||||
cardTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 22 },
|
||||
|
||||
// Actions
|
||||
forgotBtn: { alignSelf: 'flex-end', marginTop: 12, marginBottom: 4 },
|
||||
forgotText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
||||
actionRow: { flexDirection: 'row', alignItems: 'center', marginTop: 22 },
|
||||
bioBtn: { width: 56, height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
|
||||
|
||||
// Social
|
||||
socialSection: { marginTop: 26 },
|
||||
dividerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
|
||||
divider: { flex: 1, height: 1 },
|
||||
dividerText: { marginHorizontal: 12, fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1 },
|
||||
socialButtons: { flexDirection: 'row', gap: 10 },
|
||||
socialBtn: {
|
||||
flex: 1, height: 52, borderRadius: 14,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: 1,
|
||||
},
|
||||
socialText: { marginLeft: 8, fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
|
||||
|
||||
// Footer
|
||||
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
|
||||
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
|
||||
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
|
||||
});
|
||||
Reference in New Issue
Block a user