Implemented proper BLE cleanup mechanism on user logout: **Root Cause:** - BLE cleanup callback was being set but reference could become stale - No explicit cleanup call in profile logout handler - Callback stability issues due to re-renders **Changes:** 1. app/_layout.tsx: - Use useRef pattern to maintain stable callback reference - Set callback once with ref that always points to current cleanupBLE - Cleanup callback on unmount to prevent memory leaks 2. app/(tabs)/profile/index.tsx: - Add explicit cleanupBLE() call in logout handler - Import useBLE hook to access cleanup function - Ensure cleanup happens before logout completes 3. services/api.ts: - Update setOnLogoutBLECleanupCallback signature to accept null - Allows proper cleanup of callback on unmount 4. jest.setup.js: - Add AsyncStorage mock to prevent test failures 5. Tests: - Add comprehensive BLE cleanup tests - Test callback pattern and stability - Test logout flow with BLE cleanup - Test error handling during cleanup **Result:** BLE connections now properly disconnect when user logs out, preventing stale connections and potential resource leaks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
Alert,
|
|
Image,
|
|
ScrollView,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { api } from '@/services/api';
|
|
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 { useBLE } from '@/contexts/BLEContext';
|
|
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 { cleanupBLE } = useBLE();
|
|
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);
|
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadAvatar();
|
|
}, [user?.user_id]);
|
|
|
|
const loadAvatar = async () => {
|
|
try {
|
|
// First try to get cloud URL from user profile
|
|
if (user?.avatarUrl) {
|
|
setAvatarUri(user.avatarUrl);
|
|
await SecureStore.setItemAsync('userAvatar', user.avatarUrl);
|
|
return;
|
|
}
|
|
// Fallback to cached local avatar
|
|
const uri = await SecureStore.getItemAsync('userAvatar');
|
|
if (uri) {
|
|
setAvatarUri(uri);
|
|
}
|
|
} catch (err) {
|
|
// Silently ignore
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Show optimistic update immediately
|
|
setAvatarUri(optimizedUri);
|
|
|
|
// Upload to cloud storage
|
|
setIsUploadingAvatar(true);
|
|
try {
|
|
const response = await api.updateProfileAvatar(optimizedUri);
|
|
if (response.ok && response.data?.avatarUrl) {
|
|
// Use cloud URL instead of local
|
|
setAvatarUri(response.data.avatarUrl);
|
|
await SecureStore.setItemAsync('userAvatar', response.data.avatarUrl);
|
|
toast.success('Avatar updated');
|
|
} else {
|
|
// Fallback to local storage if cloud upload fails
|
|
await SecureStore.setItemAsync('userAvatar', optimizedUri);
|
|
toast.error(response.error?.message || 'Cloud upload failed, saved locally');
|
|
}
|
|
} catch (error) {
|
|
await SecureStore.setItemAsync('userAvatar', optimizedUri);
|
|
toast.error('Upload failed, saved locally');
|
|
} finally {
|
|
setIsUploadingAvatar(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
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();
|
|
// Cleanup BLE connections explicitly (also called via api.logout callback)
|
|
await cleanupBLE();
|
|
// Logout (clears SecureStore and AsyncStorage, calls BLE cleanup callback)
|
|
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}
|
|
disabled={isUploadingAvatar}
|
|
>
|
|
{avatarUri ? (
|
|
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
|
) : (
|
|
<Text style={styles.avatarText}>{userInitial}</Text>
|
|
)}
|
|
{isUploadingAvatar && (
|
|
<View style={styles.avatarLoadingOverlay}>
|
|
<ActivityIndicator size="large" color={AppColors.white} />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={styles.avatarEditBadge}
|
|
onPress={handleAvatarChange}
|
|
disabled={isUploadingAvatar}
|
|
>
|
|
<Ionicons name="camera" size={14} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Text style={styles.displayName} numberOfLines={1}>{displayName}</Text>
|
|
<Text style={styles.userEmail} numberOfLines={1}>{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,
|
|
},
|
|
avatarLoadingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
borderRadius: AvatarSizes.xl / 2,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
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,
|
|
textAlign: 'center',
|
|
},
|
|
userEmail: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
textAlign: 'center',
|
|
},
|
|
// 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,
|
|
},
|
|
});
|