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
+112
View File
@@ -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',
},
});
+389
View File
@@ -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' },
});
+207
View File
@@ -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' },
});
+250
View File
@@ -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 },
});
+132
View File
@@ -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 },
});