feat: add expo mobile application source code
This commit is contained in:
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Platform, View } from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { useAppConfig } from '../../context/ConfigContext';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { config, syncConfig } = useAppConfig();
|
||||
|
||||
// Reference design: dark/charcoal tab bar with lime active, gray inactive
|
||||
const tabBarBg = isDark ? '#111111' : '#FFFFFF';
|
||||
const activeColor = isDark ? '#C6F135' : '#1A1A1A'; // lime on dark, black on light
|
||||
const inactiveColor = isDark ? '#555555' : '#AAAAAA';
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenListeners={{
|
||||
state: () => {
|
||||
syncConfig();
|
||||
},
|
||||
}}
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: activeColor,
|
||||
tabBarInactiveTintColor: inactiveColor,
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
borderTopWidth: 0,
|
||||
backgroundColor: 'transparent',
|
||||
elevation: 0,
|
||||
height: 78,
|
||||
paddingBottom: Platform.OS === 'ios' ? 22 : 12,
|
||||
paddingTop: 10,
|
||||
},
|
||||
tabBarBackground: () => (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
backgroundColor: tabBarBg,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#2A2A2A' : '#EEEEEE',
|
||||
}
|
||||
]}
|
||||
/>
|
||||
),
|
||||
tabBarLabelStyle: {
|
||||
fontFamily: 'Outfit_600SemiBold',
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
},
|
||||
}}>
|
||||
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||
<Feather name="home" size={22} color={color} />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="notifications"
|
||||
options={{
|
||||
title: 'Activity',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||
<Feather name="bell" size={22} color={color} />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="help"
|
||||
options={{
|
||||
title: 'Support',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||
<Feather name="help-circle" size={22} color={color} />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||
<Feather name="user" size={22} color={color} />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
activeIconWrap: {
|
||||
width: 42,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity,
|
||||
Image, Switch, Platform, ScrollView
|
||||
} from 'react-native';
|
||||
import { storage } from '../../utils/storage';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { useToast } from '../../context/ToastContext';
|
||||
import { useAppConfig } from '../../context/ConfigContext';
|
||||
import { AppScreen } from '../../components/AppScreen';
|
||||
import { AIButton, AIInput, AISectionHeader, AIPressable, AISkeleton } from '../../components/UI';
|
||||
import { ApiService } from '../../services/api';
|
||||
import { Popup } from '../../components/Popup';
|
||||
import { DebugLogger } from '../../utils/logger';
|
||||
import { AISuccess } from '../../components/UI';
|
||||
import { useTranslation } from '../../context/LanguageContext';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
|
||||
import { ActionTracker } from '../../utils/actionTracker';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, signOut, syncUser } = useAuth();
|
||||
const { colors, isDark, setMode } = useAppTheme();
|
||||
const { showToast } = useToast();
|
||||
const { config } = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [logoutConfirmVisible, setLogoutConfirmVisible] = useState(false);
|
||||
const [tempName, setTempName] = useState(user?.name || config?.branding?.app_name || 'User');
|
||||
const [tempAvatar, setTempAvatar] = useState(user?.avatar || config?.branding?.logo_url || `https://i.pravatar.cc/150?u=1`);
|
||||
|
||||
const [debugClicks, setDebugClicks] = useState(0);
|
||||
const [logsModalVisible, setLogsModalVisible] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Track engagement for review prompt
|
||||
ActionTracker.trackAction(
|
||||
config?.features?.min_actions_before_review,
|
||||
config?.features?.review_prompt_enabled
|
||||
);
|
||||
|
||||
if (user) {
|
||||
setTempName(user.name);
|
||||
if (user.avatar) {
|
||||
// Append timestamp to remote URL to bypass cache
|
||||
const cacheBuster = user.avatar.includes('?') ? `&t=${Date.now()}` : `?t=${Date.now()}`;
|
||||
setTempAvatar(`${user.avatar}${cacheBuster}`);
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => setLoading(false), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [user]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !isDark;
|
||||
setMode(next ? 'dark' : 'light');
|
||||
showToast(`${next ? 'Dark' : 'Light'} mode active`, 'info');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setLogoutConfirmVisible(false);
|
||||
showToast(t('logoutSafe'), 'info');
|
||||
setTimeout(signOut, 1000);
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
try {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
showToast('Permission to access gallery is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await ImagePicker.launchImageLibraryAsync({
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.4,
|
||||
});
|
||||
|
||||
if (!res.canceled && res.assets[0].uri) {
|
||||
setLoading(true);
|
||||
showToast(t('uploadingAvatar'), 'info');
|
||||
|
||||
await ApiService.updateAvatar(res.assets[0].uri);
|
||||
await syncUser(); // Refresh global auth state
|
||||
|
||||
if (res.assets[0].uri) setTempAvatar(res.assets[0].uri);
|
||||
showToast(t('avatarUpdated'), 'success');
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || t('uploadFailed') || 'Upload failed';
|
||||
showToast(`Error: ${errorMsg}`, 'error');
|
||||
console.error('[AvatarUpload]', error);
|
||||
DebugLogger.log(`Avatar upload error: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
if (!tempName.trim()) {
|
||||
showToast('Name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await ApiService.updateProfile(tempName, user?.email || '');
|
||||
await syncUser(); // Refresh global data
|
||||
setUpdateSuccess(true);
|
||||
showToast(t('profileUpdated'), 'success');
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (error: any) {
|
||||
showToast(error.message || t('updateFailed') || 'Update failed', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cardBg = colors.surface;
|
||||
const border = colors.border;
|
||||
const subText = colors.textSecondary;
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<View style={{ paddingHorizontal: 24, paddingTop: 56, alignItems: 'center' }}>
|
||||
<AISkeleton width={100} height={100} radius={50} style={{ marginBottom: 20 }} />
|
||||
<AISkeleton width={180} height={24} style={{ marginBottom: 10 }} />
|
||||
<AISkeleton width={140} height={14} style={{ marginBottom: 32 }} />
|
||||
<AISkeleton width="100%" height={240} radius={24} />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<View>
|
||||
{/* ── Profile header ── */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.avatarWrap}>
|
||||
<Image source={{ uri: tempAvatar }} style={[styles.avatar, { borderColor: border }]} />
|
||||
<TouchableOpacity
|
||||
style={[styles.camBtn, { backgroundColor: isDark ? colors.primary : '#1A1A1A' }]}
|
||||
onPress={handlePickImage}
|
||||
>
|
||||
<Feather name="camera" size={14} color={isDark ? colors.secondary : colors.background} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{tempName}</Text>
|
||||
<Text style={[styles.email, { color: subText }]}>
|
||||
{user?.email || `user@${config?.branding?.app_name || 'biiproject'}.com`}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.editPill, { backgroundColor: cardBg, borderColor: border }]}
|
||||
onPress={() => { setUpdateSuccess(false); setEditModalVisible(true); }}
|
||||
>
|
||||
<Feather name="edit-2" size={14} color={colors.text} />
|
||||
<Text style={[styles.editPillText, { color: colors.text }]}>{t('editProfile')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Settings section ── */}
|
||||
<AISectionHeader title={t('preferences')} />
|
||||
<View style={[styles.menuCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
<BiometricToggle t={t} />
|
||||
<MenuRow
|
||||
icon="moon"
|
||||
label={t('darkTheme')}
|
||||
rightContent={
|
||||
<Switch
|
||||
value={isDark}
|
||||
onValueChange={toggleTheme}
|
||||
trackColor={{ true: colors.primary, false: colors.border }}
|
||||
thumbColor={colors.secondary}
|
||||
/>
|
||||
}
|
||||
border={border}
|
||||
/>
|
||||
<MenuRow
|
||||
icon="file-text"
|
||||
label={t.privacyLink || "Privacy Policy"}
|
||||
onPress={() => {
|
||||
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/privacy';
|
||||
require('react-native').Linking.openURL(url);
|
||||
}}
|
||||
border={border}
|
||||
/>
|
||||
<MenuRow
|
||||
icon="shield"
|
||||
label={t.termsLink || "Terms of Service"}
|
||||
onPress={() => {
|
||||
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/terms';
|
||||
require('react-native').Linking.openURL(url);
|
||||
}}
|
||||
border={border}
|
||||
isLast
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ── Logout ── */}
|
||||
<AIPressable onPress={() => setLogoutConfirmVisible(true)} style={styles.logoutPressable}>
|
||||
<View style={[styles.logoutBtn, { borderColor: colors.error }]}>
|
||||
<Feather name="log-out" size={18} color={colors.error} />
|
||||
<Text style={[styles.logoutText, { color: colors.error }]}>{t('logout')}</Text>
|
||||
</View>
|
||||
</AIPressable>
|
||||
|
||||
{/* ── App Version (Hidden Debug Trigger) ── */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
const next = debugClicks + 1;
|
||||
if (next >= 5) {
|
||||
setLogs(DebugLogger.getLogs());
|
||||
setLogsModalVisible(true);
|
||||
setDebugClicks(0);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
setDebugClicks(next);
|
||||
}
|
||||
}}
|
||||
style={styles.versionContainer}
|
||||
>
|
||||
<Text style={[styles.versionText, { color: subText }]}>
|
||||
Version {config?.app_updates?.app_version || '2.0.0'} (Build 102)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 110 }} />
|
||||
</View>
|
||||
|
||||
{/* ── Edit Profile Popup ── */}
|
||||
<Popup visible={editModalVisible} onClose={() => { setEditModalVisible(false); setUpdateSuccess(false); }} title={t('editProfile')} type="bottom">
|
||||
<View style={styles.popupBody}>
|
||||
{updateSuccess ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 20 }}>
|
||||
<AISuccess size={100} />
|
||||
<Text style={[styles.successText, { color: colors.text }]}>{t('profileUpdated')}</Text>
|
||||
<AIButton title={t('close') || "Great!"} onPress={() => { setEditModalVisible(false); setUpdateSuccess(false); }} style={{ width: '100%', marginTop: 20 }} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<AIInput label={t('fullName')} value={tempName} onChangeText={setTempName} icon="account-outline" />
|
||||
<AIButton
|
||||
title={t('confirmChanges') || "Save Changes"}
|
||||
onPress={handleUpdateProfile}
|
||||
loading={loading}
|
||||
style={{ marginTop: 10 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Popup>
|
||||
|
||||
{/* ── Debug Logs Popup ── */}
|
||||
<Popup visible={logsModalVisible} onClose={() => setLogsModalVisible(false)} title="System Logs" type="bottom">
|
||||
<ScrollView style={{ maxHeight: 400 }}>
|
||||
{logs.length === 0 ? (
|
||||
<Text style={{ textAlign: 'center', padding: 20, color: '#888' }}>No logs recorded yet.</Text>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<View key={i} style={[styles.logRow, { borderBottomColor: border }]}>
|
||||
<Text style={[styles.logText, { color: colors.text }]}>{log}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<AIButton
|
||||
title="Clear Logs"
|
||||
color={colors.error}
|
||||
onPress={() => { DebugLogger.clear(); setLogs([]); }}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Popup>
|
||||
|
||||
{/* ── Logout Confirm Popup ── */}
|
||||
<Popup visible={logoutConfirmVisible} onClose={() => setLogoutConfirmVisible(false)} title={t('logout')} type="center">
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<View style={[styles.logoutIcon, { backgroundColor: `${colors.error}20` }]}>
|
||||
<Feather name="log-out" size={36} color={colors.error} />
|
||||
</View>
|
||||
<Text style={[styles.confirmDesc, { color: subText }]}>{t('confirmLogout')}</Text>
|
||||
<View style={styles.confirmRow}>
|
||||
<AIButton title={t('cancel')} color={colors.border} onPress={() => setLogoutConfirmVisible(false)} style={{ flex: 1 }} textStyle={{ color: colors.text }} />
|
||||
<AIButton title={t('logout')} color={colors.error} onPress={handleLogout} style={{ flex: 1 }} />
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
function BiometricToggle({ t }: { t: any }) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { showToast } = useToast();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
storage.get('pref_biometrics').then(v => setEnabled(v === 'true'));
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
if (Platform.OS === 'web') {
|
||||
showToast('Biometrics not available in browser', 'info');
|
||||
return;
|
||||
}
|
||||
const res = await LocalAuthentication.authenticateAsync({ promptMessage: 'Verify identity' });
|
||||
if (res.success) {
|
||||
const next = !enabled;
|
||||
setEnabled(next);
|
||||
await storage.save('pref_biometrics', next ? 'true' : 'false');
|
||||
showToast(`Biometrics ${next ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const border = colors.border;
|
||||
return (
|
||||
<MenuRow
|
||||
icon="shield"
|
||||
label={t.biometrics || "Biometric Login"}
|
||||
rightContent={
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={toggle}
|
||||
trackColor={{ true: colors.primary, false: '#333' }}
|
||||
thumbColor={enabled ? '#FFFFFF' : '#FFFFFF'}
|
||||
/>
|
||||
}
|
||||
border={border}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRow({ icon, label, rightContent, onPress, border, isLast }: any) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const Wrapper: any = onPress ? TouchableOpacity : View;
|
||||
return (
|
||||
<Wrapper onPress={onPress} style={[styles.menuRow, { borderBottomColor: border, borderBottomWidth: isLast ? 0 : 1 }]}>
|
||||
<View style={styles.menuLeft}>
|
||||
<View style={[styles.menuIconBox, { backgroundColor: colors.background }]}>
|
||||
<Feather name={icon} size={16} color={isDark ? colors.primary : colors.secondary} />
|
||||
</View>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>{label}</Text>
|
||||
</View>
|
||||
{rightContent || <Feather name="chevron-right" size={18} color={colors.textPlaceholder} />}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { alignItems: 'center', paddingTop: 20, paddingBottom: 28, paddingHorizontal: 24 },
|
||||
avatarWrap: { position: 'relative', marginBottom: 16 },
|
||||
avatar: { width: 100, height: 100, borderRadius: 50, borderWidth: 3 },
|
||||
camBtn: { position: 'absolute', bottom: 4, right: 4, width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', elevation: 4 },
|
||||
name: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||
email: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||
editPill: { flexDirection: 'row', alignItems: 'center', marginTop: 20, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 14, borderWidth: 1, gap: 8 },
|
||||
editPillText: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||
|
||||
menuCard: { marginHorizontal: 24, borderRadius: 24, borderWidth: 1, overflow: 'hidden', marginBottom: 16 },
|
||||
menuRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16 },
|
||||
menuLeft: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||
menuIconBox: { width: 36, height: 36, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
||||
menuLabel: { fontSize: 15, fontFamily: 'Outfit_600SemiBold' },
|
||||
|
||||
logoutPressable: { marginHorizontal: 24, marginTop: 12 },
|
||||
logoutBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, borderRadius: 20, borderWidth: 1.5, borderStyle: 'dashed', gap: 10 },
|
||||
logoutText: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
|
||||
|
||||
popupBody: { paddingTop: 10 },
|
||||
logoutIcon: { width: 80, height: 80, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
|
||||
confirmDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 28 },
|
||||
confirmRow: { flexDirection: 'row', gap: 12, width: '100%' },
|
||||
successText: { fontSize: 18, fontFamily: 'Outfit_700Bold', marginTop: 12 },
|
||||
versionContainer: { alignItems: 'center', marginTop: 30, paddingVertical: 10 },
|
||||
versionText: { fontSize: 11, fontFamily: 'Outfit_500Medium', opacity: 0.6 },
|
||||
logRow: { paddingVertical: 10, borderBottomWidth: 1 },
|
||||
logText: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Platform } from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { useToast } from '../../context/ToastContext';
|
||||
import { useAppConfig } from '../../context/ConfigContext';
|
||||
import { AppScreen } from '../../components/AppScreen';
|
||||
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||
import { useTranslation } from '../../context/LanguageContext';
|
||||
import { MOCK_FAQS } from '../../constants/mocks';
|
||||
import { PALETTE } from '../../constants/theme';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
const getTopics = (t: any) => [
|
||||
{ id: '1', name: t.web || 'Web', icon: 'book-open' },
|
||||
{ id: '2', name: t.account || 'Account', icon: 'user' },
|
||||
{ id: '3', name: t.billing || 'Billing', icon: 'credit-card' },
|
||||
{ id: '4', name: t.system || 'System', icon: 'cpu' },
|
||||
];
|
||||
|
||||
// Mock data moved to constants/mocks.ts
|
||||
|
||||
export default function HelpScreen() {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { showToast } = useToast();
|
||||
const { config } = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const topics = useMemo(() => {
|
||||
if (config?.support_social?.help_topics_json && Array.isArray(config.support_social.help_topics_json)) {
|
||||
return config.support_social.help_topics_json;
|
||||
}
|
||||
return getTopics(t);
|
||||
}, [config?.support_social?.help_topics_json, t]);
|
||||
|
||||
const faqs = useMemo(() => {
|
||||
if (config?.support_social?.faq_json && Array.isArray(config.support_social.faq_json)) {
|
||||
return config.support_social.faq_json.map((f, i) => ({ id: String(i+1), ...f }));
|
||||
}
|
||||
return MOCK_FAQS;
|
||||
}, [config?.support_social?.faq_json]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleContactSupport = (type: 'whatsapp' | 'email') => {
|
||||
const contact = type === 'whatsapp' ? config?.support_social?.support_whatsapp : config?.support_social?.support_email;
|
||||
if (contact) {
|
||||
const url = type === 'whatsapp' ? `https://wa.me/${contact}` : `mailto:${contact}`;
|
||||
require('react-native').Linking.openURL(url).catch(() => {
|
||||
showToast(`Failed to open ${type}`, 'error');
|
||||
});
|
||||
} else {
|
||||
showToast('Support contact not available', 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const cardBg = colors.surface;
|
||||
const border = colors.border;
|
||||
const subText = colors.textSecondary;
|
||||
|
||||
const renderTopic = (topic: any) => {
|
||||
return (
|
||||
<AIPressable
|
||||
key={topic.id}
|
||||
onPress={() => showToast(`Opening ${topic.name} topics`, 'info')}
|
||||
style={styles.topicWrapper}
|
||||
containerStyle={{ flex: 1 }}
|
||||
>
|
||||
<View style={[styles.topicCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
<Feather name={topic.icon as any} size={24} color={isDark ? colors.primary : colors.secondary} />
|
||||
<Text style={[styles.topicName, { color: colors.text }]} numberOfLines={1}>{topic.name}</Text>
|
||||
</View>
|
||||
</AIPressable>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
|
||||
<AISkeleton width={220} height={32} style={{ marginBottom: 12 }} />
|
||||
<AISkeleton width={160} height={14} style={{ marginBottom: 32 }} />
|
||||
<AISkeleton width="100%" height={56} radius={14} style={{ marginBottom: 32 }} />
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
{[1, 2, 3, 4].map(i => <AISkeleton key={i} width="22%" height={90} radius={20} />)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<View>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.supportCenter || 'Support Center'}</Text>
|
||||
<Text style={[styles.subtitle, { color: subText }]}>
|
||||
{t.helpSubtitle || 'Find answers'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<View style={styles.searchSection}>
|
||||
<View style={[styles.searchBox, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
<Feather name="search" size={18} color={colors.textPlaceholder} />
|
||||
<TextInput
|
||||
placeholder={t.searchDoc || "Search documentation..."}
|
||||
placeholderTextColor={isDark ? '#444' : '#BBBBBB'}
|
||||
style={[styles.searchInput, { color: colors.text }]}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Topics Grid: 2 Columns FULL */}
|
||||
<AISectionHeader title={t.browseTopics || "Browse Topics"} />
|
||||
<View style={styles.topicGrid}>
|
||||
<View style={styles.topicRow}>
|
||||
{renderTopic(topics[0])}
|
||||
{renderTopic(topics[1])}
|
||||
</View>
|
||||
<View style={styles.topicRow}>
|
||||
{renderTopic(topics[2])}
|
||||
{renderTopic(topics[3])}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* FAQs */}
|
||||
<AISectionHeader title={t.faqTitle || "Frequently Asked Questions (FAQ)"} />
|
||||
<View style={styles.faqList}>
|
||||
{faqs.map((faq: any) => (
|
||||
<AIPressable key={faq.id} style={[styles.faqCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
<View style={styles.faqRow}>
|
||||
<View style={styles.faqIconBox}>
|
||||
<Feather name="help-circle" size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.question, { color: colors.text }]}>{faq.q}</Text>
|
||||
<Text style={[styles.answer, { color: subText }]}>{faq.a}</Text>
|
||||
</View>
|
||||
<Feather name="chevron-right" size={16} color={subText} />
|
||||
</View>
|
||||
</AIPressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Contact Footer */}
|
||||
<View style={styles.footerRow}>
|
||||
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('whatsapp')}>
|
||||
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
|
||||
<View style={[styles.contactIcon, { backgroundColor: '#25D36620' }]}>
|
||||
<Feather name="message-circle" size={20} color="#25D366" />
|
||||
</View>
|
||||
<Text style={styles.contactLabel}>{t.whatsapp || 'WhatsApp'}</Text>
|
||||
</View>
|
||||
</AIPressable>
|
||||
|
||||
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('email')}>
|
||||
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
|
||||
<View style={[styles.contactIcon, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Feather name="mail" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.contactLabel}>{t.emailSupport || 'Email Support'}</Text>
|
||||
</View>
|
||||
</AIPressable>
|
||||
</View>
|
||||
|
||||
<View style={{ height: 110 }} />
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 20 },
|
||||
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||
|
||||
searchSection: { paddingHorizontal: 24, marginBottom: 28 },
|
||||
searchBox: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 16, borderWidth: 1, paddingHorizontal: 16, gap: 12 },
|
||||
searchInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
|
||||
|
||||
topicGrid: { paddingHorizontal: 24, gap: 12, marginBottom: 24 },
|
||||
topicRow: { flexDirection: 'row', gap: 12 },
|
||||
topicWrapper: { flex: 1 },
|
||||
topicCard: { height: 100, borderRadius: 20, borderWidth: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
|
||||
topicName: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||
|
||||
faqList: { paddingHorizontal: 24, gap: 12 },
|
||||
faqCard: { borderRadius: 24, borderWidth: 1, padding: 16 },
|
||||
faqRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||
faqIconBox: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#C6F13520', alignItems: 'center', justifyContent: 'center' },
|
||||
question: { fontSize: 14, fontFamily: 'Outfit_700Bold', marginBottom: 2 },
|
||||
answer: { fontSize: 12, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
|
||||
|
||||
footerRow: { flexDirection: 'row', paddingHorizontal: 24, gap: 12, marginTop: 32 },
|
||||
supportBtn: { flex: 1 },
|
||||
contactCard: { height: 120, borderRadius: 24, alignItems: 'center', justifyContent: 'center', gap: 12 },
|
||||
contactIcon: { width: 44, height: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
|
||||
contactLabel: { color: '#FFFFFF', fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, Image,
|
||||
FlatList, Platform, StatusBar, Dimensions
|
||||
} from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { useToast } from '../../context/ToastContext';
|
||||
import { AppScreen } from '../../components/AppScreen';
|
||||
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||
import { useTranslation } from '../../context/LanguageContext';
|
||||
import { useAppConfig } from '../../context/ConfigContext';
|
||||
import { MOCK_ARTICLES } from '../../constants/mocks';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
|
||||
const getQuickActions = (t: any) => {
|
||||
return [
|
||||
{ id: '1', name: t('account') || 'Account', icon: 'user', dark: true },
|
||||
{ id: '2', name: t('subscription') || 'Subscription', icon: 'credit-card', dark: false },
|
||||
{ id: '3', name: t('system') || 'System', icon: 'cpu', dark: true },
|
||||
{ id: '4', name: t('explore') || 'Explore', icon: 'compass', dark: false },
|
||||
];
|
||||
};
|
||||
|
||||
const getCategories = (t: any) => [
|
||||
{ id: '1', name: t('all') || 'All' },
|
||||
{ id: '2', name: 'LLM' },
|
||||
{ id: '3', name: 'Robotics' },
|
||||
{ id: '4', name: 'Health' },
|
||||
{ id: '5', name: 'Coding' },
|
||||
];
|
||||
|
||||
// Mock data moved to constants/mocks.ts
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const router = useRouter();
|
||||
|
||||
const quickActions = useMemo(() => getQuickActions(t), [t]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (config?.features?.dashboard_categories) {
|
||||
return config.features.dashboard_categories.split(',').map((name, index) => ({
|
||||
id: String(index + 1),
|
||||
name: name.trim()
|
||||
}));
|
||||
}
|
||||
return getCategories(t);
|
||||
}, [config?.features?.dashboard_categories, t]);
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState(t('all') || 'All');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleAction = (name: string) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
showToast(`Opening ${name}`, 'info');
|
||||
};
|
||||
|
||||
const filteredArticles = useMemo(
|
||||
() => MOCK_ARTICLES.filter(a => activeCategory === 'All' || a.category === activeCategory),
|
||||
[activeCategory]
|
||||
);
|
||||
|
||||
const cardBg = colors.surface;
|
||||
const cardBorder = colors.border;
|
||||
const subText = colors.textSecondary;
|
||||
|
||||
const renderHeader = () => (
|
||||
<View style={styles.headerContent}>
|
||||
{/* ── Greeting row ── */}
|
||||
<View style={styles.headerTop}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.greeting, { color: colors.textSecondary }]}>{t('halo') || 'Good morning'} 👋</Text>
|
||||
<Text style={[styles.welcomeText, { color: colors.text }]}>{(user?.name || 'Alex').split(' ')[0]}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => router.push('/(tabs)/explore')}>
|
||||
<Image
|
||||
source={{ uri: user?.avatar || `https://i.pravatar.cc/150?u=1` }}
|
||||
style={[styles.avatar, { borderColor: colors.primary }]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Highlight card ── */}
|
||||
<AIPressable onPress={() => handleAction(t('getHelp') || 'Support')} style={styles.highlightPressable}>
|
||||
<View style={[styles.highlightCard, { backgroundColor: '#1A1A1A' }]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.highlightLabel}>{t('systemSupport') || 'System Support'}</Text>
|
||||
<Text style={styles.highlightValue}>{t('instantHelp') || 'Instant Help 24/7'}</Text>
|
||||
<View style={[styles.limeBtn, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.limeBtnText}>{t('getHelp') || 'Get Help'}</Text>
|
||||
<Feather name="arrow-right" size={14} color="#1A1A1A" style={{ marginLeft: 6 }} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.highlightIcon, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Feather name="shield" size={38} color={colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
</AIPressable>
|
||||
|
||||
{/* ── Quick action grid ── */}
|
||||
<AISectionHeader title={t('quickActions') || "Quick Actions"} />
|
||||
<View style={styles.quickGrid}>
|
||||
<View style={styles.actionRow}>
|
||||
{renderAction(quickActions[0], false)}
|
||||
{renderAction(quickActions[1], true)}
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
{renderAction(quickActions[2], true)}
|
||||
{renderAction(quickActions[3], false)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Categories ── */}
|
||||
<AISectionHeader title={t('categories') || "Categories"} />
|
||||
<FlatList
|
||||
data={categories}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoryList}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const isActive = activeCategory === item.name;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.selectionAsync();
|
||||
setActiveCategory(item.name);
|
||||
}}
|
||||
style={[
|
||||
styles.categoryPill,
|
||||
{
|
||||
backgroundColor: isActive ? (isDark ? colors.primary : '#1A1A1A') : (isDark ? '#2A2A2A' : '#FFFFFF'),
|
||||
borderColor: isActive ? 'transparent' : (isDark ? '#3A3A3C' : '#EEEEEE'),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.categoryText, { color: isActive ? (isDark ? '#1A1A1A' : '#FFFFFF') : (isDark ? '#9B9B9B' : '#6B6B6B') }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<AISectionHeader title={t('latestDiscoveries') || "Latest Discoveries"} style={{ marginTop: 20 }} />
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderAction = (item: any, isDarkCard: boolean) => {
|
||||
const bg = isDarkCard ? (isDark ? '#2A2A2A' : '#1A1A1A') : colors.primary;
|
||||
const iconColor = isDarkCard ? (isDark ? colors.primary : '#FFFFFF') : '#1A1A1A';
|
||||
const textColor = isDarkCard ? '#FFFFFF' : '#1A1A1A';
|
||||
|
||||
return (
|
||||
<AIPressable
|
||||
key={item.id}
|
||||
onPress={() => handleAction(item.name)}
|
||||
style={styles.actionCardWrapper}
|
||||
containerStyle={styles.actionCardInner}
|
||||
>
|
||||
<View style={[styles.innerContent, { backgroundColor: bg, borderColor: isDark ? '#333' : 'transparent' }]}>
|
||||
<Feather name={item.icon} size={24} color={iconColor} />
|
||||
<Text style={[styles.actionName, { color: textColor }]} numberOfLines={1}>{item.name}</Text>
|
||||
</View>
|
||||
</AIPressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppScreen scrollable={false}>
|
||||
{loading ? (
|
||||
<View style={{ padding: 24 }}><AISkeleton width="100%" height={200} radius={24} /></View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredArticles}
|
||||
keyExtractor={item => item.id}
|
||||
ListHeaderComponent={renderHeader}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
renderItem={({ item }) => (
|
||||
<AIPressable
|
||||
onPress={() => router.push({ pathname: '/detail/[id]' as any, params: { ...item, id: item.id } })}
|
||||
style={styles.feedPressable}
|
||||
>
|
||||
<View style={[styles.feedCardInner, { backgroundColor: cardBg, borderColor: cardBorder }]}>
|
||||
<Image source={{ uri: item.img }} style={styles.cardImg} />
|
||||
<View style={{ flex: 1, marginLeft: 14 }}>
|
||||
<View style={[styles.cardCatWrap, { backgroundColor: colors.primary + '20' }]}><Text style={[styles.cardCat, { color: colors.primary }]}>{item.category}</Text></View>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]} numberOfLines={2}>{item.title}</Text>
|
||||
<Text style={[styles.cardAuthor, { color: subText }]}>{item.author}</Text>
|
||||
</View>
|
||||
<Feather name="chevron-right" size={18} color={isDark ? '#444' : '#CCCCCC'} />
|
||||
</View>
|
||||
</AIPressable>
|
||||
)}
|
||||
ListFooterComponent={<View style={{ height: 100 }} />}
|
||||
/>
|
||||
)}
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: { paddingBottom: 20 },
|
||||
headerContent: { paddingTop: 10 },
|
||||
headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 24, marginBottom: 20 },
|
||||
greeting: { fontSize: 13, fontFamily: 'Outfit_400Regular' },
|
||||
welcomeText: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 2 },
|
||||
avatar: { width: 48, height: 48, borderRadius: 24, borderWidth: 2.5 },
|
||||
|
||||
highlightPressable: { marginHorizontal: 24, marginBottom: 24 },
|
||||
highlightCard: { borderRadius: 24, padding: 22, flexDirection: 'row', alignItems: 'center', elevation: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 20 },
|
||||
highlightLabel: { color: '#6B6B6B', fontFamily: 'Outfit_500Medium', fontSize: 11, textTransform: 'uppercase' },
|
||||
highlightValue: { color: '#FFFFFF', fontFamily: 'Outfit_800ExtraBold', fontSize: 22, marginTop: 4, marginBottom: 16 },
|
||||
limeBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12 },
|
||||
limeBtnText: { color: '#1A1A1A', fontFamily: 'Outfit_700Bold', fontSize: 13 },
|
||||
highlightIcon: { width: 68, height: 68, borderRadius: 20, alignItems: 'center', justifyContent: 'center', marginLeft: 16 },
|
||||
|
||||
quickGrid: { paddingHorizontal: 24, gap: 12 },
|
||||
actionRow: { flexDirection: 'row', gap: 12, marginBottom: 12 },
|
||||
actionCardWrapper: { flex: 1 },
|
||||
actionCardInner: { flex: 1 },
|
||||
innerContent: { height: 94, borderRadius: 20, borderWidth: 1, padding: 16, justifyContent: 'space-between' },
|
||||
actionName: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
|
||||
|
||||
categoryList: { paddingHorizontal: 24, paddingBottom: 4 },
|
||||
categoryPill: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 12, marginRight: 10, borderWidth: 1 },
|
||||
categoryText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
||||
|
||||
feedPressable: { marginHorizontal: 24, marginBottom: 10 },
|
||||
feedCardInner: { flexDirection: 'row', padding: 12, alignItems: 'center', borderRadius: 20, borderWidth: 1 },
|
||||
cardImg: { width: 70, height: 70, borderRadius: 14 },
|
||||
cardCatWrap: { alignSelf: 'flex-start', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, marginBottom: 6 },
|
||||
cardCat: { fontSize: 9, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase' },
|
||||
cardTitle: { fontSize: 14, fontFamily: 'Outfit_700Bold', lineHeight: 18 },
|
||||
cardAuthor: { fontSize: 11, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { AppScreen } from '../../components/AppScreen';
|
||||
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||
import { useTranslation } from '../../context/LanguageContext';
|
||||
import { MOCK_NOTIFICATIONS } from '../../constants/mocks';
|
||||
import { PALETTE } from '../../constants/theme';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
const LIME = PALETTE.lime;
|
||||
|
||||
const TYPE_MAP: Record<string, { icon: any; color: string }> = {
|
||||
success: { icon: 'check-circle', color: LIME },
|
||||
info: { icon: 'info', color: '#3B82F6' },
|
||||
warning: { icon: 'alert-circle', color: '#F59E0B' },
|
||||
alert: { icon: 'shield', color: '#EF4444' },
|
||||
update: { icon: 'refresh-cw', color: '#8B5CF6' },
|
||||
};
|
||||
|
||||
// Mock data moved to constants/mocks.ts
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const cardBg = colors.surface;
|
||||
const border = colors.border;
|
||||
const subText = colors.textSecondary;
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
|
||||
<AISkeleton width={140} height={32} style={{ marginBottom: 10 }} />
|
||||
<AISkeleton width={180} height={14} style={{ marginBottom: 32 }} />
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<View key={i} style={{ flexDirection: 'row', marginBottom: 12, gap: 14 }}>
|
||||
<AISkeleton width={48} height={48} radius={14} />
|
||||
<View style={{ flex: 1, justifyContent: 'center' }}>
|
||||
<AISkeleton width="70%" height={14} style={{ marginBottom: 8 }} />
|
||||
<AISkeleton width="40%" height={10} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||
|
||||
return (
|
||||
<AppScreen>
|
||||
<View>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.notifications || 'Activity'}</Text>
|
||||
<Text style={[styles.subtitle, { color: subText }]}>
|
||||
{MOCK_NOTIFICATIONS.length} {t.recentNotifications || 'recent notifications'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<View style={styles.list}>
|
||||
{MOCK_NOTIFICATIONS.map((item, index) => {
|
||||
const meta = TYPE_MAP[item.type] || TYPE_MAP.info;
|
||||
return (
|
||||
<AIPressable
|
||||
key={item.id}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
style={styles.notifPressable}
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
{/* Left: colored icon */}
|
||||
<View style={[styles.iconBox, { backgroundColor: `${meta.color}18` }]}>
|
||||
<Feather name={meta.icon} size={22} color={meta.color} />
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.body}>
|
||||
<View style={styles.topRow}>
|
||||
<Text style={[styles.notifTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: subText }]}>{item.time}</Text>
|
||||
</View>
|
||||
<Text style={[styles.desc, { color: subText }]} numberOfLines={2}>
|
||||
{item.desc}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</AIPressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={{ height: 110 }} />
|
||||
</View>
|
||||
</AppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 22 },
|
||||
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||
|
||||
list: { paddingHorizontal: 24 },
|
||||
notifPressable: { marginBottom: 10 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
},
|
||||
iconBox: {
|
||||
width: 48, height: 48, borderRadius: 14,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 14, flexShrink: 0,
|
||||
},
|
||||
body: { flex: 1 },
|
||||
topRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 },
|
||||
notifTitle: { fontSize: 15, fontFamily: 'Outfit_700Bold', flex: 1, marginRight: 8 },
|
||||
time: { fontSize: 11, fontFamily: 'Outfit_500Medium', flexShrink: 0, marginTop: 1 },
|
||||
desc: { fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { ThemeProvider, useAppTheme } from '../context/ThemeContext';
|
||||
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||
import { ToastProvider } from '../context/ToastContext';
|
||||
import { RefreshProvider, useRefresh } from '../context/RefreshContext';
|
||||
import { ConfigProvider, useAppConfig } from '../context/ConfigContext';
|
||||
import { LanguageProvider } from '../context/LanguageContext';
|
||||
import { View, Platform, StyleSheet, TouchableOpacity, Text, ActivityIndicator, StatusBar } from 'react-native';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useFonts, Outfit_300Light, Outfit_400Regular, Outfit_500Medium, Outfit_600SemiBold, Outfit_700Bold, Outfit_800ExtraBold } from '@expo-google-fonts/outfit';
|
||||
import { AnimatedSplash } from '../components/AnimatedSplash';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded, fontError] = useFonts({
|
||||
Outfit_300Light,
|
||||
Outfit_400Regular,
|
||||
Outfit_500Medium,
|
||||
Outfit_600SemiBold,
|
||||
Outfit_700Bold,
|
||||
Outfit_800ExtraBold,
|
||||
});
|
||||
|
||||
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fontsLoaded || fontError) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [fontsLoaded, fontError]);
|
||||
|
||||
if (!fontsLoaded && !fontError) return <View style={{ flex: 1, backgroundColor: '#020617' }} />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<RefreshProvider>
|
||||
<ToastProvider>
|
||||
{!isAnimationComplete ? (
|
||||
<AnimatedSplash key="splash-screen" onAnimationComplete={() => setIsAnimationComplete(true)} />
|
||||
) : (
|
||||
<RootLayoutContent key="main-app-content" />
|
||||
)}
|
||||
</ToastProvider>
|
||||
</RefreshProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
import { KillSwitchOverlay } from '../components/KillSwitchOverlay';
|
||||
import { AnnouncementBanner } from '../components/AnnouncementBanner';
|
||||
|
||||
function RootLayoutContent() {
|
||||
// Safe hook call because it's now guaranteed to be rendered ONLY when providers are ready and animation is done
|
||||
const { colors } = useAppTheme();
|
||||
const { config, isConnected, isSyncing, syncConfig } = useAppConfig();
|
||||
const [announcementDismissed, setAnnouncementDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) syncConfig();
|
||||
}, [isConnected]);
|
||||
|
||||
// Logic for Kill Switch & Maintenance
|
||||
const isKillSwitchActive = config?.control_center?.kill_switch_active || false;
|
||||
|
||||
// Calculate if maintenance is currently active
|
||||
const isInMaintenanceWindow = () => {
|
||||
const start = config?.control_center?.maintenance_start_at;
|
||||
const end = config?.control_center?.maintenance_end_at;
|
||||
if (!start || !end) return false;
|
||||
|
||||
const now = new Date().getTime();
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = new Date(end).getTime();
|
||||
|
||||
return now >= startTime && now <= endTime;
|
||||
};
|
||||
|
||||
const shouldBlockAccess = isKillSwitchActive || isInMaintenanceWindow();
|
||||
|
||||
// Logic for Force Update — current version comes from synced config so admin can advertise the released build
|
||||
const currentAppVersion = config?.app_updates?.app_version || "2.0.0";
|
||||
const minVersion = config?.app_updates?.min_app_version || "1.0.0";
|
||||
|
||||
const isUpdateRequired = () => {
|
||||
if (!minVersion) return false;
|
||||
const current = currentAppVersion.split('.').map(Number);
|
||||
const min = minVersion.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(current.length, min.length); i++) {
|
||||
const v1 = current[i] || 0;
|
||||
const v2 = min[i] || 0;
|
||||
if (v1 < v2) return true;
|
||||
if (v1 > v2) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUpdating = isUpdateRequired();
|
||||
|
||||
const killSwitchMessage = isKillSwitchActive
|
||||
? config?.control_center?.kill_switch_message
|
||||
: (isInMaintenanceWindow() ? "System is currently undergoing scheduled maintenance." : "");
|
||||
|
||||
const updateMessage = `A mandatory update (v${minVersion}) is required to continue using the app. Please update from the store.`;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<StatusBar barStyle={Platform.OS === 'ios' ? 'dark-content' : 'default'} />
|
||||
|
||||
{/* 1. Global Announcement */}
|
||||
<AnnouncementBanner
|
||||
visible={!!config?.control_center?.announcement_enabled && !announcementDismissed && !shouldBlockAccess}
|
||||
message={config?.control_center?.announcement_text || ''}
|
||||
type={config?.control_center?.announcement_type}
|
||||
onClose={() => setAnnouncementDismissed(true)}
|
||||
/>
|
||||
|
||||
{/* 2. Kill Switch / Maintenance Overlay */}
|
||||
<KillSwitchOverlay
|
||||
visible={shouldBlockAccess}
|
||||
message={killSwitchMessage}
|
||||
supportEmail={config?.support_social?.support_email}
|
||||
/>
|
||||
|
||||
{/* 3. Force Update Overlay */}
|
||||
<KillSwitchOverlay
|
||||
visible={isUpdating && !shouldBlockAccess}
|
||||
message={updateMessage}
|
||||
/>
|
||||
|
||||
{/* Offline Indicator */}
|
||||
{!isConnected && (
|
||||
<View style={[styles.offlineBanner, { backgroundColor: colors.error }]}>
|
||||
<Feather name="wifi-off" size={14} color="#FFF" />
|
||||
<Text style={styles.offlineText}>You are currently offline.</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Sync Indicator */}
|
||||
{isSyncing && isConnected && (
|
||||
<View style={styles.syncIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Stack screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
animation: 'fade'
|
||||
}}>
|
||||
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ animation: 'fade' }} />
|
||||
</Stack>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
offlineBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
zIndex: 999,
|
||||
},
|
||||
offlineText: {
|
||||
color: '#FFF',
|
||||
fontSize: 12,
|
||||
fontFamily: 'Outfit_600SemiBold',
|
||||
},
|
||||
syncIndicator: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'ios' ? 60 : 40,
|
||||
right: 20,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 20,
|
||||
padding: 4,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Image, TouchableOpacity, Dimensions, ScrollView, Animated, Platform
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useAppTheme } from '../../context/ThemeContext';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const LIME = '#C6F135';
|
||||
const IMG_H = 340;
|
||||
|
||||
export default function DetailScreen() {
|
||||
const { id, title, category, img, author } = useLocalSearchParams();
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const bg = isDark ? '#111111' : '#F5F5F5';
|
||||
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||
const subText = isDark ? '#6B6B6B' : '#9B9B9B';
|
||||
|
||||
const imageTranslateY = scrollY.interpolate({
|
||||
inputRange: [0, IMG_H],
|
||||
outputRange: [0, -IMG_H / 3],
|
||||
extrapolate: 'clamp'
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: bg }]}>
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={Animated.event(
|
||||
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
>
|
||||
{/* ── Hero image with parallax ── */}
|
||||
<View style={styles.imageContainer}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: imageTranslateY }] }]}>
|
||||
<Image source={{ uri: img as string }} style={styles.heroImg} />
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={['transparent', isDark ? '#111111' : '#F5F5F5']}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ── Content card ── */}
|
||||
<View style={[styles.contentCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||
<View style={styles.badgeRow}>
|
||||
<View style={[styles.catBadge, { backgroundColor: `${LIME}25` }]}>
|
||||
<Text style={styles.catBadgeText}>{category}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
|
||||
|
||||
<View style={[styles.authorRow, { borderBottomColor: border }]}>
|
||||
<Image
|
||||
source={{ uri: `https://i.pravatar.cc/100?u=${author}` }}
|
||||
style={styles.authorImg}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[styles.authorName, { color: colors.text }]}>{author}</Text>
|
||||
<Text style={[styles.date, { color: subText }]}>Published 2 hours ago</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.paragraph, { color: subText }]}>
|
||||
This is a deep dive into the topic of{' '}
|
||||
<Text style={{ color: colors.text, fontFamily: 'Outfit_600SemiBold' }}>{title}</Text>.
|
||||
Implementing modern technologies requires a balance between performance and aesthetics.
|
||||
In biiproject, we prioritize the user experience by using the latest React Native features.
|
||||
</Text>
|
||||
<Text style={[styles.paragraph, { color: subText }]}>
|
||||
Our modernization engine ensures that every pixel is optimized, every transition is smooth,
|
||||
and every interaction feels alive with haptic feedback and fluid motion.
|
||||
</Text>
|
||||
|
||||
<View style={[styles.highlightBox, { backgroundColor: isDark ? '#222222' : '#F5F5F5' }]}>
|
||||
<Feather name="info" size={16} color={LIME} style={{ marginRight: 10 }} />
|
||||
<Text style={[styles.highlightText, { color: subText }]}>
|
||||
This content is curated by our AI engine and updated daily.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ height: 120 }} />
|
||||
</Animated.ScrollView>
|
||||
|
||||
{/* ── Floating back button ── */}
|
||||
<TouchableOpacity
|
||||
style={styles.backBtn}
|
||||
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); router.back(); }}
|
||||
>
|
||||
<BlurView intensity={40} tint="dark" style={styles.blurBtn}>
|
||||
<Feather name="chevron-left" size={22} color="#FFFFFF" />
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
imageContainer: { width, height: IMG_H, overflow: 'hidden' },
|
||||
heroImg: { width: '100%', height: IMG_H + 60, resizeMode: 'cover' },
|
||||
gradient: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 160 },
|
||||
contentCard: { marginHorizontal: 16, marginTop: -32, borderRadius: 28, borderWidth: 1, padding: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.08, shadowRadius: 20, elevation: 6 },
|
||||
badgeRow: { marginBottom: 12 },
|
||||
catBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
|
||||
catBadgeText: { fontSize: 10, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#5A7000' },
|
||||
title: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', lineHeight: 34, marginBottom: 20 },
|
||||
authorRow: { flexDirection: 'row', alignItems: 'center', paddingBottom: 20, marginBottom: 20, borderBottomWidth: 1 },
|
||||
authorImg: { width: 42, height: 42, borderRadius: 21, marginRight: 12 },
|
||||
authorName: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
|
||||
date: { fontSize: 12, fontFamily: 'Outfit_400Regular', marginTop: 2 },
|
||||
paragraph: { fontSize: 15, fontFamily: 'Outfit_400Regular', lineHeight: 26, marginBottom: 18 },
|
||||
highlightBox: { flexDirection: 'row', alignItems: 'flex-start', borderRadius: 14, padding: 14, marginTop: 6 },
|
||||
highlightText: { flex: 1, fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 20 },
|
||||
backBtn: { position: 'absolute', top: Platform.OS === 'ios' ? 52 : 32, left: 20, zIndex: 100 },
|
||||
blurBtn: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'expo-router';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Index() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
// Wait for auth to settle
|
||||
if (isLoading) return null;
|
||||
|
||||
// Smart redirect: If we have a user with an ID, go to dashboard, otherwise login
|
||||
const isAuthenticated = !!(user && user.id);
|
||||
|
||||
return <Redirect href={isAuthenticated ? '/(tabs)' : '/(auth)/login'} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user