Sergei 966d8e2aba Various improvements and fixes
- Added ImageLightbox component for avatar viewing
- Updated beneficiary detail page with lightbox support
- Profile page improvements
- Bug page cleanup
- API and context updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 08:25:39 -08:00

481 lines
14 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Image,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker';
import * as Clipboard from 'expo-clipboard';
// ImageLightbox removed - tap on avatar directly opens picker
import { optimizeAvatarImage } from '@/utils/imageUtils';
import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { ProfileDrawer } from '@/components/ProfileDrawer';
import { useToast } from '@/components/ui/Toast';
import {
AppColors,
BorderRadius,
FontSizes,
Spacing,
FontWeights,
Shadows,
AvatarSizes,
} from '@/constants/theme';
// Generate stable 5-digit invite code from user identifier
const generateInviteCode = (identifier: string): string => {
// Simple hash to get a stable code
let hash = 0;
for (let i = 0; i < identifier.length; i++) {
const char = identifier.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
// Convert to 5-digit code (10000-99999 range)
const code = 10000 + (Math.abs(hash) % 90000);
return code.toString();
};
export default function ProfileScreen() {
const { user, logout } = useAuth();
const { clearAllBeneficiaryData } = useBeneficiary();
const toast = useToast();
// Drawer state
const [drawerVisible, setDrawerVisible] = useState(false);
// Settings states
const [settings, setSettings] = useState({
pushNotifications: true,
emailNotifications: false,
biometricLogin: false,
});
// Avatar
const [avatarUri, setAvatarUri] = useState<string | null>(null);
useEffect(() => {
loadAvatar();
}, [user?.user_id]);
const loadAvatar = async () => {
try {
const uri = await SecureStore.getItemAsync('userAvatar');
if (uri) {
setAvatarUri(uri);
}
} catch (err) {
console.error('Failed to load avatar:', err);
}
};
// Change avatar (tap on avatar or camera badge)
const handleAvatarChange = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'Please allow access to your photo library to change avatar.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const originalUri = result.assets[0].uri;
// Optimize image: resize to 400x400 and compress
const optimizedUri = await optimizeAvatarImage(originalUri);
setAvatarUri(optimizedUri);
await SecureStore.setItemAsync('userAvatar', optimizedUri);
toast.success('Avatar updated');
}
};
const handleSettingChange = (key: string, value: boolean) => {
setSettings(prev => ({ ...prev, [key]: value }));
if (key === 'biometricLogin' && value) {
Alert.alert('Biometric Login', 'Biometric authentication enabled!');
}
};
const handleLogout = () => {
setDrawerVisible(false);
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
// Clear local UI state
setAvatarUri(null);
// Clear beneficiary context
await clearAllBeneficiaryData();
// Logout (clears SecureStore and AsyncStorage)
await logout();
router.replace('/(auth)/login');
},
},
]
);
};
const displayName = useMemo(() => {
if (user?.firstName) {
return user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName;
}
if (user?.email) return user.email.split('@')[0];
return 'User';
}, [user?.firstName, user?.lastName, user?.email]);
const userInitial = displayName.charAt(0).toUpperCase();
// Generate invite code based on user email or id
const inviteCode = useMemo(() => {
const identifier = user?.email || user?.user_id?.toString() || 'default';
return generateInviteCode(identifier);
}, [user?.email, user?.user_id]);
const handleCopyInviteCode = async () => {
await Clipboard.setStringAsync(inviteCode);
toast.success(`Invite code "${inviteCode}" copied to clipboard`);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.menuButton}
onPress={() => setDrawerVisible(true)}
>
<Ionicons name="menu" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Profile</Text>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Profile Card */}
<View style={styles.profileCard}>
<View style={styles.avatarSection}>
<TouchableOpacity
style={styles.avatarContainer}
onPress={handleAvatarChange}
>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<Text style={styles.avatarText}>{userInitial}</Text>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.avatarEditBadge} onPress={handleAvatarChange}>
<Ionicons name="camera" size={14} color={AppColors.white} />
</TouchableOpacity>
</View>
<Text style={styles.displayName}>{displayName}</Text>
<Text style={styles.userEmail}>{user?.email || ''}</Text>
{/* Invite Code */}
<TouchableOpacity style={styles.inviteCodeSection} onPress={handleCopyInviteCode}>
<View style={styles.inviteCodeBadge}>
<Ionicons name="gift-outline" size={16} color={AppColors.primary} />
<Text style={styles.inviteCodeLabel}>Your Invite Code</Text>
</View>
<View style={styles.inviteCodeBox}>
<Text style={styles.inviteCodeText}>{inviteCode}</Text>
<Ionicons name="copy-outline" size={18} color={AppColors.primary} />
</View>
<Text style={styles.inviteCodeHint}>Tap to copy · Share with friends for rewards</Text>
</TouchableOpacity>
</View>
{/* Menu Items */}
<View style={styles.menuSection}>
<TouchableOpacity
style={styles.menuItem}
onPress={() => router.push('/(tabs)/profile/edit')}
>
<View style={styles.menuIcon}>
<Ionicons name="person-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>Edit Profile</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuItem}
onPress={() => router.push('/(tabs)')}
>
<View style={styles.menuIcon}>
<Ionicons name="people-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>My Loved Ones</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuItem}
onPress={() => setDrawerVisible(true)}
>
<View style={styles.menuIcon}>
<Ionicons name="settings-outline" size={22} color={AppColors.textSecondary} />
</View>
<Text style={styles.menuLabel}>Settings</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.menuItem, styles.menuItemLast]}
onPress={handleLogout}
>
<View style={styles.menuIcon}>
<Ionicons name="log-out-outline" size={22} color={AppColors.error} />
</View>
<Text style={styles.menuLabel}>Log Out</Text>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
</View>
</ScrollView>
{/* Settings Drawer */}
<ProfileDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onLogout={handleLogout}
settings={settings}
onSettingChange={handleSettingChange}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
menuButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 40,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Profile Card
profileCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
avatarSection: {
marginBottom: Spacing.md,
position: 'relative',
},
avatarContainer: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
avatarImage: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
},
avatarText: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.white,
},
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: AppColors.surface,
},
displayName: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
userEmail: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
// Invite Code
inviteCodeSection: {
marginTop: Spacing.lg,
alignItems: 'center',
paddingTop: Spacing.md,
borderTopWidth: 1,
borderTopColor: AppColors.border,
width: '100%',
},
inviteCodeBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginBottom: Spacing.sm,
},
inviteCodeLabel: {
fontSize: FontSizes.xs,
color: AppColors.primary,
fontWeight: FontWeights.medium,
},
inviteCodeBox: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
},
inviteCodeText: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.primary,
letterSpacing: 3,
},
inviteCodeHint: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.xs,
},
// Menu Section
menuSection: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
overflow: 'hidden',
...Shadows.sm,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
menuItemLast: {
borderBottomWidth: 0,
},
menuIcon: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
menuIconDanger: {
backgroundColor: AppColors.errorLight,
},
menuLabel: {
flex: 1,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
fontWeight: FontWeights.medium,
},
// Legacy styles (keeping for potential use)
subscriptionCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
subscriptionCardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
subscriptionIconContainer: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
subscriptionIconActive: {
backgroundColor: AppColors.successLight,
},
subscriptionIconInactive: {
backgroundColor: `${AppColors.warning}20`,
},
subscriptionHeaderText: {
flex: 1,
},
subscriptionCardTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
subscriptionCardSubtitle: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
});