feat: add expo mobile application source code
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user