feat: add expo mobile application source code

This commit is contained in:
2026-05-21 16:06:35 +07:00
parent 76d7a5c5c6
commit 0c65a7811b
77 changed files with 20356 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import { Stack } from 'expo-router';
import { useAppTheme } from '../../context/ThemeContext';
export default function AuthLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: 'fade',
}}
/>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import {
View, Text, StyleSheet,
TouchableOpacity, ActivityIndicator, Platform
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Image } from 'expo-image';
import { Feather } from '@expo/vector-icons';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
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 { AIInput, AIButton, AppScreen } from '../../components/UI';
export default function ForgotPasswordScreen() {
const router = useRouter();
const { showToast } = useToast();
const { colors, isDark } = useAppTheme();
const { config } = useAppConfig();
const { t } = useTranslation();
const { values, handleChange } = useForm({ email: '' });
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleReset = async () => {
if (!values.email.includes('@')) {
showToast(t('invalidEmail'), 'error');
return;
}
setLoading(true);
try {
// Technical Note: This calls the real API from ApiService if implemented,
// but here we keep the simulation logic as requested for demo stability.
await new Promise(res => setTimeout(res, 2000));
setSent(true);
showToast(t('emailSent'), 'success');
} catch (err) {
showToast(t('sendFailed'), 'error');
} finally {
setLoading(false);
}
};
const cardBg = colors.surface;
const border = colors.border;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<SafeAreaView style={{ flex: 1 }}>
{/* Back Button */}
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Feather name="arrow-left" size={24} color={colors.text} />
</TouchableOpacity>
<KeyboardAwareScrollView
enableOnAndroid
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
showsVerticalScrollIndicator={false}
style={styles.scroll}
>
{/* Header */}
<View style={styles.header}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
{config?.branding?.logo_url ? (
<Image source={{ uri: config.branding.logo_url }} style={{ width: 56, height: 56 }} contentFit="contain" />
) : (
<Feather name="lock" size={40} color={colors.primary} />
)}
</View>
<Text style={[styles.title, { color: colors.text }]}>{t('resetPass')}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t('resetSubtitle')} {config?.branding?.app_name || 'biiproject'}.
</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
{sent ? (
<View style={styles.successBox}>
<View style={[styles.successIcon, { backgroundColor: `${colors.primary}20` }]}>
<Feather name="check-circle" size={44} color={colors.primary} />
</View>
<Text style={[styles.successTitle, { color: colors.text }]}>{t('emailSentTitle')}</Text>
<Text style={[styles.successDesc, { color: colors.textSecondary }]}>
{t('emailSentDesc')}
</Text>
<AIButton
title={t('backToSignIn')}
onPress={() => router.back()}
style={{ width: '100%' }}
/>
</View>
) : (
<>
<AIInput
label={t('email')}
icon="mail"
placeholder="email@example.com"
value={values.email}
onChangeText={(v: string) => handleChange('email', v)}
keyboardType="email-address"
/>
<AIButton
title={t('sendInstructions')}
onPress={handleReset}
loading={loading}
style={{ marginTop: 12 }}
/>
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}>
<Text style={[styles.cancelText, { color: colors.textSecondary }]}>{t('cancel')}</Text>
</TouchableOpacity>
</>
)}
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
backBtn: { padding: 20, position: 'absolute', top: 0, left: 0, zIndex: 10 },
scroll: { paddingHorizontal: 24 },
header: { alignItems: 'center', marginBottom: 36 },
iconWrap: { width: 100, height: 100, borderRadius: 32, alignItems: 'center', justifyContent: 'center', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10 },
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 24 },
subtitle: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginTop: 10, paddingHorizontal: 20, lineHeight: 24 },
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
cancelBtn: { alignItems: 'center', marginTop: 20 },
cancelText: { fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
successBox: { alignItems: 'center', paddingVertical: 10 },
successIcon: { width: 88, height: 88, borderRadius: 44, alignItems: 'center', justifyContent: 'center', marginBottom: 24 },
successTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 12 },
successDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 32, lineHeight: 24 },
});
+231
View File
@@ -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' },
});
+163
View File
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import {
View, Text, StyleSheet, Platform,
TouchableOpacity, TextInput, ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Feather } 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 { AppScreen } from '../../components/AppScreen';
import { AIButton, AIInput } from '../../components/UI';
import { Image } from 'expo-image';
import { useTranslation } from '../../context/LanguageContext';
export default function RegisterScreen() {
const router = useRouter();
const { signUp, isLoading } = useAuth();
const { showToast } = useToast();
const { colors, isDark } = useAppTheme();
const { config } = useAppConfig();
const { t } = useTranslation();
const { values, handleChange } = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const handleRegister = async () => {
if (!values.name || !values.email || !values.password) {
showToast(t('fillAll'), 'error');
return;
}
if (values.password !== values.password_confirmation) {
showToast(t('passMismatch'), 'error');
return;
}
try {
await signUp(values.name, values.email, values.password);
showToast(t('accountCreated'), 'success');
router.replace('/(tabs)');
} catch (error: any) {
showToast(error.message || t('regFailed'), 'error');
}
};
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const inputBg = isDark ? '#222222' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<AppScreen scrollable={true}>
<View style={styles.scroll}>
{/* Header */}
<View style={styles.header}>
<View style={[styles.logoBox, { backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF' }]}>
{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 }]}>{t('createAccount')}</Text>
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
{t('join')} {config?.branding?.app_name || 'biiproject'} ecosystem
</Text>
</View>
{/* Form card */}
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
<AIInput
label={t('fullName')}
icon="user"
placeholder={t('namePlaceholder')}
value={values.name}
onChangeText={v => handleChange('name', v)}
/>
<AIInput
label={t('email')}
icon="mail"
placeholder={t('emailPlaceholder')}
value={values.email}
onChangeText={v => handleChange('email', v)}
autoCapitalize="none"
keyboardType="email-address"
containerStyle={{ marginTop: 14 }}
/>
<AIInput
label={t('password')}
icon="lock"
placeholder={t('passwordPlaceholder')}
value={values.password}
onChangeText={v => handleChange('password', v)}
isPassword
containerStyle={{ marginTop: 14 }}
/>
<AIInput
label={t('confirmPassword')}
icon="check-circle"
placeholder={t('passwordPlaceholder')}
value={values.password_confirmation}
onChangeText={v => handleChange('password_confirmation', v)}
isPassword
containerStyle={{ marginTop: 14 }}
/>
<AIButton
title={t('signUp')}
onPress={handleRegister}
loading={isLoading}
style={{ marginTop: 24 }}
/>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('haveAccount')}</Text>
<TouchableOpacity onPress={() => router.push('/(auth)/login')}>
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signIn')}</Text>
</TouchableOpacity>
</View>
</View>
<View style={{ height: 40 }} />
</View>
</AppScreen>
);
}
const styles = StyleSheet.create({
scroll: { paddingHorizontal: 24, paddingTop: 40 },
header: { alignItems: 'center', marginBottom: 28 },
logoBox: {
width: 72, height: 72, borderRadius: 24,
alignItems: 'center', justifyContent: 'center',
elevation: 8, shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12,
},
logoLetter: { fontSize: 40, fontFamily: 'Outfit_800ExtraBold' },
brandName: { fontSize: 28, fontFamily: 'Outfit_800ExtraBold', marginTop: 14 },
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
inputGroup: {},
label: { fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' },
inputRow: {
flexDirection: 'row', alignItems: 'center',
height: 54, borderRadius: 14, borderWidth: 1, paddingHorizontal: 14,
},
inputIcon: { marginRight: 10 },
input: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
mainBtn: { height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
btnText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
});